source: Klonkt/src/config/database.js@ 7ccdced

main
Last change on this file since 7ccdced was 7ccdced, checked in by Robin <roboburr@…>, 34 hours ago

Een Delete van wat nog in de rij wacht: intern oplossen

Robins vraag (26-10): "Shouldn't a Delete on an undelivered Create resolve
internally for all unfederated ones?" Ja. bfa3ba3 zorgde er alleen voor dat de
Delete NA de wachtende Create aankwam -- maar de Create kwam dan nog steeds,
en wat gewist was bereikte zo alsnog een server die het nooit had gehad.

Nu haalt een Delete per inbox de wachtende Create en Update van datzelfde
object uit de rij (cancelUndelivered). Bewust alleen wat de inhoud draagt: een
Announce of Add van een gewist object is onschuldig, de ontvanger krijgt een
404 als hij het ophaalt.

Of de Delete zelf dan nog moet, hangt af van of er iets aangekomen KAN zijn.
Daarvoor de nieuwe kolom ap_delivery.tried: een rij die in de rij ging zonder
poging (de inbox wachtte al, of de server zat in zijn backoff) is nooit de deur
uit geweest; dan gaat er voor die inbox helemaal niets ('cancelled'). Is hij
wel geprobeerd, dan kan een time-out na verwerking hem hebben laten aankomen,
en gaat de Delete toch -- onschuldig als ze hem niet kenden. Rijen van voor de
kolom tellen als geprobeerd: dat is de veilige kant.

Daarbij twee directe verzenders die ik in bfa3ba3 had gemist, nu ook langs
deliverNowOrQueue: het bewerken van een antwoord (deliverOutboxUpdate -- een
Update vóór de wachtende Create wordt genegeerd, waarna de Create de oude tekst
brengt) en guardian-activiteiten (deliverToActor).

test/direct-sends-queue.test.js, nu negen toetsen: het gewiste antwoord dat
wachtte komt nooit aan (en de Delete gaat wel, want het was geprobeerd), een
nooit geprobeerd en gewist antwoord laat niets vertrekken, alleen Create en
Update van dat object en die inbox, en de bewerking en de guardian-activiteit
wachten. Acht mutaties apart gedraaid, elk gevangen; één (ook andere soorten
weghalen) eerst niet, waarop de bedoelde grens in de toets is vastgelegd.
Volledige suite 1380 groen.

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

  • Property mode set to 100644
