source: Klonkt/src/config/database.js@ 8a474ab

main
Last change on this file since 8a474ab was 8a474ab, checked in by Robin <roboburr@โ€ฆ>, 6 hours ago

Poorten per soort: ook bij het versturen, en film heeft er nu een

Twee gaten die naast elkaar stonden en elkaar versterkten.

gate_images en gate_music golden alleen bij het SERVEREN (shaer-qc9o). Wat een
ward niet te zien kreeg mocht hij wel plaatsen, dus het stond bij iedereen
behalve bij hemzelf. Dat is geen poort meer maar een filter op zijn eigen
scherm, en dat is niet wat een guardian dichtzet. De innamekant weigert nu met
gated_images / gated_music / gated_video, in dezelfde vorm als compose en
messages.

Weigeren en niet wegknippen: stilletjes de bijlage verwijderen publiceert een
bericht dat het kind niet geschreven heeft.

En film had helemaal geen poort (shaer-mxh2), dus de zwaarste soort van de drie
was de enige die altijd door mocht. Nu een gewone rij in de catalogus met een
eigen kolom, aan beide kanten afgedwongen: gateAttachments knipt video weg als
hij dicht staat (ook als hij als AS2-Video zonder mediaType binnenkomt) en de
outbox neemt hem niet aan. shaer:video staat in de caps, zodat de app het
vooraf weet in plaats van het bij de eerste weigering te ontdekken.

De reddingsboei gaat door alle drie heen, zoals door elke andere dichte deur.
Een hulpvraag draagt juist vaak een schermafdruk.

uploadMedia zelf blijft open, en dat is met opzet: de boei gebruikt dezelfde
poot en de server weet daar nog niet waar een upload voor bedoeld is. Een
bijlage die blijft liggen kost een bestand; een boei die vastloopt kost meer.
De weigering valt bij het innemen van de post, en daar is het wel bekend.

Getoetst in test/gates-family.test.js, naast de poorten die er al stonden.
Tegenproef: met de video-regel en de innamelus eruit vallen precies de twee
nieuwe toetsen om.

  • Property mode set to 100644
