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

main
Last change on this file since f06ed56 was f06ed56, checked in by Bart <bart@…>, 4 weeks ago

Een logboek, zodat de reden ergens blijft staan

Guardianship-events waren vluchtig: onGuardianshipEvent wekte de long-poll,
zocht in een tabel van zeven soorten of er een push bij hoorde, en liet de rest
vallen. Elf van de achttien soorten verdwenen spoorloos, met hun inhoud. Een
weigering droeg reason: 'not_a_teapot' tot precies daar en niet verder --
terwijl §4.2 eist dat de ward en zijn guardians die reden te HOREN krijgen, en
niet dat ze hem afleiden uit een aanbod dat opeens weg is.

Vastleggen en melden zijn nu twee dingen. Alles komt in ap_guardian_events;
welke gebeurtenis een mens wakker maakt blijft de aparte, korte lijst die het
altijd al was.

Ingeklapt en onderaan, want hier vraagt niets om een antwoord. Zou dit tussen
de wachtrijen staan, dan wordt "moet ik iets doen" onleesbaar -- dezelfde reden
waarom de afgehandelde hulpvragen daar al staan.

200 per account, jongste eerst. Een logboek dat oneindig groeit wordt er een
die niemand opent, en afkappen aan de verkeerde kant zou hem onbruikbaar maken
op het moment dat er juist iets gebeurt. Schrijffouten worden geslikt: het
logboek is bijzaak en mag de commit of de weigering zelf nooit meesleuren.

Onbekende soorten vallen in de PWA terug op hun ruwe naam. Zichtbaar en lelijk
is beter dan netjes en afwezig.

Co-Authored-By: Claude Opus 5 <claude@…>

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