source: Klonkt/src/config/database.js@ f704237

main
Last change on this file since f704237 was fbfd7a1, checked in by Robin <roboburr@โ€ฆ>, 4 weeks ago

Je berichten verhuizen mee: FEP-1580 plus een webinterface ervoor

FEP-7628 verhuist je volgers en zegt zelf dat de inhoud een ander probleem is.
Dat probleem stond open: na een Move bleven je berichten op de oude instantie
staan, en elke reactie van een derde wees naar een URI die verdwijnt zodra dat
domein opgezegd wordt. FEP-1580 regelt dat, status DRAFT.

DE AUTORISATIE IS DE MOVE, NIET EEN CODE. De bronkant behandelt een ondertekend
verzoek namens de doel-actor alsof de bron-actor het zelf deed. Dat mag omdat
moveAccount() no_backreference weigert: moved_to staat er alleen als de
doel-actor ons al in alsoKnownAs had. Beide kanten hebben ooit ja gezegd, dus er
is geen tweede vertrouwensmechanisme nodig. Een typefout komt hier niet binnen,
want die haalt de move zelf niet. Dat dit veilig is leunt op de keyId-binding
uit shaer-xd8i: zonder die controle is "wie tekende dit" te zacht om je hele
geschiedenis aan af te geven.

NIEUWE IDS ZIJN GEEN BUG, DE VERTAALTABEL IS HET ANTWOORD. Een verhuisd bericht
krijgt een eigen URI, want het staat op een ander domein. De migration-collectie
mapt oud naar nieuw en derden lezen die om hun eigen verwijzingen bij te werken.
Zonder die collectie is de draad kapot, met die collectie is het een
verhuisbericht. Niet-publieke items staan er alleen in voor wie ze mocht zien:
een lijst met de URIs van je fan-only posts is een lek, ook zonder de inhoud.

Er gaat geen Create de deur uit. Je volgers hebben die berichten jaren geleden
al gezien; driehonderd posts die als nieuw de tijdlijn in klateren is geen
verhuizing maar spam.

Daarnaast /admin/migrate: exporteren, importeren en ophalen via de
webinterface, zodat verhuizen geen SSH-toegang meer vraagt. Importeren gaat
altijd eerst droog, met een verslag en pas daarna een knop die het echt doet.

Getest op twee draaiende instanties, A verhuisd naar B via de echte
moveAccount. Anoniem zag A 3 van de 4 berichten; ondertekend als de doel-actor
kwamen alle 4 mee, inclusief de fan-only. Titel, webadres en publicatiedatum
blijven staan. Media komt echt over: gedownload, in de mediatabel, B serveert
het. Migration-collectie 4 rijen totaal, 3 publiek.

Changed files:
src/services/ActivityPubService.js

  • isMoveTarget: het hele autorisatiepredicaat van de bronkant
  • outboxAudience en mayReadNote: de doel-actor krijgt onze eigen kijkrechten
  • buildActor adverteert migration en moves, ook leeg (de FEP wijst er apart op dat "niets verhuisd" anders niet te onderscheiden is van "kent dit niet")
  • signedGetJson geexporteerd, de ingest heeft hem nodig
  • isMoveTarget en signedGetJson in de default export (movedLock verstopte zich een dag eerder precies zo)

src/services/ap-core.js

  • FEP-1580-termen in de JSON-LD-context

src/services/ArchiveImportService.js

  • een import uit een zip vult dezelfde vertaaltabel; de spec wil dat een geexporteerde collectie identiek behandeld wordt

src/routes/activitypub.js

  • /ap/users/:slug/migration en /moves
  • de blocked-collectie gaat open voor de doel-actor, want zichtbaarheidsvoorkeuren moeten meeverhuizen

src/config/database.js

  • ap_migration en ap_moves, plus sites.migration_complete

src/server.js

  • /admin/migrate aangesloten

src/views/pages/admin.ejs

  • knop naar Migreren

src/services/i18n.js

  • mig.* in nl/en/de

New file:
src/services/MigrationService.js

  • de doelkant: ingest-routine, migration- en moves-collectie, statusvlag

src/routes/admin-migrate.js

  • exporteren, droog importeren, echt importeren, ophalen bij de oude Klonkt

src/views/pages/admin-migrate.ejs

  • de pagina

test/fep1580-migration.test.js

  • 22 tests over beide rollen, plus de regressietest bij 4101c89

remarks: FEP-8b32 ontbreekt volledig (shaer-j1v0), dus er staat geen
handtekening onder de moves-collectie en we zijn niet naleveringsklaar. Bewust
geen leeg proof-veld: een derde die het controleert wordt dan misleid. De DERDE
rol zit er ook niet in, Klonkt leest nog geen migration-collecties van anderen,
dus andermans verhuizing repareert onze verwijzingen nog niet. Alle betrokken
FEPs zijn DRAFT, ook 7628 die we al volgden; 1580 is vers en de auteur schrijft
zelf dat het een audit verdient.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@โ€ฆ>

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