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

main
Last change on this file since 7cfe35a was a29c8c8, checked in by roboburr <roboburr@โ€ฆ>, 5 weeks ago

Een hulpvraag oppikken en afsluiten, zichtbaar voor alle guardians (shaer-lgo)

Een hulpvraag (5.2.1) gaat naar ALLE guardians van een kind, op verschillende
servers. Zonder gedeelde staat denken er twee dat de ander het oppakt -- precies
het scenario waar de reddingsboei voor bestaat. Tot nu toe droeg een hulpvraag
geen enkele staat: een vlag op ap_mentions, verder niets.

DE FAALSTAND IS HIER NIET VEILIG, en dat stuurt het hele ontwerp. Bij een gate is
"dicht" het veilige antwoord. Hier is de faalstand "iedereen denkt dat het
geregeld is", en dat is gevaarlijker dan geen markering. Daarom staat een
hulpvraag bij twijfel OPEN: een lege lijst, een rij die we niet kunnen lezen, een
soort die we niet kennen -- alles wat geen expliciete afsluiting is telt als
"er wacht nog iemand".

Twee besluiten van Bart zitten in de vorm.

OPGEPIKT mag stapelen en vervalt niet, maar veroudert zichtbaar. Twee mensen die
tegelijk reageren op een kind is geen probleem; twee die allebei niets doen omdat
de ander het "geclaimd" had, wel. En een signaal dat vanzelf verdwijnt laat een
hulpvraag er onaangeroerd uitzien terwijl er iemand mee bezig is -- dus het blijft
staan en toont hoe oud het is.

AFGEHANDELD kent GEEN terugdraai. Sluiten gaat met een stevige bevestiging (geen
window.confirm, zelfde lijn als het loslaten van een ward), en leeft de vraag
daarna nog, dan wordt hij opnieuw gesteld -- een nieuwe hulpvraag. Er wordt niets
herschreven, er wordt toegevoegd. Een test bewaakt dat er geen undo() of
reopen() bestaat.

Geen nieuw protocol: de markering is een gewone directe note met een
shaer:-eigenschap, net als de zwaai en de afwezigheidsmelding. Daardoor reist hij
over de bestaande bezorging, ziet de WARD hem als bericht ("er komt iemand") en
houden de mede-guardians er staat aan over. Naar de ward en naar de andere
guardians tegelijk.

De eigen kopie wordt meteen weggeschreven, voordat er bezorgd is: het scherm van
degene die klikt hoort niet te liegen omdat een andere server traag is.

11 tests op het pure stuk. Gecontroleerd dat ze bijten: laat helpStatus altijd
"afgehandeld" zeggen en er vallen er vijf om. nl/en/de. Suite 576/576.

Niet gedekt: de weergave zelf (client-JS), en er is geen guardianship op dev om
het end-to-end te zien lopen.

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