File size: 67.7 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 // Is deze rij al eens geprobeerd? Dan KAN hij aangekomen zijn, ook al gaf het
898 // een fout (een time-out nadat de ander hem verwerkte). Een Delete voor iets
899 // dat nooit de deur uitging, hoeft niet; voor iets dat misschien aankwam wel.
900 // Standaard 1: van rijen van voor deze kolom weten we het niet, en dan is
901 // "misschien aangekomen" de veilige kant. Nieuwe rijen zeggen het zelf.
902 ensureColumn('ap_delivery', 'tried', 'INTEGER DEFAULT 1');
903 try {
904 const zonder = db.prepare('SELECT id, inbox FROM ap_delivery WHERE host IS NULL').all();
905 const zet = db.prepare('UPDATE ap_delivery SET host = ? WHERE id = ?');
906 for (const r of zonder) {
907 let h = null;
908 try { h = new URL(r.inbox).host.toLowerCase(); } catch { /* onleesbaar: blijft null */ }
909 zet.run(h, r.id);
910 }
911 } catch { /* een lege of ontbrekende wachtrij is geen fout */ }
912 db.exec(`
913 CREATE INDEX IF NOT EXISTS idx_ap_delivery_inbox ON ap_delivery(inbox, id);
914 CREATE INDEX IF NOT EXISTS idx_ap_delivery_host ON ap_delivery(host);
915 `);
916 // "Feature" a followed account: its posts show in the local Cirkel. Heet in de
917 // UI "Uitgelicht" / "Featured" (tl.autoboost), en zo heet de kolom ook in
918 // following.csv. Niet te verwarren met `featured` op de ACTOR: dat is de
919 // collectie vastgezette POSTS (toot:featured), iets heel anders.
920 ensureColumn('ap_following', 'auto_boost', 'INTEGER DEFAULT 0');
921 // A timeline post you boosted (🔁) — also shown in the Cirkel (mixed by date).
922 ensureColumn('ap_timeline', 'boosted', 'INTEGER DEFAULT 0');
923 ensureColumn('ap_timeline', 'liked', 'INTEGER DEFAULT 0'); // a feed post you liked (⭐) → toggle
924 ensureColumn('ap_timeline', 'nsfw', 'INTEGER DEFAULT 0'); // remote sensitive post → blur in the Cirkel
925 ensureColumn('ap_timeline', 'cw', 'TEXT'); // remote content-warning text
926 ensureColumn('ap_timeline', 'emoji_json', 'TEXT'); // FEP-9098 custom emoji Emoji tags from the inbound note, served back as `tag`
927 ensureColumn('ap_timeline', 'link_json', 'TEXT'); // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
928 ensureColumn('ap_timeline', 'quote_json', 'TEXT'); // FEP-044f resolved quoted-post snapshot (author + content), for the embedded quote card
929 // FEP-044f: the fediverse object THIS post quotes, resolved once at publish
930 // time so buildNote (sync, also used by the outbox) needs no network.
931 ensureColumn('posts', 'quote_uri', 'TEXT'); // the quoted object's id
932 // De kaart op je EIGEN post (shaer-k3f): dezelfde snapshots die ap_timeline
933 // voor binnenkomende posts draagt, maar dan voor wat je zelf publiceert --
934 // zonder deze twee kan de app een eigen post nooit als kaart tonen.
935 ensureColumn('posts', 'quote_json', 'TEXT'); // FEP-044f resolved quote snapshot
936 ensureColumn('posts', 'embed_json', 'TEXT'); // externe linkkaart (oEmbed/OG), thumbnail-only
937 ensureColumn('posts', 'quote_actor', 'TEXT'); // its author, so we can address them
938 ensureColumn('ap_timeline', 'embed_json', 'TEXT'); // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
939 ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the author's display name (shaer:author.emojis)
940 ensureColumn('ap_timeline', 'reblog_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the booster's display name (shaer:booster.emojis)
941 ensureColumn('ap_timeline', 'reblog_name', 'TEXT'); // a followed account boosted this → "X boosted"
942 ensureColumn('ap_timeline', 'reblog_handle', 'TEXT'); // the booster's @handle
943 ensureColumn('ap_timeline', 'reblog_icon', 'TEXT'); // the booster's avatar
944 ensureColumn('ap_timeline', 'poll_json', 'TEXT'); // a Question (poll): {multiple,options[{name,count}],endTime,closed,voters,voted}
945
946 // Delivery health per follower → surface dead accounts for manual cleanup.
947 ensureColumn('ap_followers', 'last_delivery_at', 'DATETIME'); // last SUCCESSFUL delivery to this follower's inbox
948 ensureColumn('ap_followers', 'last_error_at', 'DATETIME'); // last time a delivery to it gave up (max retries)
949
950 // ActivityPub `source` model: content_rendered = baked display HTML (#hashtags / URLs /
951 // @mentions linkified once at save). `content` stays the raw source used for editing and
952 // re-rendering. NULL on old posts → the render route bakes on the fly as a fallback.
953 ensureColumn('posts', 'content_rendered', 'TEXT');
954
955 // AP addressing of an incoming interaction: 'public' | 'unlisted' | 'followers' | 'direct',
956 // derived from the note's to/cc at ingest. The public post page only renders public/unlisted
957 // replies; followers/direct replies surface in notifications (and later Messages) with post
958 // context instead. Existing rows default to 'public' (historically almost all were).
959 ensureColumn('ap_interactions', 'visibility', "TEXT DEFAULT 'public'");
960 ensureColumn('ap_interactions', 'emoji_json', 'TEXT'); // FEP-9098 custom emojis in a reply's content (messages + thread)
961 ensureColumn('ap_interactions', 'actor_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the reply author's display name
962 // Rich replies: the reply's language (BCP47 code) → contentMap on the outgoing Note.
963 ensureColumn('ap_outbox', 'language', 'TEXT');
964 // Rich replies: JSON array [{url, mediaType, name}] → `attachment` on the Note.
965 ensureColumn('ap_outbox', 'attachments', 'TEXT');
966 ensureColumn('posts', 'ap_visibility', 'TEXT'); // public|quiet|friends|direct (C2S addressing, shaer-60b)
967 ensureColumn('posts', 'paid', 'INTEGER DEFAULT 0'); // paid post (klonkt-demo-aki)
968 ensureColumn('posts', 'paid_min_cents', 'INTEGER'); // required support; null = owner default
969 ensureColumn('paid_patreon', 'patreon_url', 'TEXT'); // owner's public Patreon page → "Word supporter" link (klonkt-demo-aki)
970 ensureColumn('ap_outbox', 'visibility', 'TEXT'); // 'direct' = private mention, never Public (shaer-tqc)
971 ensureColumn('ap_outbox', 'to_actors', 'TEXT'); // JSON array of recipient actor URIs for direct notes
972 ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
973 // Wie er op een hulpvraag af is, en wanneer hij is afgesloten (shaer-lgo).
974 // Los van ap_mentions, want dit is GEDEELDE staat: elke guardian van dit kind
975 // heeft er een kopie van, en die komt binnen als bericht van een ander. Een
976 // kolom op de mention zou alleen over onszelf gaan.
977 //
978 // OPGEPIKT mag stapelen: twee mensen die tegelijk reageren op een kind dat om
979 // hulp vraagt is geen probleem. Twee mensen die allebei niets doen omdat de
980 // ander het "geclaimd" had, wel.
981 //
982 // AFGEHANDELD kent geen terugdraai. Sluiten gebeurt met een stevige
983 // bevestiging, en leeft de vraag daarna nog, dan wordt hij opnieuw gesteld --
984 // een nieuwe hulpvraag. Zo blijft het verslag eerlijk: er wordt niets
985 // herschreven, er wordt toegevoegd.
986 db.exec(`CREATE TABLE IF NOT EXISTS ap_help_state (
987 note_uri TEXT NOT NULL,
988 guardian_uri TEXT NOT NULL,
989 kind TEXT NOT NULL, -- pickup | handled
990 guardian_handle TEXT,
991 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
992 PRIMARY KEY (note_uri, guardian_uri, kind)
993 )`);
994 db.exec('CREATE INDEX IF NOT EXISTS idx_ap_help_state_note ON ap_help_state(note_uri)');
995 // Een kind dat zelf om een poort vraagt (shaer-8ru). Een VRAAG, geen stem:
996 // pas als een guardian hem oppakt wordt het een voorstel dat langs de tally
997 // gaat. handled_at in plaats van verwijderen -- wat een kind gevraagd heeft
998 // hoort terug te vinden te zijn, ook als het antwoord nee was.
999 db.exec(`CREATE TABLE IF NOT EXISTS ap_gate_requests (
1000 id INTEGER PRIMARY KEY AUTOINCREMENT,
1001 slug TEXT NOT NULL, -- de guardian die hem ontving
1002 ward_uri TEXT NOT NULL,
1003 feature TEXT NOT NULL,
1004 note_uri TEXT,
1005 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
1006 handled_at TEXT,
1007 UNIQUE (slug, ward_uri, feature, handled_at)
1008 )`);
1009 db.exec('CREATE INDEX IF NOT EXISTS idx_ap_gate_requests_slug ON ap_gate_requests(slug, handled_at)');
1010 ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
1011 ensureColumn('ap_outbox', 'wave', 'INTEGER'); // FEP-633c shaer:wave (guardian -> ward nudge)
1012 ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms)
1013 ensureColumn('ap_gated_offers', 'proposer', 'TEXT'); // who proposed (5.6): the settle-answer goes back to them
1014 // Zou JOUW antwoord het besluit afmaken (shaer-8vt)? De telling loopt op de
1015 // server van het kind; zonder dit veld kan een guardian elders niet weten dat
1016 // hij de doorslag geeft. Ontbreekt hij, dan waarschuwen we -- bij twijfel.
1017 ensureColumn('ap_gated_reviews', 'decisive', 'INTEGER');
1018 // Did a guardian actually say yes to this follower? That is what makes the
1019 // mutual shortcut sound: a ward may follow back anyone its guardians already
1020 // admitted, without asking the same question twice. Only follows that came
1021 // through the §5.3 gate carry the mark; a free actor's followers never faced
1022 // one. Everyone already following when this column arrives is grandfathered
1023 // in (Barts besluit, 3-8): the rule is exact from that moment forward rather
1024 // than retroactively suspicious of relationships that already exist.
1025 {
1026 const had = db.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('ap_followers') WHERE name = 'gate_approved'").get();
1027 ensureColumn('ap_followers', 'gate_approved', 'INTEGER DEFAULT 0');
1028 if (!had || !had.n) {
1029 try { db.prepare('UPDATE ap_followers SET gate_approved = 1').run(); } catch { /* table still empty on a fresh init */ }
1030 }
1031 }
1032 ensureColumn('posts', 'c2s_attachments', 'TEXT'); // media a C2S Note carried (JSON [{url,mediaType,name}]); buildNote federates them
1033 // 30-7: C2S posts briefly got their content media copied onto the cover,
1034 // which showed the same video twice on the post page. Clear the covers that
1035 // duplicate their own content; idempotent, only ever touches those.
1036 try {
1037 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();
1038 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();
1039 } catch { /* posts table absent on fresh init */ }
1040 // Bestaande rijen naar EEN spelling (shaer-a937). De schrijfwegen leveren
1041 // sinds deze release ISO; dit haalt na wat er in SQL-notatie is blijven
1042 // staan, zodat de sortering ook zonder de isoSql-wikkel klopt.
1043 //
1044 // Alleen rijen met een spatie op positie 11 en geen 'T': dat is precies de
1045 // CURRENT_TIMESTAMP-vorm. Idempotent -- een tweede keer draaien vindt niets
1046 // meer -- en het raakt een ISO-stempel nooit aan.
1047 //
1048 // strftime geeft NULL op iets dat het niet als tijd herkent; de WHERE laat
1049 // zulke rijen met rust, want een onleesbare stempel vervangen door NULL is
1050 // gegevens weggooien. Ze blijven staan zoals ze stonden.
1051 {
1052 const kolommen = [
1053 ['ap_timeline', 'created_at'], ['ap_timeline', 'published'],
1054 ['ap_mentions', 'created_at'], ['ap_mentions', 'published'],
1055 ['ap_interactions', 'created_at'], ['ap_interactions', 'published'],
1056 ['ap_outbox', 'created_at'],
1057 ];
1058 let veranderd = 0;
1059 for (const [tabel, kolom] of kolommen) {
1060 try {
1061 const r = db.prepare(
1062 `UPDATE ${tabel} SET ${kolom} = strftime('%Y-%m-%dT%H:%M:%SZ', ${kolom})
1063 WHERE ${kolom} IS NOT NULL AND ${kolom} LIKE '____-__-__ %'
1064 AND strftime('%Y-%m-%dT%H:%M:%SZ', ${kolom}) IS NOT NULL`).run();
1065 veranderd += r.changes;
1066 } catch { /* tabel bestaat nog niet op een verse installatie */ }
1067 }
1068 if (veranderd) console.log(`🕒 tijdstempels genormaliseerd: ${veranderd} rijen`);
1069 }
1070 ensureColumn('ap_mentions', 'wave', 'INTEGER'); // inbound guardian wave
1071 // FEP-633c §2.2: object hint that the author is a ward. Register-only for now;
1072 // used later at reddings-boei / escalation routing.
1073 ensureColumn('ap_timeline', 'has_guardians', 'INTEGER');
1074 ensureColumn('ap_mentions', 'has_guardians', 'INTEGER');
1075 // Berichten and de Krant render a post the same way, so a mention or a reply
1076 // needs the same trimmings a timeline row already has: custom emojis, the
1077 // media the note carried, and the quote / link-preview card.
1078 ensureColumn('ap_mentions', 'emoji_json', 'TEXT'); // FEP-9098, in the content
1079 ensureColumn('ap_mentions', 'actor_emoji_json', 'TEXT'); // FEP-9098, in the display name
1080 ensureColumn('ap_mentions', 'media_json', 'TEXT');
1081 ensureColumn('ap_mentions', 'quote_json', 'TEXT'); // FEP-044f quoted post
1082 ensureColumn('ap_mentions', 'embed_json', 'TEXT'); // external link preview
1083 // AS2 inReplyTo op een vermelding/bericht. Bestond hier niet, en daarmee ging
1084 // de ouder bij het opslaan verloren: elk ANTWOORD in een gesprek kwam bij de
1085 // client aan alsof het een gesprek begon (Robins melding, 26-8). Bestaande
1086 // rijen blijven leeg -- die ouder is niet meer te achterhalen zonder hem
1087 // opnieuw op te halen, en een verzonnen ouder is erger dan geen.
1088 ensureColumn('ap_mentions', 'in_reply_to', 'TEXT');
1089 // Zachte groepen (shaer-mbji, src/services/conversation-key.js). Een gesprek
1090 // is de SET deelnemers; die werd bij een binnenkomende note nergens bewaard,
1091 // dus een bericht van Anna aan jou en Ben wist niet meer dat Ben erbij was.
1092 //
1093 // `conv` is de sleutel, één keer berekend bij het schrijven: elke lezing
1094 // hoeft hem dan alleen te lezen, en een sleutel die bij elke query opnieuw
1095 // uit JSON gebouwd wordt is een plek om het verschillend te doen. NULL op
1096 // oude rijen, en dan valt de lezing terug op wat hij altijd deed.
1097 ensureColumn('ap_mentions', 'conv', 'TEXT');
1098 ensureColumn('ap_mentions', 'participants', 'TEXT'); // JSON: de anderen, zonder mij
1099 ensureColumn('ap_mentions', 'context', 'TEXT'); // FEP-7888
1100 ensureColumn('ap_outbox', 'conv', 'TEXT');
1101 ensureColumn('ap_outbox', 'context', 'TEXT');
1102 ensureColumn('ap_interactions', 'media_json', 'TEXT');
1103 ensureColumn('ap_interactions', 'quote_json', 'TEXT');
1104 ensureColumn('ap_interactions', 'embed_json', 'TEXT');
1105 ensureColumn('ap_followers', 'name', 'TEXT'); // cached display name (shaer-aa3)
1106 ensureColumn('ap_followers', 'handle', 'TEXT'); // @user@host
1107 ensureColumn('ap_followers', 'icon', 'TEXT'); // avatar URL
1108 feedStateTriggers();
1109}
1110
1111/**
1112 * Wat er met een tijdlijn gebeurd is, op één plek (shaer-n05).
1113 *
1114 * De inbox-lezing voegt vier bronnen samen. De vraag "is er iets veranderd" werd
1115 * eerst beantwoord met MAX(rowid) over die vier -- een TOEVALLIGE eigenschap van
1116 * de tabellen, geen feit dat ergens is opgeschreven. Dat gaf precies de gebreken
1117 * die je van zo'n afleiding verwacht: bewerkingen en verwijderingen bewogen hem
1118 * niet, en hij kon achteruit lopen. Dezelfde fout als reacties uitlezen uit
1119 * ap_timeline.liked (shaer-9e9).
1120 *
1121 * Nu één rij per bericht per tijdlijn, met een oplopende `rev` en `kind`. Dat
1122 * beantwoordt drie vragen die anders drie eigen oplossingen zouden krijgen:
1123 * is er iets veranderd sinds N, wát is er veranderd, en is dit bericht bewerkt.
1124 *
1125 * Bijgehouden door TRIGGERS en niet door de aanroepende code, om dezelfde reden
1126 * dat er geen gebeurtenis-emitter is: een trigger zit in de database, dus geen
1127 * enkel codepad kan hem vergeten. De prijs is onzichtbare logica -- wie alleen de
1128 * JavaScript leest ziet niet waarom deze tabel vult. Vandaar dat ze hier staan,
1129 * bij de tabel, en niet verspreid.
1130 *
1131 * Let op de `UPDATE OF`-kolomlijsten: die zijn niet decoratief. Een like schrijft
1132 * ap_timeline.liked en een 🔁 schrijft .boosted; zonder die afbakening zou je
1133 * eigen like het bericht als BEWERKT merken en elke wachtende client wekken.
1134 */
1135function feedStateTriggers() {
1136 try {
1137 db.exec(`
1138 -- Tot waar jij een gesprek gelezen hebt (shaer-frontend-3tx).
1139 --
1140 -- Een MARKERING, geen teller: het aantal ongelezen berichten is een
1141 -- COUNT over de berichten die na deze cursor komen. Een opgeslagen
1142 -- getal zou opgehoogd, verlaagd en gerepareerd moeten worden, en zou
1143 -- blijven staan als er iets verwijderd wordt -- badge zegt 3, er is
1144 -- niets.
1145 --
1146 -- De cursor is samengesteld ('<stempel>|<ref>'), dezelfde vorm als de
1147 -- gesprekspaginering en om dezelfde reden: twee berichten in dezelfde
1148 -- seconde is bij DM's een gesprek, geen randgeval.
1149 CREATE TABLE IF NOT EXISTS ap_read_markers (
1150 slug TEXT NOT NULL,
1151 other TEXT NOT NULL, -- de tegenpartij (actor uri)
1152 cursor TEXT NOT NULL,
1153 at DATETIME DEFAULT CURRENT_TIMESTAMP,
1154 PRIMARY KEY (slug, other)
1155 );
1156 CREATE TABLE IF NOT EXISTS ap_feed_state (
1157 slug TEXT NOT NULL,
1158 object_uri TEXT NOT NULL,
1159 rev INTEGER NOT NULL,
1160 kind TEXT NOT NULL, -- new | updated | deleted
1161 at DATETIME DEFAULT CURRENT_TIMESTAMP,
1162 PRIMARY KEY (slug, object_uri)
1163 );
1164 CREATE INDEX IF NOT EXISTS idx_ap_feed_state_rev ON ap_feed_state(slug, rev);
1165 -- Eén doorlopende teller voor de hele instance. Bewust niet MAX(rev) uit de
1166 -- tabel zelf: verdwijnt de hoogste rij, dan zou die teruglopen en denkt een
1167 -- client dat er niets gebeurd is.
1168 CREATE TABLE IF NOT EXISTS ap_feed_rev (n INTEGER NOT NULL);
1169 `);
1170 if (!db.prepare('SELECT COUNT(*) AS n FROM ap_feed_rev').get().n) {
1171 db.prepare('INSERT INTO ap_feed_rev (n) VALUES (0)').run();
1172 }
1173 // slug + object_uri verschillen per bron; de rest is voor alle vier gelijk.
1174 const zet = (naam, gebeurtenis, tabel, slug, uri, kind, extra = '', wanneer = '') => `
1175 DROP TRIGGER IF EXISTS ${naam};
1176 CREATE TRIGGER ${naam} AFTER ${gebeurtenis} ON ${tabel}${wanneer ? ` WHEN ${wanneer}` : ''} BEGIN
1177 UPDATE ap_feed_rev SET n = n + 1;
1178 INSERT INTO ap_feed_state (slug, object_uri, rev, kind)
1179 ${extra || `VALUES (${slug}, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}')`}
1180 ON CONFLICT(slug, object_uri) DO UPDATE
1181 SET rev = excluded.rev, kind = excluded.kind, at = CURRENT_TIMESTAMP;
1182 END;`;
1183 const joinPosts = (uri, kind) => `
1184 SELECT s.slug, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}'
1185 FROM posts p JOIN sites s ON s.id = p.site_id`;
1186 db.exec([
1187 zet('trg_feed_tl_ins', 'INSERT', 'ap_timeline', 'NEW.slug', 'NEW.id', 'new'),
1188 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'),
1189 zet('trg_feed_tl_del', 'DELETE', 'ap_timeline', 'OLD.slug', 'OLD.id', 'deleted'),
1190 zet('trg_feed_mn_ins', 'INSERT', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'new'),
1191 zet('trg_feed_mn_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'updated'),
1192 zet('trg_feed_mn_del', 'DELETE', 'ap_mentions', 'OLD.slug', 'OLD.object_uri', 'deleted'),
1193 zet('trg_feed_ob_ins', 'INSERT', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'new'),
1194 zet('trg_feed_ob_upd', 'UPDATE OF content, attachments', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'updated'),
1195 zet('trg_feed_ob_del', 'DELETE', 'ap_outbox', 'OLD.site_slug', 'OLD.id', 'deleted'),
1196 // ap_interactions draagt geen slug: die hangt aan de POST. Vandaar de join,
1197 // en vandaar dat deze drie niet in de gewone vorm passen.
1198 //
1199 // De WHEN op kind='reply' is nodig omdat deze tabel ook likes en announces
1200 // draagt, en die schrijven object_uri = '' (zie recordInteraction). Zonder de
1201 // WHEN bumpte elke inkomende like de rev, werd elke wachter gewekt en kreeg
1202 // die de hele collectie opnieuw terwijl er niets aan veranderd was: precies de
1203 // kosten die de 304 moest wegnemen. Bovendien belandde er dan een rij op de
1204 // lege string in ap_feed_state, die feedChangesSince vervolgens uitdeelt.
1205 // De oude cursor filterde hier wel op kind; bij ap_timeline is dit ook gedaan
1206 // (de UPDATE OF sluit liked/boosted uit) en één tabel verder vergeten.
1207 zet('trg_feed_ia_ins', 'INSERT', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'new')} WHERE p.id = NEW.post_id`, "NEW.kind = 'reply'"),
1208 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'"),
1209 zet('trg_feed_ia_del', 'DELETE', 'ap_interactions', '', '', '', `${joinPosts('OLD.object_uri', 'deleted')} WHERE p.id = OLD.post_id`, "OLD.kind = 'reply'"),
1210 ].join('\n'));
1211 } catch (e) {
1212 // Niet fataal: zonder deze tabel valt het wachten terug op "altijd de tijd
1213 // volmaken", en dat is traag maar niet stuk.
1214 console.error('❌ feed-state triggers:', e.message);
1215 }
1216}
1217
1218function ensureColumn(table, column, definition) {
1219 try {
1220 db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
1221 console.log(`🔧 Added column ${table}.${column}`);
1222 } catch (e) {
1223 // "duplicate column name" → already there. Anything else, surface it.
1224 if (!/duplicate column/i.test(e.message)) {
1225 console.error(`❌ ensureColumn(${table}.${column}):`, e.message);
1226 }
1227 }
1228}
1229
1230export default db;
Note: See TracBrowser for help on using the repository browser.