source: Klonkt/src/config/database.js@ 2d13bf3

main
Last change on this file since 2d13bf3 was ba76bf5, checked in by Robin <roboburr@…>, 5 weeks ago

Drie gaten uit de 1.7.0-review: index, wekkers, en de exporter-tijdzone

Drie losstaande reparaties uit de review van de 66 commits. Elk mechanisch, elk met
een tegenproef dat de test zonder de fix ook echt faalt.

  1. De permalink-lookup van reacties had geen index. canonicalReactionUri zoekt op

(slug, url) en viel terug op idx_ap_timeline_slug, dus een scan van elke rij van die
slug. Dat gebeurt PER REACTIE in getInteractions, en de reactie-migratie erft het in
haar re-key-join die synchroon voor listen draait: de opstartkosten waren reacties
maal tijdlijnrijen. Nagemeten met EXPLAIN QUERY PLAN, nu een indexzoek.

  1. De feed-wekkers gingen af op likes en boosts. ap_interactions draagt naast replies

ook likes en announces, en die schrijven een LEGE object_uri (regel 2310, tegenover
o.id bij replies). Zonder WHEN bumpte elke inkomende like de rev, werd elke
long-poll-wachter gewekt, en kreeg die de hele collectie opnieuw terwijl er niets aan
veranderd was: precies de kosten die de 304 moest wegnemen. Bovendien belandde er een
rij op de lege string in ap_feed_state, die feedChangesSince als sleutel uitdeelt. Bij
ap_timeline was hier wel op gelet (de UPDATE OF sluit liked en boosted uit) en een
tabel verder vergeten.

  1. De exporter schreef lokale tijd als UTC. Kale Date.parse op SQLite's

'YYYY-MM-DD HH:MM:SS' leest dat als lokale tijd, dus op UTC+2 ging er twee uur van
elke stempel af voordat hij het archief in ging. Die verschuiving wordt bij het
exporteren INGEBAKKEN en valt niet weg bij het importeren. Het raakte de stempels die
de database zelf zet (concepten, ingeplande posts, gearchiveerde antwoorden), niet die
uit de editor, dus de schade was stil en gedeeltelijk. Dezelfde correctie stond al
twee keer in deze codebase, in isoStamp() en stampMs(); de exporter had de les gemist.
EXPORT-FORMAT.md schreef altijd al "ISO 8601, UTC" voor.

Changed files:
src/config/database.js

  • index idx_ap_timeline_url op (slug, url)
  • zet() kreeg een optionele wanneer-parameter voor een WHEN op de trigger
  • de drie ap_interactions-triggers filteren nu op kind='reply' (OLD bij delete)

src/services/ArchiveExportService.js

  • toISO leest SQL-notatie expliciet als UTC

New file:
test/feed-state-triggers.test.js

  • like en boost wekken niets, reply wel, verwijderde reply ook
  • er staat nooit een rij op de lege sleutel
  • en de permalink-lookup gebruikt de index, via EXPLAIN QUERY PLAN

remarks: tegenproef gedaan op alle drie. Zonder de database-fixes 4 van 6 rood; zonder
de exporter-fix valt de archief-test om in Europe/Amsterdam en niet in UTC. Volledige
suite 587 groen in beide tijdzones. De vierde vondst, de guardian-controle op
hulpmarkeringen, zit hier NIET in: die vraagt de guardian-set van de ward federatief
op te halen via shaer:guardians en dat is ontwerpwerk, geen mechanische fix.

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

  • Property mode set to 100644
