source: Klonkt/src/config/database.js@ 4ae254a

main
Last change on this file since 4ae254a was 4ae254a, checked in by Robin <roboburr@…>, 26 hours ago

Bezorging voor een Klonkt die niet altijd aan staat: leeftijd, offline, per server

De uitgaande helft van shaer-xzab (Bart, 26-10): een Klonkt op een Mac thuis
slaapt, gaat de trein in, staat een weekend uit, en de bezorgwachtrij was daar
niet op gebouwd.

xzab.1 Verlopen op LEEFTIJD (5 dagen sinds created_at), niet na zes pogingen.
Zes pogingen waren ~4,5 uur, en de trede van 360 minuten werd nooit gebruikt:
de telling was er eerder. Treden nu 1m, 5m, 15m, 1u, 3u, 6u, 12u, dan elke 24u.

xzab.2 Offline zijn kost geen pogingen. Faalt een hele beurt op netwerkniveau
(geen enkel HTTP-antwoord) naar meer dan één server, dan ligt het aan ons:
alles twee minuten opschuiven, de telling ongemoeid, en niets laten verlopen.
Bij één server is "hij ligt eruit" net zo waarschijnlijk, dus dat telt gewoon,
en een SSRF-weigering is een besluit en geen netwerkfout.

xzab.3 Backoff per server, volgorde per inbox. Eén mislukking naar een server
schuift al zijn rijen op (nieuwe kolom ap_delivery.host, met een eenmalige
backfill), in plaats van dertig rijen met elk acht seconden time-out op
dezelfde dode server. Per inbox gaat de oudste eerst -- en ook het directe
versturen in deliverWithRetry wacht als er voor die inbox nog iets in de rij
staat, of als die server in zijn backoff zit. Anders kwam een Delete aan vóór
de Create die nog wachtte, en stond de post daarna weer op die server.

test/delivery-queue.test.js: twaalf toetsen tegen echte HTTP-servers en dichte
poorten. Twaalf mutaties apart gedraaid, elk gevangen; één toets bleek eerst
gemaskeerd (de backoff hield tegen wat de volgorde moest tegenhouden) en is
aangescherpt. Volledige suite 1362 groen.

Nog open in het epic: xzab.6 (wat zet bij Mastodon e.a. een domein terug op
beschikbaar), en daarna het wakker worden, xzab.4 en xzab.5.

Niet in deze ronde: drie plekken versturen zelf direct en zetten pas bij een
mislukking in de rij (guardianship/delivery.js, ActivityPubService bij een
antwoord en bij het wissen van een reactie). Die gaan niet langs de volgorde
per inbox.

Co-Authored-By: Claude Opus 5.5 <noreply@…>

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