File size: 65.0 KB
Lineย 
1import Database from 'better-sqlite3';
2import path from 'path';
3import { fileURLToPath } from 'url';
4import fs from 'fs';
5
6const __dirname = path.dirname(fileURLToPath(import.meta.url));
7const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../storage/database.sqlite');
8
9// Ensure storage directory exists
10const storageDir = path.dirname(dbPath);
11if (!fs.existsSync(storageDir)) {
12 fs.mkdirSync(storageDir, { recursive: true });
13}
14
15// Initialize database
16const db = new Database(dbPath);
17db.pragma('journal_mode = WAL');
18db.pragma('foreign_keys = ON');
19// With WAL + several concurrent writers (request handlers, the delivery worker, the
20// background thread-crawler) a short write-lock should retry rather than throw SQLITE_BUSY.
21db.pragma('busy_timeout = 5000'); // wait up to 5s for a lock instead of failing immediately
22db.pragma('synchronous = NORMAL'); // safe with WAL (no torn writes); fewer fsyncs = faster writes
23
24/**
25 * Tijdstempels in EEN spelling (shaer-a937).
26 *
27 * Deze database draagt twee vormen door elkaar: '2026-08-06 09:02:01' van
28 * CURRENT_TIMESTAMP en '2026-08-06T02:08:01.000Z' van toISOString(). SQLite
29 * vergelijkt ze als TEKST, en op positie 10 staat een 'T' (0x54) tegenover een
30 * spatie (0x20) -- dus binnen dezelfde dag wint de ISO-vorm altijd, hoe laat hij
31 * ook is. Een antwoord van 02:08 kwam zo boven een like van 09:02 te staan.
32 *
33 * `NU_ISO` is wat je SCHRIJFT, `isoSql()` is waarmee je VERGELIJKT of SORTEERT.
34 * De twee horen bij elkaar: het eerste zorgt dat er niets nieuws bijkomt, het
35 * tweede dat wat er al staat toch goed op volgorde komt.
36 *
37 * isoSql valt met COALESCE terug op de RAUWE waarde: strftime geeft NULL op iets
38 * dat het niet als tijd herkent, en zonder die terugval zou zo'n rij uit de
39 * sortering vallen -- of erger, als hij ook geSELECTeerd wordt (de cursor in de
40 * gesprekslezing) zou de client een lege stempel terugkrijgen en zijn plek
41 * kwijtraken. Waar zo'n onleesbare waarde dan LANDT is onbepaald: hij wordt als
42 * tekst vergeleken en 'geen datum' staat nu eenmaal boven '2026-...'. Dat is de
43 * juiste ruil -- data die je niet begrijpt bewaar je, je gooit hem niet weg.
44 *
45 * Ze staan HIER en niet in een dienst omdat ze over de opslag gaan: elke plek
46 * die sorteert importeert `db` toch al uit dit bestand.
47 */
48export const NU_ISO = "strftime('%Y-%m-%dT%H:%M:%SZ','now')";
49export const isoSql = (expr) => `COALESCE(strftime('%Y-%m-%dT%H:%M:%SZ', ${expr}), ${expr})`;
50
51export function initializeDatabase() {
52 const tableExists = db.prepare(`
53 SELECT name FROM sqlite_master WHERE type='table' AND name='users'
54 `).get();
55
56 if (!tableExists) {
57 console.log('๐Ÿ”ง Initializing database schema...');
58 const schemaPath = path.join(__dirname, '..', 'db', 'migrations', '001-init.sql');
59 const schema = fs.readFileSync(schemaPath, 'utf-8');
60 db.exec(schema);
61 console.log('โœ… Database initialized with v9-soul schema');
62 }
63
64 // Additive column migrations โ€” safe to run every boot.
65 // SQLite throws if the column already exists; we swallow that.
66 ensureColumn('sites', 'enable_audio_player', 'INTEGER DEFAULT 1');
67 // Eigenaarspoort (Robins wens, 18-8-2026): volgers niet automatisch
68 // accepteren maar door de eigenaar laten beslissen, op z'n fediverse.
69 // STANDAARD AAN (ook Robins wens, zelfde dag): een nieuwe of bijgewerkte
70 // klonkt beschermt zijn eigenaar meteen; uitzetten is de bewuste keuze.
71 ensureColumn('sites', 'approve_followers', 'INTEGER DEFAULT 1');
72 // (Verwijderd 31-7-2026: sites.guardian_only en ap_guardian_invites hoorden
73 // bij de guardian-lite accounts. Bestaande installaties houden kolom en tabel
74 // ongebruikt; nieuwe krijgen ze niet meer.)
75 // FEP-633c ยง5.3: follows targeting a ward are held pending until its
76 // guardians approve (Guardian 2). Gating applies only to ward-actors.
77 db.exec(`CREATE TABLE IF NOT EXISTS ap_pending_follows (
78 id TEXT PRIMARY KEY,
79 ward_slug TEXT NOT NULL,
80 follower_uri TEXT NOT NULL,
81 follower_inbox TEXT,
82 follower_shared_inbox TEXT,
83 follower_name TEXT,
84 follower_handle TEXT,
85 follower_icon TEXT,
86 activity_json TEXT,
87 quorum TEXT DEFAULT 'any',
88 status TEXT DEFAULT 'pending',
89 created_at TEXT DEFAULT CURRENT_TIMESTAMP
90 )`);
91 db.exec(`CREATE TABLE IF NOT EXISTS ap_pending_follow_approvals (
92 follow_id TEXT NOT NULL,
93 guardian_uri TEXT NOT NULL,
94 decision TEXT NOT NULL,
95 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
96 PRIMARY KEY (follow_id, guardian_uri)
97 )`);
98 // FEP-633c ยง5.3, the OTHER direction (shaer-p729): a ward's own follow is
99 // held until its guardians approve. Deliberately not ap_pending_follows โ€”
100 // that table is keyed with the ward as the TARGET ("who wants to follow me"),
101 // and adding a direction column would make every existing query ambiguous.
102 db.exec(`CREATE TABLE IF NOT EXISTS ap_pending_outgoing_follows (
103 id TEXT PRIMARY KEY,
104 ward_slug TEXT NOT NULL,
105 target_uri TEXT NOT NULL,
106 target_inbox TEXT,
107 target_name TEXT,
108 target_handle TEXT,
109 target_icon TEXT,
110 quorum TEXT DEFAULT 'any',
111 status TEXT DEFAULT 'pending',
112 created_at TEXT DEFAULT CURRENT_TIMESTAMP
113 )`);
114 db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ap_outgoing_follows_target
115 ON ap_pending_outgoing_follows(ward_slug, target_uri)`);
116 // Wat er gebeurd is, en waarom (shaer-p729, ยง4.2). Guardianship-events waren
117 // vluchtig: onGuardianshipEvent wekte de long-poll en stuurde eventueel een
118 // push, en de rest van de gebeurtenis loste op. Een weigering droeg
119 // `reason: 'not_a_teapot'` tot in die functie en verder niet -- de ward en
120 // zijn guardians hoorden het alleen doordat het aanbod uit de wachtrij
121 // verdween. ยง4.2 eist dat ze het TE HOREN krijgen, met de reden erbij.
122 //
123 // Een logboek, geen wachtrij: hier staat niets dat om een antwoord vraagt.
124 // Daarom hoort het ook ingeklapt te staan -- naast wat nog wel wacht, maakt
125 // afgelopen nieuws de open vraag onleesbaar.
126 db.exec(`CREATE TABLE IF NOT EXISTS ap_guardian_events (
127 id INTEGER PRIMARY KEY AUTOINCREMENT,
128 slug TEXT NOT NULL,
129 kind TEXT NOT NULL,
130 payload TEXT,
131 created_at TEXT DEFAULT CURRENT_TIMESTAMP
132 )`);
133 db.exec('CREATE INDEX IF NOT EXISTS idx_ap_guardian_events_slug ON ap_guardian_events(slug, id DESC)');
134 db.exec(`CREATE TABLE IF NOT EXISTS ap_outgoing_follow_approvals (
135 follow_id TEXT NOT NULL,
136 guardian_uri TEXT NOT NULL,
137 decision TEXT NOT NULL,
138 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
139 PRIMARY KEY (follow_id, guardian_uri)
140 )`);
141 // Cross-instance follow-approval (modelled on the guardian offer): the
142 // guardian-side COPY of a gated follow on a REMOTE ward, forwarded here by
143 // the ward's server as an Offer(Follow). The decision is sent back to the
144 // ward's inbox. (Local wards use ap_pending_follows directly.)
145 db.exec(`CREATE TABLE IF NOT EXISTS ap_follow_reviews (
146 id TEXT NOT NULL,
147 guardian_slug TEXT NOT NULL,
148 ward_uri TEXT NOT NULL,
149 ward_inbox TEXT,
150 follower_uri TEXT NOT NULL,
151 follower_handle TEXT,
152 follower_icon TEXT,
153 follow_json TEXT,
154 status TEXT DEFAULT 'pending',
155 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
156 PRIMARY KEY (guardian_slug, id)
157 )`);
158 // Guardianship Fase 2 (shaer-jdb): een doorgestuurde follow-goedkeuring draagt
159 // een RICHTING. Bij een inkomende is de follower iemand anders en de ward het
160 // doel; bij een uitgaande is de ward zelf de follower en staat het doel in het
161 // Follow-object. Zonder deze twee kolommen werd een uitgaande opgeslagen als
162 // "deze ward wil deze ward volgen" en viel het doel weg -- dan valt er niets
163 // zinnigs te tonen, hoe je de wachtrij ook vult.
164 ensureColumn('ap_follow_reviews', 'direction', "TEXT DEFAULT 'incoming'");
165 ensureColumn('ap_follow_reviews', 'target_uri', 'TEXT');
166 ensureColumn('ap_follow_reviews', 'target_handle', 'TEXT');
167 ensureColumn('sites', 'profile_photo', 'TEXT');
168 // De MusicBrainz-koppeling van de artiest (shaer-mbz). Een MBID is een
169 // verwijzing naar hun register, geen kopie ervan -- de naam staat erbij zodat
170 // het beheerscherm kan tonen WAT er gekoppeld is zonder ervoor te moeten
171 // netwerken, en zodat een verkeerde koppeling opvalt.
172 ensureColumn('sites', 'mb_artist_id', 'TEXT');
173 ensureColumn('sites', 'mb_artist_name', 'TEXT');
174 // Een UITGAVE heeft twee dingen die een afspeellijst niet heeft (shaer-756s).
175 //
176 // release_date en niet `year`: die kolom bestaat al en blijft, maar hun
177 // AlbumSerializer leest `released` als een DateField. Een jaartal als
178 // 2024-01-01 versturen is een dag verzinnen, en dat is precies wat we bij
179 // artiesten en albums niet doen. Volledige datum of niets.
180 //
181 // mb_release_id is de tegenhanger van sites.mb_artist_id: dezelfde soort
182 // verwijzing naar MusicBrainz, een niveau lager.
183 //
184 // Ze horen ALLEEN bij kind='album'. PlaylistService dwingt dat af bij het
185 // opslaan -- zie daar waarom dat niet alleen in het scherm mag zitten.
186 ensureColumn('playlists', 'release_date', 'TEXT');
187 ensureColumn('playlists', 'mb_release_id', 'TEXT');
188 ensureColumn('audio_tracks', 'cover_url', 'TEXT');
189 ensureColumn('audio_tracks', 'album', 'TEXT');
190 ensureColumn('users', 'reset_token', 'TEXT');
191 ensureColumn('users', 'reset_token_expires', 'DATETIME');
192 // Google OAuth: link a Google account to a user (login via Google).
193 ensureColumn('users', 'google_sub', 'TEXT');
194 // Read-only/viewer account: can view everything but make no changes.
195 ensureColumn('users', 'readonly', 'INTEGER DEFAULT 0');
196 // Personal interface language (nl|en|de). Null = follow the default (site/env/browser).
197 ensureColumn('users', 'lang', 'TEXT');
198 // Site-level moderation toggle. 'trust' = auto-approve, 'moderate' = pending until reviewed.
199 // Circles: whether this site may appear in other sites' circles (surfacing opt-out).
200 ensureColumn('sites', 'allow_circle', 'INTEGER DEFAULT 1');
201
202 // One EXPLICIT primary/main site (= the company/label site in hub mode,
203 // the only site in solo) instead of the fragile "oldest = main" convention
204 // that was duplicated in 4 places. Backfill: mark the oldest if no primary
205 // site exists yet, so existing behaviour is preserved exactly.
206 ensureColumn('sites', 'is_primary', 'INTEGER DEFAULT 0');
207 try {
208 const hasPrimary = db.prepare('SELECT 1 FROM sites WHERE is_primary = 1 LIMIT 1').get();
209 if (!hasPrimary) {
210 const oldest = db.prepare('SELECT id FROM sites ORDER BY created_at ASC LIMIT 1').get();
211 if (oldest) db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(oldest.id);
212 }
213 } catch (e) { /* sites table still empty/absent on fresh init โ€” ensurePrimarySite handles it */ }
214
215 // v9 audit additions โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”
216 // SEO/social columns the v9 template uses (most live in 001-init.sql already
217 // for fresh DBs but ensureColumn is idempotent for existing DBs).
218 ensureColumn('sites', 'twitter', 'TEXT'); // @handle (with @)
219 ensureColumn('sites', 'schema_type', "TEXT DEFAULT 'Person'"); // Person|Organization
220 ensureColumn('sites', 'publisher_name', 'TEXT');
221 ensureColumn('sites', 'publisher_url', 'TEXT');
222 ensureColumn('sites', 'publisher_logo', 'TEXT');
223 ensureColumn('sites', 'profile_enabled', 'INTEGER DEFAULT 1');
224 ensureColumn('sites', 'profile_name', 'TEXT'); // display name (falls back to title)
225 ensureColumn('sites', 'profile_bio', 'TEXT'); // short bio for header
226 ensureColumn('sites', 'profile_links', 'TEXT'); // JSON array [{platform, url}]
227 ensureColumn('sites', 'feed_view_default', "TEXT DEFAULT 'grid'"); // timeline | grid
228 ensureColumn('sites', 'feed_view_switch', 'INTEGER DEFAULT 1'); // show switcher
229 // Welke TWEEDE weergave deze site aanbiedt naast Grid.
230 // 'reader' Lezen, hele berichten, een per scherm
231 // 'timeline' de chronologische lijst
232 // 'auto' Lezen op mobiel, Tijdlijn op desktop (grens 768px)
233 // Lezen verving Tijdlijn, maar de tijdlijn is nooit weggehaald -- home.ejs
234 // rendert alle drie de secties en de CSS kiest. Dit maakt er weer een keuze
235 // van in plaats van een besluit voor iedereen. 'auto' kost geen JavaScript:
236 // het is een mediaquery, dus geen resize-afhandeling en geen flikkering.
237 ensureColumn('sites', 'feed_alt_view', "TEXT DEFAULT 'reader'"); // reader | timeline | auto
238 // Krijgt elk bericht in Lezen zijn EIGEN scherm? Aan betekent min-height:100svh
239 // per bericht: een kort bericht vult dan het scherm en je ziet het volgende
240 // pas na een snap. Uit is de stroom zoals hij nu is, bericht na bericht zonder
241 // lege ruimte. Stond eerst vast op AAN, is toen vast op UIT gezet omdat een
242 // kort bericht een halve pagina leegte gaf; nu is het een keuze.
243 ensureColumn('sites', 'reader_full_page', 'INTEGER DEFAULT 0');
244 ensureColumn('sites', 'show_search', 'INTEGER DEFAULT 1');
245 ensureColumn('sites', 'show_archive_link', 'INTEGER DEFAULT 1');
246 // Gated feature (FEP-633c): may external (non-fediverse) embeds be shown to
247 // this account? NULL = auto, which means OFF for a ward and ON for anyone
248 // else. The guardians flip it; the gate itself lives server-side, so a ward
249 // never even receives the thumbnail it is not allowed to see.
250 ensureColumn('sites', 'external_embeds', 'INTEGER');
251 // De rest van de gate-familie (shaer-ahy.1, "maak ze allemaal functioneel",
252 // Barts opdracht 8-8). Zelfde drietal als external_embeds: NULL is de
253 // automatiek (dicht voor een ward, open voor de rest), 0/1 is een besluit
254 // van de guardians en wint van de automatiek.
255 ensureColumn('sites', 'external_threads', 'INTEGER'); // replies van vreemden onder een post (shaer-9y2)
256 ensureColumn('sites', 'gate_replies', 'INTEGER'); // zelf antwoorden in een gesprek (shaer-r4c)
257 ensureColumn('sites', 'gate_images', 'INTEGER'); // afbeeldingsbijlagen (shaer-6p5)
258 ensureColumn('sites', 'gate_messages', 'INTEGER'); // heel Messages (shaer-3ow)
259 ensureColumn('sites', 'gate_compose', 'INTEGER'); // zelf posten, de (+) kaart (shaer-qgev)
260 ensureColumn('sites', 'gate_music', 'INTEGER'); // audiobijlagen (shaer-rmz)
261 // Film had als enige soort GEEN poort (shaer-mxh2). Dat is geen gat dat je
262 // kunt laten staan: een kind met plaatjes en muziek dicht kon wel een half
263 // uur video plaatsen, en dat is de zwaarste soort van de drie.
264 ensureColumn('sites', 'gate_video', 'INTEGER'); // videobijlagen (shaer-mxh2)
265 ensureColumn('sites', 'gate_quote_cards', 'INTEGER'); // ingebedde quote-kaarten (shaer-mls)
266 ensureColumn('sites', 'gate_custom_emoji', 'INTEGER'); // FEP-9098 emoji-plaatjes (shaer-ytw)
267 ensureColumn('sites', 'gate_account_move', 'INTEGER'); // FEP-7628 Move (shaer-tge)
268 // Wie de ward ZELF mag volgen (shaer-p729): de tegenhanger van shaer:follows,
269 // dat over de andere richting gaat. ยง5.3 schrijft alleen het doorsturen voor
270 // van een Follow NAAR een ward; wat je verder gated is een keuze van de
271 // implementatie, en dit is die keuze. Verstelbaar, anders dan de inkomende
272 // kant: een kind dat ouder wordt hoort niet eeuwig te blijven vragen.
273 ensureColumn('sites', 'gate_following', 'INTEGER'); // zelf iemand volgen (shaer-p729)
274 // The heavier sibling (FEP-633c 5.6): may a player from outside this app run
275 // INSIDE it? A preview is a picture; playback hands the screen to a third
276 // party's engine, recommendations and all. Two settings, so the guardians can
277 // allow the one without the other. NULL = auto, which means off for a ward.
278 ensureColumn('sites', 'external_playback', 'INTEGER');
279 ensureColumn('sites', 'og_theme', 'TEXT'); // OG share-card variant: NULL=auto (follow site theme) | 'light' | 'dark'
280 // FEP-7628: former identities this actor claims (JSON array of actor URIs).
281 // Publishing them as alsoKnownAs is what lets the OLD server approve a Move
282 // of its followers to this account โ€” the claim must be visible on OUR side.
283 ensureColumn('sites', 'ap_aliases', 'TEXT');
284 ensureColumn('sites', 'moved_to', 'TEXT'); // FEP-7628 slice 2: waarheen dit account vertrok
285 // FEP-1580: staat de ingest-routine nog open? 1 = klaar (en dat is ook de
286 // stand van een site die nooit iets gemigreerd heeft, want er hangt niets).
287 // Derden pollen op deze vlag, dus hij moet ook "er valt niets te wachten"
288 // kunnen zeggen.
289 ensureColumn('sites', 'migration_complete', 'INTEGER DEFAULT 1');
290
291 // Per-post noindex + type
292 ensureColumn('posts', 'noindex', 'INTEGER DEFAULT 0');
293 ensureColumn('posts', 'publish_at', 'DATETIME'); // release planning (premium #3): scheduled go-live
294 ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0'); // fan-only preview (premium #3)
295 ensureColumn('posts', 'nsfw', 'INTEGER DEFAULT 0'); // sensitive content โ†’ blur + click-to-reveal; fediverse sensitive
296 ensureColumn('posts', 'cover_video_url', 'TEXT'); // muted loop MP4 for an animated cover (Safari-smooth)
297 ensureColumn('posts', 'cover_alt', 'TEXT'); // alt text / description for the cover (a11y โ†’ AS2 attachment `name`)
298 ensureColumn('posts', 'language', 'TEXT'); // BCP-47 content language โ†’ federates as AS2 contentMap (Mastodon language filter/translate)
299 ensureColumn('posts', 'content_warning', 'TEXT'); // custom CW label (empty = default "Gevoelige inhoud")
300 ensureColumn('posts', 'type', "TEXT DEFAULT 'post'"); // post | foto | video | audio
301 ensureColumn('posts', 'poll_json', 'TEXT'); // a poll WE host โ†’ federates as AS2 Question: {multiple,options[{name}],endTime,closed}
302
303 // Statistics (premium module) โ€” bare counters, cookie-free.
304 ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0'); // views per post
305 ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0'); // plays per track
306 ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download-for-email (premium #2)
307 ensureColumn('audio_tracks', 'credit', 'TEXT'); // owner/credit (copyright holder)
308 ensureColumn('audio_tracks', 'license', 'TEXT'); // license (e.g. "CC BY 4.0", "All rights reserved")
309 ensureColumn('audio_tracks', 'link_spotify', 'TEXT'); // "open in" links per track
310 ensureColumn('audio_tracks', 'link_youtube', 'TEXT');
311 ensureColumn('audio_tracks', 'link_soundcloud', 'TEXT');
312 // Per-track: federate the actual audio file as an AS2 Audio attachment so it plays inline
313 // in EVERY fediverse client (incl. the Mastodon apps). Default 0 = gated (web player only,
314 // file not exposed). Opt-in 1 = the file is served ungated + shared on the fediverse.
315 ensureColumn('audio_tracks', 'fedi_open', 'INTEGER DEFAULT 0');
316
317 // Playlists (v9 feature) โ€” first-class entity. CREATE IF NOT EXISTS is
318 // idempotent so it's safe to run on every boot regardless of DB age.
319 db.exec(`
320 CREATE TABLE IF NOT EXISTS playlists (
321 id TEXT PRIMARY KEY,
322 site_id TEXT NOT NULL,
323 title TEXT NOT NULL,
324 artist TEXT,
325 year INTEGER,
326 cover_url TEXT,
327 kind TEXT DEFAULT 'album',
328 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
329 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
330 FOREIGN KEY (site_id) REFERENCES sites(id)
331 );
332 CREATE TABLE IF NOT EXISTS playlist_tracks (
333 playlist_id TEXT NOT NULL,
334 track_id TEXT NOT NULL,
335 position INTEGER NOT NULL DEFAULT 0,
336 PRIMARY KEY (playlist_id, track_id),
337 FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
338 FOREIGN KEY (track_id) REFERENCES audio_tracks(id) ON DELETE CASCADE
339 );
340 CREATE INDEX IF NOT EXISTS idx_playlist_tracks_pos
341 ON playlist_tracks(playlist_id, position);
342 `);
343
344 // Global app settings (key/value singleton). One instance is one owner, so
345 // there is no tenancy mode here anymore โ€” see SettingsService.
346 // Oudere installaties dragen nog een dode rij key='tenancy' ('solo' of
347 // 'circle'). Niets leest hem; bewust laten staan (shaer-x7c0) in plaats van
348 // er opruimcode voor te schrijven die na รฉรฉn ronde zelf dood is.
349 db.exec(`
350 CREATE TABLE IF NOT EXISTS app_settings (
351 key TEXT PRIMARY KEY,
352 value TEXT,
353 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
354 );
355 `);
356
357 // โ”€โ”€ Statistics (premium) โ€” cookie-free โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
358 // stat_daily: pageview count per day per site (bare counter).
359 // stat_visitor_day: one row per UNIQUE visitor hash per day per site
360 // (sha256 of IP+UA+day-salt; the salt rotates daily and is never stored
361 // โ†’ no persistent identifier, no cookie, no consent required).
362 db.exec(`
363 CREATE TABLE IF NOT EXISTS stat_daily (
364 site_id TEXT NOT NULL,
365 day TEXT NOT NULL,
366 pageviews INTEGER NOT NULL DEFAULT 0,
367 PRIMARY KEY (site_id, day)
368 );
369 CREATE TABLE IF NOT EXISTS stat_visitor_day (
370 site_id TEXT NOT NULL,
371 day TEXT NOT NULL,
372 visitor_hash TEXT NOT NULL,
373 PRIMARY KEY (site_id, day, visitor_hash)
374 );
375 CREATE INDEX IF NOT EXISTS idx_stat_visitor_day ON stat_visitor_day(site_id, day);
376 CREATE TABLE IF NOT EXISTS stat_referrer (
377 site_id TEXT NOT NULL,
378 host TEXT NOT NULL,
379 count INTEGER NOT NULL DEFAULT 0,
380 PRIMARY KEY (site_id, host)
381 );
382 `);
383
384 // Newsletter / mailing list (premium). Subscribers per site; double opt-in when SMTP
385 // is configured (status 'pending' until confirmed), otherwise single opt-in ('confirmed').
386 // 'unsub' = unsubscribed. token = confirm/unsubscribe key (used in email links).
387 db.exec(`
388 CREATE TABLE IF NOT EXISTS subscribers (
389 id TEXT PRIMARY KEY,
390 site_id TEXT NOT NULL,
391 email TEXT NOT NULL,
392 status TEXT NOT NULL DEFAULT 'pending',
393 source TEXT DEFAULT 'widget',
394 token TEXT NOT NULL,
395 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
396 confirmed_at DATETIME,
397 UNIQUE(site_id, email)
398 );
399 CREATE INDEX IF NOT EXISTS idx_subscribers_site_status ON subscribers(site_id, status);
400 `);
401
402 // Sent newsletters (history + counts).
403 db.exec(`
404 CREATE TABLE IF NOT EXISTS newsletters (
405 id TEXT PRIMARY KEY,
406 site_id TEXT NOT NULL,
407 subject TEXT NOT NULL,
408 body TEXT NOT NULL,
409 sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
410 recipient_count INTEGER DEFAULT 0
411 );
412 `);
413
414 // Show agenda (premium #8): tour dates / gigs per site.
415 db.exec(`
416 CREATE TABLE IF NOT EXISTS shows (
417 id TEXT PRIMARY KEY,
418 site_id TEXT NOT NULL,
419 date TEXT NOT NULL,
420 time TEXT,
421 city TEXT NOT NULL,
422 venue TEXT,
423 country TEXT,
424 ticket_url TEXT,
425 notes TEXT,
426 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
427 );
428 CREATE INDEX IF NOT EXISTS idx_shows_site_date ON shows(site_id, date);
429 `);
430
431 // Link-in-bio click statistics (premium #6). One counter per (site, url); the
432 // link-in-bio page links via /links/go/:i which counts the click and redirects.
433 db.exec(`
434 CREATE TABLE IF NOT EXISTS link_clicks (
435 site_id TEXT NOT NULL,
436 url TEXT NOT NULL,
437 clicks INTEGER DEFAULT 0,
438 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
439 PRIMARY KEY (site_id, url)
440 );
441 `);
442
443
444 // โ”€โ”€ ActivityPub (fediverse bridge) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
445 // RSA keypair per actor (Mastodon-compatible HTTP Signatures; separate from
446 // the Cirkels Ed25519 keys). ap_followers = remote AP actors following us.
447 db.exec(`
448 CREATE TABLE IF NOT EXISTS ap_keys (
449 slug TEXT PRIMARY KEY,
450 public_pem TEXT NOT NULL,
451 private_pem TEXT NOT NULL,
452 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
453 ); -- LUISTERAARS (shaer-0nh). Wie de BIBLIOTHEEK volgt, niet de actor.
454 --
455 -- Een eigen tabel en niet een vlag op ap_followers, en dat is met opzet:
456 -- deze accounts horen onze gewone posts NIET te krijgen. Zolang ze in een
457 -- andere tabel staan kan een bezorging ze niet per ongeluk meenemen -- een
458 -- vlag die iemand vergeet te filteren zou dat wel doen, en dan komt de
459 -- Krant van een site bij mensen die alleen muziek wilden.
460 CREATE TABLE IF NOT EXISTS ap_library_followers (
461 id INTEGER PRIMARY KEY AUTOINCREMENT,
462 slug TEXT NOT NULL,
463 actor_uri TEXT NOT NULL,
464 inbox TEXT,
465 shared_inbox TEXT,
466 name TEXT,
467 handle TEXT,
468 icon TEXT,
469 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
470 last_delivery_at DATETIME,
471 last_error_at DATETIME,
472 UNIQUE (slug, actor_uri)
473 );
474
475 -- OpenWebAuth (FEP-61cf): eenmalige tokens waarmee een BEZOEKER van elders
476 -- bewijst wie hij is. Klonkt is hier de 'target instance': we hebben nooit
477 -- iemands prive-sleutel nodig, alleen zijn publieke -- dus staat hier ook
478 -- geen geheim van een ander in.
479 --
480 -- Het token is kort houdbaar (minuten, zie OpenWebAuthService) en gaat na
481 -- inwisselen meteen weg: eenmalig is de hele bedoeling. De FEP noemt de
482 -- opruiming expliciet als DoS-verdediging -- zonder vervaltijd vult iemand
483 -- deze tabel met tokens die hij nooit inwisselt.
484 CREATE TABLE IF NOT EXISTS owa_tokens (
485 token TEXT PRIMARY KEY,
486 actor_uri TEXT NOT NULL,
487 created_at INTEGER NOT NULL -- ms sinds epoch
488 );
489 CREATE INDEX IF NOT EXISTS idx_owa_tokens_created ON owa_tokens(created_at);
490
491 CREATE TABLE IF NOT EXISTS ap_followers (
492 id INTEGER PRIMARY KEY AUTOINCREMENT,
493 slug TEXT NOT NULL,
494 actor_uri TEXT NOT NULL,
495 inbox TEXT,
496 shared_inbox TEXT,
497 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
498 UNIQUE(slug, actor_uri)
499 );
500 CREATE INDEX IF NOT EXISTS idx_ap_followers_slug ON ap_followers(slug);
501 CREATE TABLE IF NOT EXISTS ap_interactions (
502 id INTEGER PRIMARY KEY AUTOINCREMENT,
503 kind TEXT NOT NULL, -- 'reply' | 'like' | 'announce'
504 post_id TEXT NOT NULL,
505 object_uri TEXT NOT NULL DEFAULT '', -- remote note id (reply) or '' (like/announce)
506 actor_uri TEXT NOT NULL,
507 actor_name TEXT,
508 actor_handle TEXT,
509 actor_url TEXT,
510 actor_icon TEXT,
511 content TEXT, -- sanitized HTML (reply)
512 published TEXT,
513 parent_uri TEXT, -- the note this reply replies to (for nesting)
514 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
515 UNIQUE(kind, post_id, actor_uri, object_uri)
516 );
517 CREATE INDEX IF NOT EXISTS idx_ap_inter_post ON ap_interactions(post_id, kind);
518 -- Moderation tombstones: object URIs the site owner removed. Checked at ingest
519 -- (handleInbox) AND by the thread-crawler, so a removed reply never comes back
520 -- via thread-filling. Private notes can't be flagged via authorize_interaction
521 -- (their fetch 401s), so owner moderation acts on the locally stored copy.
522 CREATE TABLE IF NOT EXISTS ap_rejected_objects (
523 object_uri TEXT PRIMARY KEY,
524 post_id TEXT,
525 reason TEXT,
526 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
527 );
528 -- ActivityPub C2S (client-to-server): OAuth 2.0 for native/web clients (Shaer).
529 -- Public clients + PKCE (RFC 8252); tokens stored hashed; token is per user+site.
530 CREATE TABLE IF NOT EXISTS oauth_clients (
531 client_id TEXT PRIMARY KEY,
532 client_name TEXT,
533 redirect_uris TEXT NOT NULL, -- JSON array
534 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
535 );
536 CREATE TABLE IF NOT EXISTS oauth_codes (
537 code TEXT PRIMARY KEY,
538 client_id TEXT NOT NULL,
539 user_id TEXT NOT NULL,
540 site_slug TEXT NOT NULL,
541 redirect_uri TEXT NOT NULL,
542 code_challenge TEXT, -- PKCE S256 (verplicht voor public clients)
543 scope TEXT,
544 expires_at DATETIME NOT NULL,
545 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
546 );
547 CREATE TABLE IF NOT EXISTS oauth_tokens (
548 token_hash TEXT PRIMARY KEY, -- sha256(bearer); het token zelf slaan we nooit op
549 client_id TEXT NOT NULL,
550 user_id TEXT NOT NULL,
551 site_slug TEXT NOT NULL,
552 scope TEXT,
553 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
554 last_used_at DATETIME
555 );
556 -- Paid posts (klonkt-demo-aki): the site owner's own Patreon campaign.
557 -- Secrets are encrypted at rest (CryptoBox). Never reuses the instance-level
558 -- patreon_* settings, which are Klonkt Premium's separate license flow.
559 CREATE TABLE IF NOT EXISTS paid_patreon (
560 site_id TEXT PRIMARY KEY,
561 client_id TEXT,
562 client_secret_enc TEXT,
563 campaign_id TEXT,
564 access_token_enc TEXT,
565 refresh_token_enc TEXT,
566 token_exp INTEGER, -- unix seconds
567 default_min_cents INTEGER DEFAULT 0,
568 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
569 );
570 -- One row per passkey. NO patron identity is stored (design decision):
571 -- {passkey, site, proven cents, expiry}. Not traceable to a person.
572 CREATE TABLE IF NOT EXISTS paid_entitlements (
573 credential_id TEXT PRIMARY KEY, -- WebAuthn credential id (opaque, base64url)
574 site_id TEXT NOT NULL,
575 public_key TEXT NOT NULL, -- COSE public key, base64url
576 counter INTEGER DEFAULT 0,
577 transports TEXT,
578 min_cents INTEGER DEFAULT 0, -- the amount proven at link time
579 expires_at INTEGER NOT NULL, -- unix seconds; re-link after
580 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
581 );
582 -- Web Push (docs/webpush-design.md): one row per browser/device the owner
583 -- enabled notifications on. Payloads are encrypted to p256dh/auth (RFC 8291).
584 CREATE TABLE IF NOT EXISTS push_subscriptions (
585 endpoint TEXT PRIMARY KEY, -- push-service URL for this device
586 user_id TEXT NOT NULL,
587 p256dh TEXT NOT NULL, -- client public key
588 auth TEXT NOT NULL, -- client auth secret
589 alert_types TEXT, -- JSON {follow,reply,like,boost,dm}
590 ua_label TEXT,
591 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
592 last_ok_at DATETIME
593 );
594 CREATE TABLE IF NOT EXISTS ap_outbox (
595 id TEXT PRIMARY KEY, -- note path segment (uuid) โ†’ /ap/notes/<id>
596 site_slug TEXT NOT NULL,
597 post_id TEXT NOT NULL,
598 post_slug TEXT,
599 in_reply_to TEXT, -- remote status uri we reply to
600 to_actor TEXT, -- remote actor uri (mentioned)
601 to_handle TEXT,
602 content TEXT NOT NULL, -- sanitized HTML of our reply
603 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
604 );
605 CREATE INDEX IF NOT EXISTS idx_ap_outbox_post ON ap_outbox(post_id);
606 -- Your like/boost state on a REMOTE post (the interact page), so those become toggles.
607 CREATE TABLE IF NOT EXISTS ap_my_reactions (
608 site_slug TEXT NOT NULL,
609 target_uri TEXT NOT NULL,
610 kind TEXT NOT NULL, -- 'like' | 'boost'
611 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
612 UNIQUE(site_slug, target_uri, kind)
613 );
614 `);
615 ensureColumn('ap_interactions', 'parent_uri', 'TEXT'); // nesting (existing DBs)
616 ensureColumn('ap_interactions', 'acted_boost', 'INTEGER DEFAULT 0'); // owner boosted this comment (๐Ÿ”) โ†’ can undo
617 ensureColumn('ap_interactions', 'acted_like', 'INTEGER DEFAULT 0'); // owner liked this comment (โญ) โ†’ can undo
618
619 // Fediverse CLIENT: accounts WE follow (outbound) + the home timeline of their posts.
620 db.exec(`
621 CREATE TABLE IF NOT EXISTS ap_following (
622 id INTEGER PRIMARY KEY AUTOINCREMENT,
623 slug TEXT NOT NULL, -- our site that follows
624 actor_uri TEXT NOT NULL, -- the followed account's actor id
625 handle TEXT, name TEXT, icon TEXT, url TEXT,
626 inbox TEXT, -- their inbox (for Create delivery / Undo)
627 follow_id TEXT, -- the Follow activity id we sent (Accept matching)
628 status TEXT DEFAULT 'pending', -- pending | accepted
629 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
630 UNIQUE(slug, actor_uri)
631 );
632 -- Antwoorden van accounts die we volgen komen gewoon binnen, ondertekend
633 -- door de schrijver, maar horen niet in de Krant (belongsInTimeline) en
634 -- werden daarna nergens bewaard. Kwam er later een doorgestuurd antwoord OP
635 -- zo'n bericht, dan kenden we de ouder niet en wezen we het af (shaer-e9g).
636 -- Alleen de URI, geen inhoud: dit voedt uitsluitend de vraag "kennen wij dit
637 -- bericht?". Wordt na 30 dagen gesnoeid; doorsturen gebeurt kort na het
638 -- antwoord, dus langer bewaren levert niets op.
639 CREATE TABLE IF NOT EXISTS ap_seen_notes (
640 uri TEXT PRIMARY KEY,
641 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
642 );
643 CREATE INDEX IF NOT EXISTS idx_ap_seen_notes_age ON ap_seen_notes(created_at);
644 CREATE TABLE IF NOT EXISTS ap_timeline (
645 id TEXT NOT NULL, -- the remote note's AP id
646 slug TEXT NOT NULL, -- whose home timeline (our site)
647 author_uri TEXT, author_name TEXT, author_handle TEXT, author_icon TEXT, author_url TEXT,
648 content TEXT, url TEXT, published TEXT, media_json TEXT,
649 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
650 UNIQUE(slug, id)
651 );
652 CREATE INDEX IF NOT EXISTS idx_ap_timeline_slug ON ap_timeline(slug, published);
653 -- canonicalReactionUri herleidt een permalink naar het object-id door op (slug, url)
654 -- te zoeken. Zonder deze index viel dat terug op idx_ap_timeline_slug, dus een scan
655 -- van elke rij van die slug. Dat gebeurt PER REACTIE in getInteractions, en de
656 -- reactie-migratie erft het in haar re-key-join, die synchroon vรณรณr listen draait:
657 -- de opstartkosten waren reacties maal tijdlijnrijen.
658 CREATE INDEX IF NOT EXISTS idx_ap_timeline_url ON ap_timeline(slug, url);
659 -- FEP-1580: de vertaaltabel van een verhuizing. Per gemigreerd object waar
660 -- het VANDAAN kwam en welke URI het HIER kreeg. Derden lezen deze mapping en
661 -- werken er hun eigen inReplyTo/Like-verwijzingen mee bij; zonder deze tabel
662 -- is "de berichten krijgen nieuwe adressen" een permanent kapotte draad.
663 --
664 -- De spec eist omgekeerd-chronologisch op het moment dat de kopie HIER is
665 -- aangemaakt (niet de oorspronkelijke publicatiedatum). Daarom sorteren we
666 -- op de autoincrement-id en niet op created_at: die heeft secondeprecisie,
667 -- en een ingest zet er tientallen per seconde in.
668 CREATE TABLE IF NOT EXISTS ap_migration (
669 id INTEGER PRIMARY KEY AUTOINCREMENT,
670 slug TEXT NOT NULL, -- onze site, de DOELkant van de verhuizing
671 origin TEXT NOT NULL, -- object-URI op de broninstantie
672 target TEXT NOT NULL, -- de URI die het object hier kreeg
673 source_actor TEXT NOT NULL DEFAULT '',
674 is_public INTEGER NOT NULL DEFAULT 1, -- niet-publieke items horen niet in een publieke pagina
675 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
676 UNIQUE(slug, origin)
677 );
678 CREATE INDEX IF NOT EXISTS idx_ap_migration_slug ON ap_migration(slug, id DESC);
679 -- De Move-activities zelf, gededupliceerd. FEP-1580 wil dat deze collectie
680 -- een op zichzelf staand bewijs vormt voor de items in ap_migration, met een
681 -- FEP-8b32 integrity proof van de bron-actor plus een kopie van diens
682 -- actor-document. Wij bewaren allebei die stukken al (activity_json en
683 -- actor_json), maar Klonkt kent 8b32 nog niet: zie shaer-j1v0. Zolang dat
684 -- open staat is deze collectie structureel goed en niet verifieerbaar.
685 CREATE TABLE IF NOT EXISTS ap_moves (
686 id INTEGER PRIMARY KEY AUTOINCREMENT,
687 slug TEXT NOT NULL,
688 move_id TEXT NOT NULL, -- id van de Move-activity, tevens dedup-sleutel
689 source_actor TEXT NOT NULL,
690 target_actor TEXT NOT NULL,
691 activity_json TEXT NOT NULL,
692 actor_json TEXT, -- inline kopie van het bron-actordocument
693 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
694 UNIQUE(slug, move_id)
695 );
696 CREATE TABLE IF NOT EXISTS ap_blocks (
697 id INTEGER PRIMARY KEY AUTOINCREMENT,
698 slug TEXT NOT NULL, -- our site that set the block
699 target TEXT NOT NULL, -- actor URI (actor block) or domain (domain block)
700 kind TEXT NOT NULL, -- 'actor' | 'domain'
701 label TEXT, -- display (@handle or domain)
702 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
703 UNIQUE(slug, target)
704 );
705 CREATE INDEX IF NOT EXISTS idx_ap_blocks_target ON ap_blocks(target);
706 -- Committed guardian โ†” ward relations, one row per local side. role
707 -- 'ward' = the local slug is a ward of other_uri; 'guardian' = the local
708 -- slug guards other_uri. status is always 'accepted' here now: PENDING
709 -- offers live in ap_guardian_offers below (FEP-633c multi-party handshake).
710 CREATE TABLE IF NOT EXISTS ap_guardianships (
711 id INTEGER PRIMARY KEY AUTOINCREMENT,
712 slug TEXT NOT NULL, -- our local site in this relation (guardianship module)
713 role TEXT NOT NULL, -- 'guardian' (slug guards other) | 'ward' (other guards slug)
714 other_uri TEXT NOT NULL, -- the counterpart actor URI (local or remote)
715 other_handle TEXT, -- cached @user@host for display
716 status TEXT NOT NULL, -- 'offered' (legacy) | 'accepted'
717 offer_id TEXT, -- the Offer activity id (FEP-633c section 3)
718 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
719 UNIQUE(slug, role, other_uri)
720 );
721 CREATE INDEX IF NOT EXISTS idx_ap_guardianships_slug ON ap_guardianships(slug, role, status);
722 -- The multi-party handshake (FEP-633c section 3), one row per offer this
723 -- instance is a party to. Mirrors the Shaer test daemon's Handshake:
724 -- accepts accumulate in ap_guardian_offer_accepts, and the offer commits
725 -- only when the candidate returns the handle after ward + candidate + at
726 -- least one existing guardian have accepted.
727 CREATE TABLE IF NOT EXISTS ap_guardian_offers (
728 offer_id TEXT NOT NULL, -- the Offer activity id (minted by the candidate)
729 slug TEXT NOT NULL, -- the local site tracking this handshake (each party keeps its own copy)
730 ward_uri TEXT NOT NULL, -- the ward-to-be
731 candidate_uri TEXT NOT NULL, -- the guardian-candidate (fixed initiator)
732 existing_guardians TEXT NOT NULL DEFAULT '[]', -- JSON array of the ward's current guardian URIs
733 status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'committed' | 'void'
734 handle TEXT, -- the escalation handle returned at commit (section 6)
735 ward_handle TEXT, -- cached @ward@host for display
736 candidate_handle TEXT, -- cached @candidate@host for display
737 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
738 PRIMARY KEY (slug, offer_id)
739 );
740 CREATE INDEX IF NOT EXISTS idx_ap_guardian_offers_slug ON ap_guardian_offers(slug, status);
741 CREATE TABLE IF NOT EXISTS ap_guardian_offer_accepts (
742 offer_id TEXT NOT NULL, -- FK to ap_guardian_offers
743 slug TEXT NOT NULL, -- the local site's copy of the tally
744 party_uri TEXT NOT NULL, -- the party who accepted (ward | candidate | an existing guardian)
745 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
746 PRIMARY KEY (slug, offer_id, party_uri)
747 );
748 -- FEP-633c ยง5.6: a gated setting a ward's guardians decide together, which
749 -- has to work when they live on other servers (the ordinary case). One row
750 -- per guardian answer; the ward's server tallies (ยง3.5) and enforces.
751 -- The proposals themselves, so an Accept that only references the offer
752 -- id can still be resolved to "which feature, which value".
753 CREATE TABLE IF NOT EXISTS ap_gated_offers (
754 offer_id TEXT PRIMARY KEY,
755 slug TEXT NOT NULL, -- the ward, on this server
756 feature TEXT NOT NULL,
757 value INTEGER NOT NULL,
758 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
759 );
760 -- The guardian-side COPY of a gated-setting proposal on a ward, forwarded
761 -- here by the WARD's server (the same shape ap_follow_reviews has for a
762 -- gated follow). Without it a guardian on another server never learns a
763 -- proposal exists and can never answer it, so a threshold of two can never
764 -- be reached and every proposal expires. The answer goes back to the
765 -- ward's inbox, which tallies (5.6).
766 CREATE TABLE IF NOT EXISTS ap_gated_reviews (
767 id TEXT NOT NULL, -- the offer id, as minted by the proposer
768 guardian_slug TEXT NOT NULL, -- us, one of the ward's guardians
769 ward_uri TEXT NOT NULL,
770 ward_inbox TEXT,
771 proposer TEXT, -- who opened it (for display)
772 feature TEXT NOT NULL,
773 value INTEGER NOT NULL,
774 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
775 PRIMARY KEY (guardian_slug, id)
776 );
777 -- The PROPOSER's own record of a gated proposal it sent (5.6). Without it
778 -- a guardian clicks "propose", the ward's server tallies somewhere else,
779 -- and the proposer has nowhere to even see that something is running: the
780 -- status was a button caption that did not survive a page refresh. The
781 -- ward's server answers the Offer once the decision settles (Accept when
782 -- it settled on the proposed value, Reject otherwise); that answer lands
783 -- in status. An open row past the decision window renders as expired.
784 CREATE TABLE IF NOT EXISTS ap_gated_sent (
785 offer_id TEXT PRIMARY KEY, -- as minted by us, the proposer
786 guardian_slug TEXT NOT NULL, -- us
787 ward_uri TEXT NOT NULL,
788 feature TEXT NOT NULL,
789 value INTEGER NOT NULL, -- what we proposed
790 status TEXT NOT NULL DEFAULT 'open', -- 'open' | 'accepted' | 'rejected'
791 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
792 );
793 CREATE TABLE IF NOT EXISTS ap_gated_votes (
794 slug TEXT NOT NULL, -- the WARD, on this server
795 feature TEXT NOT NULL, -- e.g. 'shaer:externalEmbeds'
796 guardian_uri TEXT NOT NULL, -- who answered (must be a committed guardian)
797 value INTEGER NOT NULL, -- the value they voted for (0/1)
798 opened_at DATETIME NOT NULL, -- when this decision opened (the window start)
799 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
800 PRIMARY KEY (slug, feature, guardian_uri)
801 );
802 -- Guardian availability (FEP-633c 3.6): one guardian's attention as seen
803 -- from one ward on this server. Never public; the ward reads it via the
804 -- owner-only guardians queue. One rule above all: one answer restores
805 -- everything, so every row here is one answer away from disappearing.
806 CREATE TABLE IF NOT EXISTS ap_guardian_attention (
807 ward_slug TEXT NOT NULL,
808 guardian_uri TEXT NOT NULL,
809 state TEXT NOT NULL DEFAULT 'active', -- 'active' | 'away' | 'dormant'
810 away_until INTEGER, -- epoch ms while declared away
811 PRIMARY KEY (ward_slug, guardian_uri)
812 );
813 -- The ONLY admissible dormancy evidence (3.6.2): directly addressed
814 -- requests that went unanswered. Calendar time alone never counts.
815 CREATE TABLE IF NOT EXISTS ap_attention_requests (
816 ward_slug TEXT NOT NULL,
817 guardian_uri TEXT NOT NULL,
818 request_id TEXT NOT NULL,
819 asked_at INTEGER NOT NULL, -- epoch ms
820 PRIMARY KEY (ward_slug, guardian_uri, request_id)
821 );
822 -- A lapse (3.6.3): the available co-guardians deciding to release a
823 -- dormant one. Irreversible, so the window always runs in full; any sign
824 -- of life from the target cancels it outright.
825 CREATE TABLE IF NOT EXISTS ap_lapses (
826 id TEXT PRIMARY KEY,
827 ward_slug TEXT NOT NULL,
828 ward_uri TEXT NOT NULL,
829 target_uri TEXT NOT NULL,
830 opened_by TEXT NOT NULL,
831 set_json TEXT NOT NULL, -- the available set at open, target excluded
832 accepts_json TEXT NOT NULL DEFAULT '[]',
833 rejects_json TEXT NOT NULL DEFAULT '[]',
834 opened_at INTEGER NOT NULL, -- epoch ms
835 window_ms INTEGER NOT NULL,
836 cancelled INTEGER NOT NULL DEFAULT 0,
837 applied INTEGER NOT NULL DEFAULT 0,
838 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
839 );
840 CREATE TABLE IF NOT EXISTS ap_delivery (
841 id INTEGER PRIMARY KEY AUTOINCREMENT,
842 slug TEXT NOT NULL, -- our site/actor that signs the delivery
843 inbox TEXT NOT NULL, -- recipient inbox URL
844 body TEXT NOT NULL, -- the activity JSON to POST
845 attempts INTEGER NOT NULL DEFAULT 0,
846 next_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
847 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
848 );
849 CREATE INDEX IF NOT EXISTS idx_ap_delivery_due ON ap_delivery(next_at);
850 CREATE TABLE IF NOT EXISTS poll_votes (
851 id INTEGER PRIMARY KEY AUTOINCREMENT,
852 post_id INTEGER NOT NULL, -- our local poll post (posts.id)
853 actor_uri TEXT NOT NULL, -- the remote voter's AP actor URI
854 choice TEXT NOT NULL, -- the chosen option's name (matches poll_json options[].name)
855 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
856 UNIQUE(post_id, actor_uri, choice)
857 );
858 CREATE INDEX IF NOT EXISTS idx_poll_votes_post ON poll_votes(post_id);
859 CREATE TABLE IF NOT EXISTS ap_mentions (
860 id INTEGER PRIMARY KEY AUTOINCREMENT,
861 slug TEXT NOT NULL, -- our mentioned site/actor
862 object_uri TEXT NOT NULL, -- the remote note that mentions us
863 note_url TEXT, -- its human URL (open/interact)
864 actor_uri TEXT, actor_name TEXT, actor_handle TEXT, actor_icon TEXT, actor_url TEXT,
865 content TEXT, -- sanitized HTML snippet of the mentioning note
866 published TEXT,
867 in_reply_to TEXT, -- de note waarop dit een antwoord is (AS2 inReplyTo)
868 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
869 UNIQUE(slug, object_uri)
870 );
871 CREATE INDEX IF NOT EXISTS idx_ap_mentions_slug ON ap_mentions(slug, created_at);
872 CREATE TABLE IF NOT EXISTS ap_reports (
873 id INTEGER PRIMARY KEY AUTOINCREMENT,
874 slug TEXT NOT NULL, -- our site the report is about (its owner moderates)
875 actor_uri TEXT, -- the reporter's actor URI
876 actor_name TEXT, actor_handle TEXT, actor_icon TEXT,
877 content TEXT, -- the reason (plain text)
878 objects TEXT, -- JSON array of reported object URIs (our actor + statuses)
879 seen INTEGER DEFAULT 0,
880 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
881 );
882 CREATE INDEX IF NOT EXISTS idx_ap_reports_slug ON ap_reports(slug, created_at);
883 `);
884 // "Feature" a followed account: its posts show in the local Cirkel. Heet in de
885 // UI "Uitgelicht" / "Featured" (tl.autoboost), en zo heet de kolom ook in
886 // following.csv. Niet te verwarren met `featured` op de ACTOR: dat is de
887 // collectie vastgezette POSTS (toot:featured), iets heel anders.
888 ensureColumn('ap_following', 'auto_boost', 'INTEGER DEFAULT 0');
889 // A timeline post you boosted (๐Ÿ”) โ€” also shown in the Cirkel (mixed by date).
890 ensureColumn('ap_timeline', 'boosted', 'INTEGER DEFAULT 0');
891 ensureColumn('ap_timeline', 'liked', 'INTEGER DEFAULT 0'); // a feed post you liked (โญ) โ†’ toggle
892 ensureColumn('ap_timeline', 'nsfw', 'INTEGER DEFAULT 0'); // remote sensitive post โ†’ blur in the Cirkel
893 ensureColumn('ap_timeline', 'cw', 'TEXT'); // remote content-warning text
894 ensureColumn('ap_timeline', 'emoji_json', 'TEXT'); // FEP-9098 custom emoji Emoji tags from the inbound note, served back as `tag`
895 ensureColumn('ap_timeline', 'link_json', 'TEXT'); // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
896 ensureColumn('ap_timeline', 'quote_json', 'TEXT'); // FEP-044f resolved quoted-post snapshot (author + content), for the embedded quote card
897 // FEP-044f: the fediverse object THIS post quotes, resolved once at publish
898 // time so buildNote (sync, also used by the outbox) needs no network.
899 ensureColumn('posts', 'quote_uri', 'TEXT'); // the quoted object's id
900 // De kaart op je EIGEN post (shaer-k3f): dezelfde snapshots die ap_timeline
901 // voor binnenkomende posts draagt, maar dan voor wat je zelf publiceert --
902 // zonder deze twee kan de app een eigen post nooit als kaart tonen.
903 ensureColumn('posts', 'quote_json', 'TEXT'); // FEP-044f resolved quote snapshot
904 ensureColumn('posts', 'embed_json', 'TEXT'); // externe linkkaart (oEmbed/OG), thumbnail-only
905 ensureColumn('posts', 'quote_actor', 'TEXT'); // its author, so we can address them
906 ensureColumn('ap_timeline', 'embed_json', 'TEXT'); // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
907 ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the author's display name (shaer:author.emojis)
908 ensureColumn('ap_timeline', 'reblog_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the booster's display name (shaer:booster.emojis)
909 ensureColumn('ap_timeline', 'reblog_name', 'TEXT'); // a followed account boosted this โ†’ "X boosted"
910 ensureColumn('ap_timeline', 'reblog_handle', 'TEXT'); // the booster's @handle
911 ensureColumn('ap_timeline', 'reblog_icon', 'TEXT'); // the booster's avatar
912 ensureColumn('ap_timeline', 'poll_json', 'TEXT'); // a Question (poll): {multiple,options[{name,count}],endTime,closed,voters,voted}
913
914 // Delivery health per follower โ†’ surface dead accounts for manual cleanup.
915 ensureColumn('ap_followers', 'last_delivery_at', 'DATETIME'); // last SUCCESSFUL delivery to this follower's inbox
916 ensureColumn('ap_followers', 'last_error_at', 'DATETIME'); // last time a delivery to it gave up (max retries)
917
918 // ActivityPub `source` model: content_rendered = baked display HTML (#hashtags / URLs /
919 // @mentions linkified once at save). `content` stays the raw source used for editing and
920 // re-rendering. NULL on old posts โ†’ the render route bakes on the fly as a fallback.
921 ensureColumn('posts', 'content_rendered', 'TEXT');
922
923 // AP addressing of an incoming interaction: 'public' | 'unlisted' | 'followers' | 'direct',
924 // derived from the note's to/cc at ingest. The public post page only renders public/unlisted
925 // replies; followers/direct replies surface in notifications (and later Messages) with post
926 // context instead. Existing rows default to 'public' (historically almost all were).
927 ensureColumn('ap_interactions', 'visibility', "TEXT DEFAULT 'public'");
928 ensureColumn('ap_interactions', 'emoji_json', 'TEXT'); // FEP-9098 custom emojis in a reply's content (messages + thread)
929 ensureColumn('ap_interactions', 'actor_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the reply author's display name
930 // Rich replies: the reply's language (BCP47 code) โ†’ contentMap on the outgoing Note.
931 ensureColumn('ap_outbox', 'language', 'TEXT');
932 // Rich replies: JSON array [{url, mediaType, name}] โ†’ `attachment` on the Note.
933 ensureColumn('ap_outbox', 'attachments', 'TEXT');
934 ensureColumn('posts', 'ap_visibility', 'TEXT'); // public|quiet|friends|direct (C2S addressing, shaer-60b)
935 ensureColumn('posts', 'paid', 'INTEGER DEFAULT 0'); // paid post (klonkt-demo-aki)
936 ensureColumn('posts', 'paid_min_cents', 'INTEGER'); // required support; null = owner default
937 ensureColumn('paid_patreon', 'patreon_url', 'TEXT'); // owner's public Patreon page โ†’ "Word supporter" link (klonkt-demo-aki)
938 ensureColumn('ap_outbox', 'visibility', 'TEXT'); // 'direct' = private mention, never Public (shaer-tqc)
939 ensureColumn('ap_outbox', 'to_actors', 'TEXT'); // JSON array of recipient actor URIs for direct notes
940 ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
941 // Wie er op een hulpvraag af is, en wanneer hij is afgesloten (shaer-lgo).
942 // Los van ap_mentions, want dit is GEDEELDE staat: elke guardian van dit kind
943 // heeft er een kopie van, en die komt binnen als bericht van een ander. Een
944 // kolom op de mention zou alleen over onszelf gaan.
945 //
946 // OPGEPIKT mag stapelen: twee mensen die tegelijk reageren op een kind dat om
947 // hulp vraagt is geen probleem. Twee mensen die allebei niets doen omdat de
948 // ander het "geclaimd" had, wel.
949 //
950 // AFGEHANDELD kent geen terugdraai. Sluiten gebeurt met een stevige
951 // bevestiging, en leeft de vraag daarna nog, dan wordt hij opnieuw gesteld --
952 // een nieuwe hulpvraag. Zo blijft het verslag eerlijk: er wordt niets
953 // herschreven, er wordt toegevoegd.
954 db.exec(`CREATE TABLE IF NOT EXISTS ap_help_state (
955 note_uri TEXT NOT NULL,
956 guardian_uri TEXT NOT NULL,
957 kind TEXT NOT NULL, -- pickup | handled
958 guardian_handle TEXT,
959 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
960 PRIMARY KEY (note_uri, guardian_uri, kind)
961 )`);
962 db.exec('CREATE INDEX IF NOT EXISTS idx_ap_help_state_note ON ap_help_state(note_uri)');
963 // Een kind dat zelf om een poort vraagt (shaer-8ru). Een VRAAG, geen stem:
964 // pas als een guardian hem oppakt wordt het een voorstel dat langs de tally
965 // gaat. handled_at in plaats van verwijderen -- wat een kind gevraagd heeft
966 // hoort terug te vinden te zijn, ook als het antwoord nee was.
967 db.exec(`CREATE TABLE IF NOT EXISTS ap_gate_requests (
968 id INTEGER PRIMARY KEY AUTOINCREMENT,
969 slug TEXT NOT NULL, -- de guardian die hem ontving
970 ward_uri TEXT NOT NULL,
971 feature TEXT NOT NULL,
972 note_uri TEXT,
973 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
974 handled_at TEXT,
975 UNIQUE (slug, ward_uri, feature, handled_at)
976 )`);
977 db.exec('CREATE INDEX IF NOT EXISTS idx_ap_gate_requests_slug ON ap_gate_requests(slug, handled_at)');
978 ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
979 ensureColumn('ap_outbox', 'wave', 'INTEGER'); // FEP-633c shaer:wave (guardian -> ward nudge)
980 ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms)
981 ensureColumn('ap_gated_offers', 'proposer', 'TEXT'); // who proposed (5.6): the settle-answer goes back to them
982 // Zou JOUW antwoord het besluit afmaken (shaer-8vt)? De telling loopt op de
983 // server van het kind; zonder dit veld kan een guardian elders niet weten dat
984 // hij de doorslag geeft. Ontbreekt hij, dan waarschuwen we -- bij twijfel.
985 ensureColumn('ap_gated_reviews', 'decisive', 'INTEGER');
986 // Did a guardian actually say yes to this follower? That is what makes the
987 // mutual shortcut sound: a ward may follow back anyone its guardians already
988 // admitted, without asking the same question twice. Only follows that came
989 // through the ยง5.3 gate carry the mark; a free actor's followers never faced
990 // one. Everyone already following when this column arrives is grandfathered
991 // in (Barts besluit, 3-8): the rule is exact from that moment forward rather
992 // than retroactively suspicious of relationships that already exist.
993 {
994 const had = db.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('ap_followers') WHERE name = 'gate_approved'").get();
995 ensureColumn('ap_followers', 'gate_approved', 'INTEGER DEFAULT 0');
996 if (!had || !had.n) {
997 try { db.prepare('UPDATE ap_followers SET gate_approved = 1').run(); } catch { /* table still empty on a fresh init */ }
998 }
999 }
1000 ensureColumn('posts', 'c2s_attachments', 'TEXT'); // media a C2S Note carried (JSON [{url,mediaType,name}]); buildNote federates them
1001 // 30-7: C2S posts briefly got their content media copied onto the cover,
1002 // which showed the same video twice on the post page. Clear the covers that
1003 // duplicate their own content; idempotent, only ever touches those.
1004 try {
1005 db.prepare("UPDATE posts SET cover_video_url = NULL WHERE cover_video_url LIKE '/media/reply-media/%' AND instr(content, cover_video_url) > 0").run();
1006 db.prepare("UPDATE posts SET cover_image_url = NULL WHERE cover_image_url LIKE '/media/reply-media/%' AND instr(content, cover_image_url) > 0").run();
1007 } catch { /* posts table absent on fresh init */ }
1008 // Bestaande rijen naar EEN spelling (shaer-a937). De schrijfwegen leveren
1009 // sinds deze release ISO; dit haalt na wat er in SQL-notatie is blijven
1010 // staan, zodat de sortering ook zonder de isoSql-wikkel klopt.
1011 //
1012 // Alleen rijen met een spatie op positie 11 en geen 'T': dat is precies de
1013 // CURRENT_TIMESTAMP-vorm. Idempotent -- een tweede keer draaien vindt niets
1014 // meer -- en het raakt een ISO-stempel nooit aan.
1015 //
1016 // strftime geeft NULL op iets dat het niet als tijd herkent; de WHERE laat
1017 // zulke rijen met rust, want een onleesbare stempel vervangen door NULL is
1018 // gegevens weggooien. Ze blijven staan zoals ze stonden.
1019 {
1020 const kolommen = [
1021 ['ap_timeline', 'created_at'], ['ap_timeline', 'published'],
1022 ['ap_mentions', 'created_at'], ['ap_mentions', 'published'],
1023 ['ap_interactions', 'created_at'], ['ap_interactions', 'published'],
1024 ['ap_outbox', 'created_at'],
1025 ];
1026 let veranderd = 0;
1027 for (const [tabel, kolom] of kolommen) {
1028 try {
1029 const r = db.prepare(
1030 `UPDATE ${tabel} SET ${kolom} = strftime('%Y-%m-%dT%H:%M:%SZ', ${kolom})
1031 WHERE ${kolom} IS NOT NULL AND ${kolom} LIKE '____-__-__ %'
1032 AND strftime('%Y-%m-%dT%H:%M:%SZ', ${kolom}) IS NOT NULL`).run();
1033 veranderd += r.changes;
1034 } catch { /* tabel bestaat nog niet op een verse installatie */ }
1035 }
1036 if (veranderd) console.log(`๐Ÿ•’ tijdstempels genormaliseerd: ${veranderd} rijen`);
1037 }
1038 ensureColumn('ap_mentions', 'wave', 'INTEGER'); // inbound guardian wave
1039 // FEP-633c ยง2.2: object hint that the author is a ward. Register-only for now;
1040 // used later at reddings-boei / escalation routing.
1041 ensureColumn('ap_timeline', 'has_guardians', 'INTEGER');
1042 ensureColumn('ap_mentions', 'has_guardians', 'INTEGER');
1043 // Berichten and de Krant render a post the same way, so a mention or a reply
1044 // needs the same trimmings a timeline row already has: custom emojis, the
1045 // media the note carried, and the quote / link-preview card.
1046 ensureColumn('ap_mentions', 'emoji_json', 'TEXT'); // FEP-9098, in the content
1047 ensureColumn('ap_mentions', 'actor_emoji_json', 'TEXT'); // FEP-9098, in the display name
1048 ensureColumn('ap_mentions', 'media_json', 'TEXT');
1049 ensureColumn('ap_mentions', 'quote_json', 'TEXT'); // FEP-044f quoted post
1050 ensureColumn('ap_mentions', 'embed_json', 'TEXT'); // external link preview
1051 // AS2 inReplyTo op een vermelding/bericht. Bestond hier niet, en daarmee ging
1052 // de ouder bij het opslaan verloren: elk ANTWOORD in een gesprek kwam bij de
1053 // client aan alsof het een gesprek begon (Robins melding, 26-8). Bestaande
1054 // rijen blijven leeg -- die ouder is niet meer te achterhalen zonder hem
1055 // opnieuw op te halen, en een verzonnen ouder is erger dan geen.
1056 ensureColumn('ap_mentions', 'in_reply_to', 'TEXT');
1057 ensureColumn('ap_interactions', 'media_json', 'TEXT');
1058 ensureColumn('ap_interactions', 'quote_json', 'TEXT');
1059 ensureColumn('ap_interactions', 'embed_json', 'TEXT');
1060 ensureColumn('ap_followers', 'name', 'TEXT'); // cached display name (shaer-aa3)
1061 ensureColumn('ap_followers', 'handle', 'TEXT'); // @user@host
1062 ensureColumn('ap_followers', 'icon', 'TEXT'); // avatar URL
1063 feedStateTriggers();
1064}
1065
1066/**
1067 * Wat er met een tijdlijn gebeurd is, op รฉรฉn plek (shaer-n05).
1068 *
1069 * De inbox-lezing voegt vier bronnen samen. De vraag "is er iets veranderd" werd
1070 * eerst beantwoord met MAX(rowid) over die vier -- een TOEVALLIGE eigenschap van
1071 * de tabellen, geen feit dat ergens is opgeschreven. Dat gaf precies de gebreken
1072 * die je van zo'n afleiding verwacht: bewerkingen en verwijderingen bewogen hem
1073 * niet, en hij kon achteruit lopen. Dezelfde fout als reacties uitlezen uit
1074 * ap_timeline.liked (shaer-9e9).
1075 *
1076 * Nu รฉรฉn rij per bericht per tijdlijn, met een oplopende `rev` en `kind`. Dat
1077 * beantwoordt drie vragen die anders drie eigen oplossingen zouden krijgen:
1078 * is er iets veranderd sinds N, wรกt is er veranderd, en is dit bericht bewerkt.
1079 *
1080 * Bijgehouden door TRIGGERS en niet door de aanroepende code, om dezelfde reden
1081 * dat er geen gebeurtenis-emitter is: een trigger zit in de database, dus geen
1082 * enkel codepad kan hem vergeten. De prijs is onzichtbare logica -- wie alleen de
1083 * JavaScript leest ziet niet waarom deze tabel vult. Vandaar dat ze hier staan,
1084 * bij de tabel, en niet verspreid.
1085 *
1086 * Let op de `UPDATE OF`-kolomlijsten: die zijn niet decoratief. Een like schrijft
1087 * ap_timeline.liked en een ๐Ÿ” schrijft .boosted; zonder die afbakening zou je
1088 * eigen like het bericht als BEWERKT merken en elke wachtende client wekken.
1089 */
1090function feedStateTriggers() {
1091 try {
1092 db.exec(`
1093 -- Tot waar jij een gesprek gelezen hebt (shaer-frontend-3tx).
1094 --
1095 -- Een MARKERING, geen teller: het aantal ongelezen berichten is een
1096 -- COUNT over de berichten die na deze cursor komen. Een opgeslagen
1097 -- getal zou opgehoogd, verlaagd en gerepareerd moeten worden, en zou
1098 -- blijven staan als er iets verwijderd wordt -- badge zegt 3, er is
1099 -- niets.
1100 --
1101 -- De cursor is samengesteld ('<stempel>|<ref>'), dezelfde vorm als de
1102 -- gesprekspaginering en om dezelfde reden: twee berichten in dezelfde
1103 -- seconde is bij DM's een gesprek, geen randgeval.
1104 CREATE TABLE IF NOT EXISTS ap_read_markers (
1105 slug TEXT NOT NULL,
1106 other TEXT NOT NULL, -- de tegenpartij (actor uri)
1107 cursor TEXT NOT NULL,
1108 at DATETIME DEFAULT CURRENT_TIMESTAMP,
1109 PRIMARY KEY (slug, other)
1110 );
1111 CREATE TABLE IF NOT EXISTS ap_feed_state (
1112 slug TEXT NOT NULL,
1113 object_uri TEXT NOT NULL,
1114 rev INTEGER NOT NULL,
1115 kind TEXT NOT NULL, -- new | updated | deleted
1116 at DATETIME DEFAULT CURRENT_TIMESTAMP,
1117 PRIMARY KEY (slug, object_uri)
1118 );
1119 CREATE INDEX IF NOT EXISTS idx_ap_feed_state_rev ON ap_feed_state(slug, rev);
1120 -- Eรฉn doorlopende teller voor de hele instance. Bewust niet MAX(rev) uit de
1121 -- tabel zelf: verdwijnt de hoogste rij, dan zou die teruglopen en denkt een
1122 -- client dat er niets gebeurd is.
1123 CREATE TABLE IF NOT EXISTS ap_feed_rev (n INTEGER NOT NULL);
1124 `);
1125 if (!db.prepare('SELECT COUNT(*) AS n FROM ap_feed_rev').get().n) {
1126 db.prepare('INSERT INTO ap_feed_rev (n) VALUES (0)').run();
1127 }
1128 // slug + object_uri verschillen per bron; de rest is voor alle vier gelijk.
1129 const zet = (naam, gebeurtenis, tabel, slug, uri, kind, extra = '', wanneer = '') => `
1130 DROP TRIGGER IF EXISTS ${naam};
1131 CREATE TRIGGER ${naam} AFTER ${gebeurtenis} ON ${tabel}${wanneer ? ` WHEN ${wanneer}` : ''} BEGIN
1132 UPDATE ap_feed_rev SET n = n + 1;
1133 INSERT INTO ap_feed_state (slug, object_uri, rev, kind)
1134 ${extra || `VALUES (${slug}, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}')`}
1135 ON CONFLICT(slug, object_uri) DO UPDATE
1136 SET rev = excluded.rev, kind = excluded.kind, at = CURRENT_TIMESTAMP;
1137 END;`;
1138 const joinPosts = (uri, kind) => `
1139 SELECT s.slug, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}'
1140 FROM posts p JOIN sites s ON s.id = p.site_id`;
1141 db.exec([
1142 zet('trg_feed_tl_ins', 'INSERT', 'ap_timeline', 'NEW.slug', 'NEW.id', 'new'),
1143 zet('trg_feed_tl_upd', 'UPDATE OF content, media_json, nsfw, cw, url, poll_json, quote_json, embed_json', 'ap_timeline', 'NEW.slug', 'NEW.id', 'updated'),
1144 zet('trg_feed_tl_del', 'DELETE', 'ap_timeline', 'OLD.slug', 'OLD.id', 'deleted'),
1145 zet('trg_feed_mn_ins', 'INSERT', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'new'),
1146 zet('trg_feed_mn_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'updated'),
1147 zet('trg_feed_mn_del', 'DELETE', 'ap_mentions', 'OLD.slug', 'OLD.object_uri', 'deleted'),
1148 zet('trg_feed_ob_ins', 'INSERT', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'new'),
1149 zet('trg_feed_ob_upd', 'UPDATE OF content, attachments', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'updated'),
1150 zet('trg_feed_ob_del', 'DELETE', 'ap_outbox', 'OLD.site_slug', 'OLD.id', 'deleted'),
1151 // ap_interactions draagt geen slug: die hangt aan de POST. Vandaar de join,
1152 // en vandaar dat deze drie niet in de gewone vorm passen.
1153 //
1154 // De WHEN op kind='reply' is nodig omdat deze tabel ook likes en announces
1155 // draagt, en die schrijven object_uri = '' (zie recordInteraction). Zonder de
1156 // WHEN bumpte elke inkomende like de rev, werd elke wachter gewekt en kreeg
1157 // die de hele collectie opnieuw terwijl er niets aan veranderd was: precies de
1158 // kosten die de 304 moest wegnemen. Bovendien belandde er dan een rij op de
1159 // lege string in ap_feed_state, die feedChangesSince vervolgens uitdeelt.
1160 // De oude cursor filterde hier wel op kind; bij ap_timeline is dit ook gedaan
1161 // (de UPDATE OF sluit liked/boosted uit) en รฉรฉn tabel verder vergeten.
1162 zet('trg_feed_ia_ins', 'INSERT', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'new')} WHERE p.id = NEW.post_id`, "NEW.kind = 'reply'"),
1163 zet('trg_feed_ia_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'updated')} WHERE p.id = NEW.post_id`, "NEW.kind = 'reply'"),
1164 zet('trg_feed_ia_del', 'DELETE', 'ap_interactions', '', '', '', `${joinPosts('OLD.object_uri', 'deleted')} WHERE p.id = OLD.post_id`, "OLD.kind = 'reply'"),
1165 ].join('\n'));
1166 } catch (e) {
1167 // Niet fataal: zonder deze tabel valt het wachten terug op "altijd de tijd
1168 // volmaken", en dat is traag maar niet stuk.
1169 console.error('โŒ feed-state triggers:', e.message);
1170 }
1171}
1172
1173function ensureColumn(table, column, definition) {
1174 try {
1175 db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
1176 console.log(`๐Ÿ”ง Added column ${table}.${column}`);
1177 } catch (e) {
1178 // "duplicate column name" โ†’ already there. Anything else, surface it.
1179 if (!/duplicate column/i.test(e.message)) {
1180 console.error(`โŒ ensureColumn(${table}.${column}):`, e.message);
1181 }
1182 }
1183}
1184
1185export default db;
Note: See TracBrowser for help on using the repository browser.