File size: 49.4 KB
RevLine 
[7bc636b]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');
[7b07035]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
[7bc636b]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');
[72ec6a4]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.)
[5c373b8]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 )`);
[fa33214]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 )`);
[2b4252c]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 )`);
[1e172f3]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');
[7bc636b]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');
[834bcc3]122 // Google OAuth: link a Google account to a user (login via Google).
[c80e78b]123 ensureColumn('users', 'google_sub', 'TEXT');
[834bcc3]124 // Read-only/viewer account: can view everything but make no changes.
[640b39c]125 ensureColumn('users', 'readonly', 'INTEGER DEFAULT 0');
[834bcc3]126 // Personal interface language (nl|en|de). Null = follow the default (site/env/browser).
[5e61b17]127 ensureColumn('users', 'lang', 'TEXT');
[7bc636b]128 // Site-level moderation toggle. 'trust' = auto-approve, 'moderate' = pending until reviewed.
[834bcc3]129 // Circles: whether this site may appear in other sites' circles (surfacing opt-out).
[0091cb7]130 ensureColumn('sites', 'allow_circle', 'INTEGER DEFAULT 1');
[7bc636b]131
[834bcc3]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.
[7881080]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 }
[834bcc3]143 } catch (e) { /* sites table still empty/absent on fresh init — ensurePrimarySite handles it */ }
[7881080]144
[7bc636b]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}]
[8ea3d0d]157 ensureColumn('sites', 'feed_view_default', "TEXT DEFAULT 'grid'"); // timeline | grid
[7bc636b]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');
[fc40410]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');
[e27b8db]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');
[fc40410]171 ensureColumn('sites', 'og_theme', 'TEXT'); // OG share-card variant: NULL=auto (follow site theme) | 'light' | 'dark'
[ccaa530]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');
[0ca7e9a4]176 ensureColumn('sites', 'moved_to', 'TEXT'); // FEP-7628 slice 2: waarheen dit account vertrok
[7bc636b]177
178 // Per-post noindex + type
179 ensureColumn('posts', 'noindex', 'INTEGER DEFAULT 0');
[834bcc3]180 ensureColumn('posts', 'publish_at', 'DATETIME'); // release planning (premium #3): scheduled go-live
[b9dc94c]181 ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0'); // fan-only preview (premium #3)
[837fc9c]182 ensureColumn('posts', 'nsfw', 'INTEGER DEFAULT 0'); // sensitive content → blur + click-to-reveal; fediverse sensitive
[1d6f9a2]183 ensureColumn('posts', 'cover_video_url', 'TEXT'); // muted loop MP4 for an animated cover (Safari-smooth)
[d18c60e]184 ensureColumn('posts', 'cover_alt', 'TEXT'); // alt text / description for the cover (a11y → AS2 attachment `name`)
[0688b5f]185 ensureColumn('posts', 'language', 'TEXT'); // BCP-47 content language → federates as AS2 contentMap (Mastodon language filter/translate)
[b7d4458]186 ensureColumn('posts', 'content_warning', 'TEXT'); // custom CW label (empty = default "Gevoelige inhoud")
[7bc636b]187 ensureColumn('posts', 'type', "TEXT DEFAULT 'post'"); // post | foto | video | audio
[0403187]188 ensureColumn('posts', 'poll_json', 'TEXT'); // a poll WE host → federates as AS2 Question: {multiple,options[{name}],endTime,closed}
[7bc636b]189
[834bcc3]190 // Statistics (premium module) — bare counters, cookie-free.
191 ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0'); // views per post
[d549549]192 ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0'); // plays per track
[834bcc3]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
[183875b]197 ensureColumn('audio_tracks', 'link_youtube', 'TEXT');
198 ensureColumn('audio_tracks', 'link_soundcloud', 'TEXT');
[f2eacca]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');
[d549549]203
[7bc636b]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 `);
[6351545]230
[834bcc3]231 // Global app settings (key/value singleton). Includes the tenancy mode
232 // (solo = one site, hub = company site + /user/). Default = solo.
[6351545]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();
[b300682]241
[834bcc3]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).
[d549549]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);
[1794fac]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 );
[d549549]267 `);
268
[834bcc3]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).
[2e247e4]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
[834bcc3]287 // Sent newsletters (history + counts).
[2e247e4]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 `);
[37edecd]298
[834bcc3]299 // Show agenda (premium #8): tour dates / gigs per site.
[8d32dcf]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
[834bcc3]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.
[37edecd]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 `);
[535f955]327
[6bd25d1]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);
[c16e0a5]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,
[7d932ce]361 parent_uri TEXT, -- the note this reply replies to (for nesting)
[c16e0a5]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);
[67c1f24]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 );
[d49b60b]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 );
[61e3daf]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 );
[9e9e6f9]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 );
[ad10715]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 );
[55bc7f9]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);
[3d37c67]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 );
[6bd25d1]462 `);
[7d932ce]463 ensureColumn('ap_interactions', 'parent_uri', 'TEXT'); // nesting (existing DBs)
[c745659]464 ensureColumn('ap_interactions', 'acted_boost', 'INTEGER DEFAULT 0'); // owner boosted this comment (🔁) → can undo
[3289a64]465 ensureColumn('ap_interactions', 'acted_like', 'INTEGER DEFAULT 0'); // owner liked this comment (⭐) → can undo
[914eb9f]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 );
[32a4ffb]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);
[914eb9f]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);
[ba76bf5]501 -- canonicalReactionUri herleidt een permalink naar het object-id door op (slug, url)
502 -- te zoeken. Zonder deze index viel dat terug op idx_ap_timeline_slug, dus een scan
503 -- van elke rij van die slug. Dat gebeurt PER REACTIE in getInteractions, en de
504 -- reactie-migratie erft het in haar re-key-join, die synchroon vóór listen draait:
505 -- de opstartkosten waren reacties maal tijdlijnrijen.
506 CREATE INDEX IF NOT EXISTS idx_ap_timeline_url ON ap_timeline(slug, url);
[f5c3870]507 CREATE TABLE IF NOT EXISTS ap_blocks (
508 id INTEGER PRIMARY KEY AUTOINCREMENT,
509 slug TEXT NOT NULL, -- our site that set the block
510 target TEXT NOT NULL, -- actor URI (actor block) or domain (domain block)
511 kind TEXT NOT NULL, -- 'actor' | 'domain'
512 label TEXT, -- display (@handle or domain)
513 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
514 UNIQUE(slug, target)
515 );
516 CREATE INDEX IF NOT EXISTS idx_ap_blocks_target ON ap_blocks(target);
[780a7c6]517 -- Committed guardian ↔ ward relations, one row per local side. role
518 -- 'ward' = the local slug is a ward of other_uri; 'guardian' = the local
519 -- slug guards other_uri. status is always 'accepted' here now: PENDING
520 -- offers live in ap_guardian_offers below (FEP-633c multi-party handshake).
[6b5d7da]521 CREATE TABLE IF NOT EXISTS ap_guardianships (
522 id INTEGER PRIMARY KEY AUTOINCREMENT,
523 slug TEXT NOT NULL, -- our local site in this relation (guardianship module)
524 role TEXT NOT NULL, -- 'guardian' (slug guards other) | 'ward' (other guards slug)
525 other_uri TEXT NOT NULL, -- the counterpart actor URI (local or remote)
526 other_handle TEXT, -- cached @user@host for display
[780a7c6]527 status TEXT NOT NULL, -- 'offered' (legacy) | 'accepted'
[6b5d7da]528 offer_id TEXT, -- the Offer activity id (FEP-633c section 3)
529 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
530 UNIQUE(slug, role, other_uri)
531 );
532 CREATE INDEX IF NOT EXISTS idx_ap_guardianships_slug ON ap_guardianships(slug, role, status);
[780a7c6]533 -- The multi-party handshake (FEP-633c section 3), one row per offer this
534 -- instance is a party to. Mirrors the Shaer test daemon's Handshake:
535 -- accepts accumulate in ap_guardian_offer_accepts, and the offer commits
536 -- only when the candidate returns the handle after ward + candidate + at
537 -- least one existing guardian have accepted.
538 CREATE TABLE IF NOT EXISTS ap_guardian_offers (
539 offer_id TEXT NOT NULL, -- the Offer activity id (minted by the candidate)
540 slug TEXT NOT NULL, -- the local site tracking this handshake (each party keeps its own copy)
541 ward_uri TEXT NOT NULL, -- the ward-to-be
542 candidate_uri TEXT NOT NULL, -- the guardian-candidate (fixed initiator)
543 existing_guardians TEXT NOT NULL DEFAULT '[]', -- JSON array of the ward's current guardian URIs
544 status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'committed' | 'void'
545 handle TEXT, -- the escalation handle returned at commit (section 6)
546 ward_handle TEXT, -- cached @ward@host for display
547 candidate_handle TEXT, -- cached @candidate@host for display
548 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
549 PRIMARY KEY (slug, offer_id)
550 );
551 CREATE INDEX IF NOT EXISTS idx_ap_guardian_offers_slug ON ap_guardian_offers(slug, status);
552 CREATE TABLE IF NOT EXISTS ap_guardian_offer_accepts (
553 offer_id TEXT NOT NULL, -- FK to ap_guardian_offers
554 slug TEXT NOT NULL, -- the local site's copy of the tally
555 party_uri TEXT NOT NULL, -- the party who accepted (ward | candidate | an existing guardian)
556 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
557 PRIMARY KEY (slug, offer_id, party_uri)
558 );
[65abc85]559 -- FEP-633c §5.6: a gated setting a ward's guardians decide together, which
560 -- has to work when they live on other servers (the ordinary case). One row
561 -- per guardian answer; the ward's server tallies (§3.5) and enforces.
562 -- The proposals themselves, so an Accept that only references the offer
563 -- id can still be resolved to "which feature, which value".
564 CREATE TABLE IF NOT EXISTS ap_gated_offers (
565 offer_id TEXT PRIMARY KEY,
566 slug TEXT NOT NULL, -- the ward, on this server
567 feature TEXT NOT NULL,
568 value INTEGER NOT NULL,
569 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
570 );
[88d7c8f]571 -- The guardian-side COPY of a gated-setting proposal on a ward, forwarded
572 -- here by the WARD's server (the same shape ap_follow_reviews has for a
573 -- gated follow). Without it a guardian on another server never learns a
574 -- proposal exists and can never answer it, so a threshold of two can never
575 -- be reached and every proposal expires. The answer goes back to the
576 -- ward's inbox, which tallies (5.6).
577 CREATE TABLE IF NOT EXISTS ap_gated_reviews (
578 id TEXT NOT NULL, -- the offer id, as minted by the proposer
579 guardian_slug TEXT NOT NULL, -- us, one of the ward's guardians
580 ward_uri TEXT NOT NULL,
581 ward_inbox TEXT,
582 proposer TEXT, -- who opened it (for display)
583 feature TEXT NOT NULL,
584 value INTEGER NOT NULL,
585 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
586 PRIMARY KEY (guardian_slug, id)
587 );
[d56d471]588 -- The PROPOSER's own record of a gated proposal it sent (5.6). Without it
589 -- a guardian clicks "propose", the ward's server tallies somewhere else,
590 -- and the proposer has nowhere to even see that something is running: the
591 -- status was a button caption that did not survive a page refresh. The
592 -- ward's server answers the Offer once the decision settles (Accept when
593 -- it settled on the proposed value, Reject otherwise); that answer lands
594 -- in status. An open row past the decision window renders as expired.
595 CREATE TABLE IF NOT EXISTS ap_gated_sent (
596 offer_id TEXT PRIMARY KEY, -- as minted by us, the proposer
597 guardian_slug TEXT NOT NULL, -- us
598 ward_uri TEXT NOT NULL,
599 feature TEXT NOT NULL,
600 value INTEGER NOT NULL, -- what we proposed
601 status TEXT NOT NULL DEFAULT 'open', -- 'open' | 'accepted' | 'rejected'
602 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
603 );
[65abc85]604 CREATE TABLE IF NOT EXISTS ap_gated_votes (
605 slug TEXT NOT NULL, -- the WARD, on this server
606 feature TEXT NOT NULL, -- e.g. 'shaer:externalEmbeds'
607 guardian_uri TEXT NOT NULL, -- who answered (must be a committed guardian)
608 value INTEGER NOT NULL, -- the value they voted for (0/1)
609 opened_at DATETIME NOT NULL, -- when this decision opened (the window start)
610 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
611 PRIMARY KEY (slug, feature, guardian_uri)
612 );
[6eab7e9]613 -- Guardian availability (FEP-633c 3.6): one guardian's attention as seen
614 -- from one ward on this server. Never public; the ward reads it via the
615 -- owner-only guardians queue. One rule above all: one answer restores
616 -- everything, so every row here is one answer away from disappearing.
617 CREATE TABLE IF NOT EXISTS ap_guardian_attention (
618 ward_slug TEXT NOT NULL,
619 guardian_uri TEXT NOT NULL,
620 state TEXT NOT NULL DEFAULT 'active', -- 'active' | 'away' | 'dormant'
621 away_until INTEGER, -- epoch ms while declared away
622 PRIMARY KEY (ward_slug, guardian_uri)
623 );
624 -- The ONLY admissible dormancy evidence (3.6.2): directly addressed
625 -- requests that went unanswered. Calendar time alone never counts.
626 CREATE TABLE IF NOT EXISTS ap_attention_requests (
627 ward_slug TEXT NOT NULL,
628 guardian_uri TEXT NOT NULL,
629 request_id TEXT NOT NULL,
630 asked_at INTEGER NOT NULL, -- epoch ms
631 PRIMARY KEY (ward_slug, guardian_uri, request_id)
632 );
633 -- A lapse (3.6.3): the available co-guardians deciding to release a
634 -- dormant one. Irreversible, so the window always runs in full; any sign
635 -- of life from the target cancels it outright.
636 CREATE TABLE IF NOT EXISTS ap_lapses (
637 id TEXT PRIMARY KEY,
638 ward_slug TEXT NOT NULL,
639 ward_uri TEXT NOT NULL,
640 target_uri TEXT NOT NULL,
641 opened_by TEXT NOT NULL,
642 set_json TEXT NOT NULL, -- the available set at open, target excluded
643 accepts_json TEXT NOT NULL DEFAULT '[]',
644 rejects_json TEXT NOT NULL DEFAULT '[]',
645 opened_at INTEGER NOT NULL, -- epoch ms
646 window_ms INTEGER NOT NULL,
647 cancelled INTEGER NOT NULL DEFAULT 0,
648 applied INTEGER NOT NULL DEFAULT 0,
649 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
650 );
[5a6a457]651 CREATE TABLE IF NOT EXISTS ap_delivery (
652 id INTEGER PRIMARY KEY AUTOINCREMENT,
653 slug TEXT NOT NULL, -- our site/actor that signs the delivery
654 inbox TEXT NOT NULL, -- recipient inbox URL
655 body TEXT NOT NULL, -- the activity JSON to POST
656 attempts INTEGER NOT NULL DEFAULT 0,
657 next_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
658 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
659 );
660 CREATE INDEX IF NOT EXISTS idx_ap_delivery_due ON ap_delivery(next_at);
[0403187]661 CREATE TABLE IF NOT EXISTS poll_votes (
662 id INTEGER PRIMARY KEY AUTOINCREMENT,
663 post_id INTEGER NOT NULL, -- our local poll post (posts.id)
664 actor_uri TEXT NOT NULL, -- the remote voter's AP actor URI
665 choice TEXT NOT NULL, -- the chosen option's name (matches poll_json options[].name)
666 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
667 UNIQUE(post_id, actor_uri, choice)
668 );
669 CREATE INDEX IF NOT EXISTS idx_poll_votes_post ON poll_votes(post_id);
[fe97cc3]670 CREATE TABLE IF NOT EXISTS ap_mentions (
671 id INTEGER PRIMARY KEY AUTOINCREMENT,
672 slug TEXT NOT NULL, -- our mentioned site/actor
673 object_uri TEXT NOT NULL, -- the remote note that mentions us
674 note_url TEXT, -- its human URL (open/interact)
675 actor_uri TEXT, actor_name TEXT, actor_handle TEXT, actor_icon TEXT, actor_url TEXT,
676 content TEXT, -- sanitized HTML snippet of the mentioning note
677 published TEXT,
678 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
679 UNIQUE(slug, object_uri)
680 );
681 CREATE INDEX IF NOT EXISTS idx_ap_mentions_slug ON ap_mentions(slug, created_at);
[737ea05]682 CREATE TABLE IF NOT EXISTS ap_reports (
683 id INTEGER PRIMARY KEY AUTOINCREMENT,
684 slug TEXT NOT NULL, -- our site the report is about (its owner moderates)
685 actor_uri TEXT, -- the reporter's actor URI
686 actor_name TEXT, actor_handle TEXT, actor_icon TEXT,
687 content TEXT, -- the reason (plain text)
688 objects TEXT, -- JSON array of reported object URIs (our actor + statuses)
689 seen INTEGER DEFAULT 0,
690 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
691 );
692 CREATE INDEX IF NOT EXISTS idx_ap_reports_slug ON ap_reports(slug, created_at);
[914eb9f]693 `);
[5045c30]694 // "Feature" a followed account: its posts show in the local Cirkel.
[f278df9]695 ensureColumn('ap_following', 'auto_boost', 'INTEGER DEFAULT 0');
[5045c30]696 // A timeline post you boosted (🔁) — also shown in the Cirkel (mixed by date).
697 ensureColumn('ap_timeline', 'boosted', 'INTEGER DEFAULT 0');
[9d34855]698 ensureColumn('ap_timeline', 'liked', 'INTEGER DEFAULT 0'); // a feed post you liked (⭐) → toggle
[b7d4458]699 ensureColumn('ap_timeline', 'nsfw', 'INTEGER DEFAULT 0'); // remote sensitive post → blur in the Cirkel
700 ensureColumn('ap_timeline', 'cw', 'TEXT'); // remote content-warning text
[97bacf2]701 ensureColumn('ap_timeline', 'emoji_json', 'TEXT'); // FEP-9098 custom emoji Emoji tags from the inbound note, served back as `tag`
[eb36688]702 ensureColumn('ap_timeline', 'link_json', 'TEXT'); // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
[6fd0e20]703 ensureColumn('ap_timeline', 'quote_json', 'TEXT'); // FEP-044f resolved quoted-post snapshot (author + content), for the embedded quote card
[b258a79]704 // FEP-044f: the fediverse object THIS post quotes, resolved once at publish
705 // time so buildNote (sync, also used by the outbox) needs no network.
706 ensureColumn('posts', 'quote_uri', 'TEXT'); // the quoted object's id
707 ensureColumn('posts', 'quote_actor', 'TEXT'); // its author, so we can address them
708 ensureColumn('ap_timeline', 'embed_json', 'TEXT'); // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
[a677616]709 ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the author's display name (shaer:author.emojis)
[f3caf19]710 ensureColumn('ap_timeline', 'reblog_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the booster's display name (shaer:booster.emojis)
[c6cdce6]711 ensureColumn('ap_timeline', 'reblog_name', 'TEXT'); // a followed account boosted this → "X boosted"
712 ensureColumn('ap_timeline', 'reblog_handle', 'TEXT'); // the booster's @handle
713 ensureColumn('ap_timeline', 'reblog_icon', 'TEXT'); // the booster's avatar
[6053c6c]714 ensureColumn('ap_timeline', 'poll_json', 'TEXT'); // a Question (poll): {multiple,options[{name,count}],endTime,closed,voters,voted}
[8878814]715
716 // Delivery health per follower → surface dead accounts for manual cleanup.
717 ensureColumn('ap_followers', 'last_delivery_at', 'DATETIME'); // last SUCCESSFUL delivery to this follower's inbox
718 ensureColumn('ap_followers', 'last_error_at', 'DATETIME'); // last time a delivery to it gave up (max retries)
[2d6a9c3]719
720 // ActivityPub `source` model: content_rendered = baked display HTML (#hashtags / URLs /
721 // @mentions linkified once at save). `content` stays the raw source used for editing and
722 // re-rendering. NULL on old posts → the render route bakes on the fly as a fallback.
723 ensureColumn('posts', 'content_rendered', 'TEXT');
[3778ddb]724
725 // AP addressing of an incoming interaction: 'public' | 'unlisted' | 'followers' | 'direct',
726 // derived from the note's to/cc at ingest. The public post page only renders public/unlisted
727 // replies; followers/direct replies surface in notifications (and later Messages) with post
728 // context instead. Existing rows default to 'public' (historically almost all were).
729 ensureColumn('ap_interactions', 'visibility', "TEXT DEFAULT 'public'");
[ef1853c]730 ensureColumn('ap_interactions', 'emoji_json', 'TEXT'); // FEP-9098 custom emojis in a reply's content (messages + thread)
731 ensureColumn('ap_interactions', 'actor_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the reply author's display name
[33e1dbd]732 // Rich replies: the reply's language (BCP47 code) → contentMap on the outgoing Note.
733 ensureColumn('ap_outbox', 'language', 'TEXT');
[feced2c]734 // Rich replies: JSON array [{url, mediaType, name}] → `attachment` on the Note.
735 ensureColumn('ap_outbox', 'attachments', 'TEXT');
[81b2e1e]736 ensureColumn('posts', 'ap_visibility', 'TEXT'); // public|quiet|friends|direct (C2S addressing, shaer-60b)
[928d1c7]737 ensureColumn('posts', 'paid', 'INTEGER DEFAULT 0'); // paid post (klonkt-demo-aki)
738 ensureColumn('posts', 'paid_min_cents', 'INTEGER'); // required support; null = owner default
[c3d12a6]739 ensureColumn('paid_patreon', 'patreon_url', 'TEXT'); // owner's public Patreon page → "Word supporter" link (klonkt-demo-aki)
[024f4f8]740 ensureColumn('ap_outbox', 'visibility', 'TEXT'); // 'direct' = private mention, never Public (shaer-tqc)
741 ensureColumn('ap_outbox', 'to_actors', 'TEXT'); // JSON array of recipient actor URIs for direct notes
[155c24e]742 ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
[a29c8c8]743 // Wie er op een hulpvraag af is, en wanneer hij is afgesloten (shaer-lgo).
744 // Los van ap_mentions, want dit is GEDEELDE staat: elke guardian van dit kind
745 // heeft er een kopie van, en die komt binnen als bericht van een ander. Een
746 // kolom op de mention zou alleen over onszelf gaan.
747 //
748 // OPGEPIKT mag stapelen: twee mensen die tegelijk reageren op een kind dat om
749 // hulp vraagt is geen probleem. Twee mensen die allebei niets doen omdat de
750 // ander het "geclaimd" had, wel.
751 //
752 // AFGEHANDELD kent geen terugdraai. Sluiten gebeurt met een stevige
753 // bevestiging, en leeft de vraag daarna nog, dan wordt hij opnieuw gesteld --
754 // een nieuwe hulpvraag. Zo blijft het verslag eerlijk: er wordt niets
755 // herschreven, er wordt toegevoegd.
756 db.exec(`CREATE TABLE IF NOT EXISTS ap_help_state (
757 note_uri TEXT NOT NULL,
758 guardian_uri TEXT NOT NULL,
759 kind TEXT NOT NULL, -- pickup | handled
760 guardian_handle TEXT,
761 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
762 PRIMARY KEY (note_uri, guardian_uri, kind)
763 )`);
764 db.exec('CREATE INDEX IF NOT EXISTS idx_ap_help_state_note ON ap_help_state(note_uri)');
[6b5d7da]765 ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
[e62f65d]766 ensureColumn('ap_outbox', 'wave', 'INTEGER'); // FEP-633c shaer:wave (guardian -> ward nudge)
[6eab7e9]767 ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms)
[d56d471]768 ensureColumn('ap_gated_offers', 'proposer', 'TEXT'); // who proposed (5.6): the settle-answer goes back to them
[fa33214]769 // Did a guardian actually say yes to this follower? That is what makes the
770 // mutual shortcut sound: a ward may follow back anyone its guardians already
771 // admitted, without asking the same question twice. Only follows that came
772 // through the §5.3 gate carry the mark; a free actor's followers never faced
773 // one. Everyone already following when this column arrives is grandfathered
774 // in (Barts besluit, 3-8): the rule is exact from that moment forward rather
775 // than retroactively suspicious of relationships that already exist.
776 {
777 const had = db.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('ap_followers') WHERE name = 'gate_approved'").get();
778 ensureColumn('ap_followers', 'gate_approved', 'INTEGER DEFAULT 0');
779 if (!had || !had.n) {
780 try { db.prepare('UPDATE ap_followers SET gate_approved = 1').run(); } catch { /* table still empty on a fresh init */ }
781 }
782 }
[6089c53]783 ensureColumn('posts', 'c2s_attachments', 'TEXT'); // media a C2S Note carried (JSON [{url,mediaType,name}]); buildNote federates them
[2a10445]784 // 30-7: C2S posts briefly got their content media copied onto the cover,
785 // which showed the same video twice on the post page. Clear the covers that
786 // duplicate their own content; idempotent, only ever touches those.
787 try {
788 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();
789 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();
790 } catch { /* posts table absent on fresh init */ }
[e62f65d]791 ensureColumn('ap_mentions', 'wave', 'INTEGER'); // inbound guardian wave
[af5b79b]792 // FEP-633c §2.2: object hint that the author is a ward. Register-only for now;
793 // used later at reddings-boei / escalation routing.
794 ensureColumn('ap_timeline', 'has_guardians', 'INTEGER');
795 ensureColumn('ap_mentions', 'has_guardians', 'INTEGER');
[d9ad6c5]796 // Berichten and de Krant render a post the same way, so a mention or a reply
797 // needs the same trimmings a timeline row already has: custom emojis, the
798 // media the note carried, and the quote / link-preview card.
799 ensureColumn('ap_mentions', 'emoji_json', 'TEXT'); // FEP-9098, in the content
800 ensureColumn('ap_mentions', 'actor_emoji_json', 'TEXT'); // FEP-9098, in the display name
801 ensureColumn('ap_mentions', 'media_json', 'TEXT');
802 ensureColumn('ap_mentions', 'quote_json', 'TEXT'); // FEP-044f quoted post
803 ensureColumn('ap_mentions', 'embed_json', 'TEXT'); // external link preview
804 ensureColumn('ap_interactions', 'media_json', 'TEXT');
805 ensureColumn('ap_interactions', 'quote_json', 'TEXT');
806 ensureColumn('ap_interactions', 'embed_json', 'TEXT');
[7922694]807 ensureColumn('ap_followers', 'name', 'TEXT'); // cached display name (shaer-aa3)
808 ensureColumn('ap_followers', 'handle', 'TEXT'); // @user@host
809 ensureColumn('ap_followers', 'icon', 'TEXT'); // avatar URL
[d14bf1c]810 feedStateTriggers();
811}
812
813/**
814 * Wat er met een tijdlijn gebeurd is, op één plek (shaer-n05).
815 *
816 * De inbox-lezing voegt vier bronnen samen. De vraag "is er iets veranderd" werd
817 * eerst beantwoord met MAX(rowid) over die vier -- een TOEVALLIGE eigenschap van
818 * de tabellen, geen feit dat ergens is opgeschreven. Dat gaf precies de gebreken
819 * die je van zo'n afleiding verwacht: bewerkingen en verwijderingen bewogen hem
820 * niet, en hij kon achteruit lopen. Dezelfde fout als reacties uitlezen uit
821 * ap_timeline.liked (shaer-9e9).
822 *
823 * Nu één rij per bericht per tijdlijn, met een oplopende `rev` en `kind`. Dat
824 * beantwoordt drie vragen die anders drie eigen oplossingen zouden krijgen:
825 * is er iets veranderd sinds N, wát is er veranderd, en is dit bericht bewerkt.
826 *
827 * Bijgehouden door TRIGGERS en niet door de aanroepende code, om dezelfde reden
828 * dat er geen gebeurtenis-emitter is: een trigger zit in de database, dus geen
829 * enkel codepad kan hem vergeten. De prijs is onzichtbare logica -- wie alleen de
830 * JavaScript leest ziet niet waarom deze tabel vult. Vandaar dat ze hier staan,
831 * bij de tabel, en niet verspreid.
832 *
833 * Let op de `UPDATE OF`-kolomlijsten: die zijn niet decoratief. Een like schrijft
834 * ap_timeline.liked en een 🔁 schrijft .boosted; zonder die afbakening zou je
835 * eigen like het bericht als BEWERKT merken en elke wachtende client wekken.
836 */
837function feedStateTriggers() {
838 try {
839 db.exec(`
840 CREATE TABLE IF NOT EXISTS ap_feed_state (
841 slug TEXT NOT NULL,
842 object_uri TEXT NOT NULL,
843 rev INTEGER NOT NULL,
844 kind TEXT NOT NULL, -- new | updated | deleted
845 at DATETIME DEFAULT CURRENT_TIMESTAMP,
846 PRIMARY KEY (slug, object_uri)
847 );
848 CREATE INDEX IF NOT EXISTS idx_ap_feed_state_rev ON ap_feed_state(slug, rev);
849 -- Eén doorlopende teller voor de hele instance. Bewust niet MAX(rev) uit de
850 -- tabel zelf: verdwijnt de hoogste rij, dan zou die teruglopen en denkt een
851 -- client dat er niets gebeurd is.
852 CREATE TABLE IF NOT EXISTS ap_feed_rev (n INTEGER NOT NULL);
853 `);
854 if (!db.prepare('SELECT COUNT(*) AS n FROM ap_feed_rev').get().n) {
855 db.prepare('INSERT INTO ap_feed_rev (n) VALUES (0)').run();
856 }
857 // slug + object_uri verschillen per bron; de rest is voor alle vier gelijk.
[ba76bf5]858 const zet = (naam, gebeurtenis, tabel, slug, uri, kind, extra = '', wanneer = '') => `
[d14bf1c]859 DROP TRIGGER IF EXISTS ${naam};
[ba76bf5]860 CREATE TRIGGER ${naam} AFTER ${gebeurtenis} ON ${tabel}${wanneer ? ` WHEN ${wanneer}` : ''} BEGIN
[d14bf1c]861 UPDATE ap_feed_rev SET n = n + 1;
862 INSERT INTO ap_feed_state (slug, object_uri, rev, kind)
863 ${extra || `VALUES (${slug}, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}')`}
864 ON CONFLICT(slug, object_uri) DO UPDATE
865 SET rev = excluded.rev, kind = excluded.kind, at = CURRENT_TIMESTAMP;
866 END;`;
867 const joinPosts = (uri, kind) => `
868 SELECT s.slug, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}'
869 FROM posts p JOIN sites s ON s.id = p.site_id`;
870 db.exec([
871 zet('trg_feed_tl_ins', 'INSERT', 'ap_timeline', 'NEW.slug', 'NEW.id', 'new'),
872 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'),
873 zet('trg_feed_tl_del', 'DELETE', 'ap_timeline', 'OLD.slug', 'OLD.id', 'deleted'),
874 zet('trg_feed_mn_ins', 'INSERT', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'new'),
875 zet('trg_feed_mn_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'updated'),
876 zet('trg_feed_mn_del', 'DELETE', 'ap_mentions', 'OLD.slug', 'OLD.object_uri', 'deleted'),
877 zet('trg_feed_ob_ins', 'INSERT', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'new'),
878 zet('trg_feed_ob_upd', 'UPDATE OF content, attachments', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'updated'),
879 zet('trg_feed_ob_del', 'DELETE', 'ap_outbox', 'OLD.site_slug', 'OLD.id', 'deleted'),
880 // ap_interactions draagt geen slug: die hangt aan de POST. Vandaar de join,
881 // en vandaar dat deze drie niet in de gewone vorm passen.
[ba76bf5]882 //
883 // De WHEN op kind='reply' is nodig omdat deze tabel ook likes en announces
884 // draagt, en die schrijven object_uri = '' (zie recordInteraction). Zonder de
885 // WHEN bumpte elke inkomende like de rev, werd elke wachter gewekt en kreeg
886 // die de hele collectie opnieuw terwijl er niets aan veranderd was: precies de
887 // kosten die de 304 moest wegnemen. Bovendien belandde er dan een rij op de
888 // lege string in ap_feed_state, die feedChangesSince vervolgens uitdeelt.
889 // De oude cursor filterde hier wel op kind; bij ap_timeline is dit ook gedaan
890 // (de UPDATE OF sluit liked/boosted uit) en één tabel verder vergeten.
891 zet('trg_feed_ia_ins', 'INSERT', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'new')} WHERE p.id = NEW.post_id`, "NEW.kind = 'reply'"),
892 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'"),
893 zet('trg_feed_ia_del', 'DELETE', 'ap_interactions', '', '', '', `${joinPosts('OLD.object_uri', 'deleted')} WHERE p.id = OLD.post_id`, "OLD.kind = 'reply'"),
[d14bf1c]894 ].join('\n'));
895 } catch (e) {
896 // Niet fataal: zonder deze tabel valt het wachten terug op "altijd de tijd
897 // volmaken", en dat is traag maar niet stuk.
898 console.error('❌ feed-state triggers:', e.message);
899 }
[7bc636b]900}
901
902function ensureColumn(table, column, definition) {
903 try {
904 db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
905 console.log(`🔧 Added column ${table}.${column}`);
906 } catch (e) {
907 // "duplicate column name" → already there. Anything else, surface it.
908 if (!/duplicate column/i.test(e.message)) {
909 console.error(`❌ ensureColumn(${table}.${column}):`, e.message);
910 }
911 }
912}
913
914export default db;
Note: See TracBrowser for help on using the repository browser.