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

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

De kaart op je eigen post, en de composer-preview (shaer-k3f)

Het bead-uitgangspunt "serverkant is klaar" bleek half waar: AP-quotes
kregen bij publiceren een quote_uri, maar eigen posts hadden geen kolom
voor een kaart-snapshot en de outbox serveerde geen shaer:quote of
shaer:embed -- de app kon een eigen post dus nooit als kaart tonen, ook
na publicatie niet.

Drie stukken, allemaal langs de pijplijn die er al was:

OPSLAAN deliverCreate resolvet de eerste externe link net als de

inbox dat voor binnenkomende posts doet: een fediverse-
object wordt een quote-snapshot (resolveQuoteByUri, de kern
van resolveQuote maar vanaf een kale URI), anders probeert
de link een externe kaart (resolveExternalEmbed). In
posts.quote_json/embed_json, VOOR de vroege return: ook een
post zonder volgers hoort zijn kaart te krijgen, de app
leest hem uit de outbox en niet uit een bezorging.

SERVEREN de friend-poot van de outbox draagt shaer:quote en -- alleen

voor de bearer en langs zijn eigen embeds-poort --
shaer:embed. Een remote vriend krijgt de embed niet: diens
server resolvet en gate zelf bij ontvangst.

PREVIEW GET /ap/users/:slug/card?url= (bearer-only): een URL langs

exact dezelfde pijplijn, zodat de preview in de composer
nooit iets belooft dat de post niet waarmaakt. Zelfde
gate-regels als de tijdlijn.

Drie tests met gestubde fetch: extern -> embed-snapshot, fediverse ->
quote-snapshot (en niet allebei), en de preview die voor dezelfde links
dezelfde kaarten geeft. Alle 723 groen.

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

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