source: Klonkt/src/config/database.js@ 3b43e4c

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

De gate-familie functioneel: acht poorten die echt schakelen (shaer-ahy.1)

Barts opdracht (8-8): "maak ze allemaal maar functioneel." Alle
setting-gates uit de catalogus hebben nu een kolom, zijn voorstelbaar en
beslisbaar via de bestaande tally, en worden ECHT afgedwongen -- bij de
aflevering (wat dicht is wordt nooit geserialiseerd, de regel die de
embeds al hadden) of bij de inname (wat de ward niet mag versturen
weigert de outbox met een eerlijke 403).

externalThreads de thread-kring (shaer-9y2): dicht is de kring van de

guardians met telling, open is alles; per verzoek en
buiten de threadcache om, want een poort die net
dichtging mag niet twee minuten open nawerken

images/music bijlagen gefilterd op mediaType, ook in de thread
quoteCards shaer:quote niet geserialiseerd
customEmoji Emoji-tags en byline-emoji niet geserialiseerd; de

:shortcode: blijft als tekst staan, dat is eerlijk

messages de berichten-poot dicht voor vreemden en vrienden,

maar NOOIT voor het guardian-kanaal, en de outbox
weigert directe berichten -- behalve de reddingsboei:
een poort die het hulpkanaal afsnijdt beschermt
niemand

compose de outbox weigert eigen posts; een antwoord valt

onder het gesprek, niet onder een eigen podium

accountMove de harde weigering van shaer-tge is een gate

geworden met dezelfde standaard: guardians kunnen
hem nu openzetten

De capabilities dragen de hele familie, zodat de app VOORAF weet wat hij
mag aanbieden -- de (+) kaart leest shaer:compose al. De voorstelroute
herschreef een onbekende feature stilletjes naar externalEmbeds; dat is
nu een 400, want een voorstel dat op de verkeerde poort landt mag een
guardian nooit overkomen. Het paneel leest de standen catalogusbreed.

TWEE blijven bewust gepland. publicProfile is niet een veld dat je
wegfiltert maar het hele publieke web-oppervlak (de ontwerpvraag van
shaer-hj0); een half slot leest als een heel slot en dat is gevaarlijker
dan geen. independence draagt gezag over een kind over en zijn vorm
hoort bij shaer-90v beslist te worden, niet hier geimproviseerd.

Zestien nieuwe/aangepaste toetsen, alle 640 groen.

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

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