source: Klonkt/src/config/database.js@ 709dc6f

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

Twee richtingen, twee poorten: shaer:following naast shaer:follows

In de catalogus stond één rij voor twee mechanismen. Het paneel telde
listReviewsByDirection(slug, 'incoming') en zette dat getal onder
shaer:follows, dus een guardian las "3 wachtend" en wist niet of er drie
vreemden bij zijn kind wilden of dat zijn kind drie keer had gevraagd of het
iemand mocht volgen. Dat zijn niet dezelfde zorg, en sinds shaer-p729 bestaan
ze allebei echt.

Nu twee poorten, en het verschil ertussen is opzet. §5.3 EIST dat een Follow
naar een ward langs de guardians gaat: shaer:follows blijft dus fixed. Over de
andere richting zegt de FEP niets — wat je verder gated is expliciet aan de
implementatie gelaten — dus shaer:following is onze keuze, en dan hoort hij ook
echt losgelaten te kunnen worden. Verstelbaar, met gate_following als kolom en
dezelfde automatiek als de rest: onbeslist is dicht voor een ward en open voor
ieder ander. Een kind dat erin groeit hoeft niet eeuwig te blijven vragen.

Geen eigen ownFollowsAllowed(): wardGateAllowed() is er al en zegt er zelf bij
dat het één implementatie hoort te zijn. Een derde kopie zou precies de tweede
plek zijn die er anders over kan gaan denken.

De labels zijn nog aan de clients: de server geeft de feature-naam door, de
Guardian PWA en Shaer moeten er nog woorden bij kiezen.

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

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