source: Klonkt/src/config/database.js@ 5d0d3f35

main
Last change on this file since 5d0d3f35 was b1d5f3f, checked in by Bart <bart@…>, 28 hours ago

Identiteitsbewijzen (FEP-c390): een sleutel van elders zegt dat dit account van hem is

Een Ed25519-sleutel die niet op deze server staat -- in Shaer op papier, alleen
ingetypt als hij nodig is -- ondertekent een VerifiableIdentityStatement:
"did:key:... is ook deze actor". Klonkt ondertekent niets. Hij controleert de
verklaring en zet hem onder attachment op de actor, waar iedereen hem zelf
kan nalopen.

Wat dat bewijst: dat dezelfde entiteit achter sleutel en account zit. Verhuist
het account, dan draagt het nieuwe dezelfde did:key, en dat bewijst de
continuiteit ook als de oude server weg of vijandig is -- een Move kan dat niet,
want die moet door de OUDE server getekend worden.

eddsa-jcs-2022, omdat Mitra en streams dat controleren. IdentityProof.js is
puur: JCS (RFC 8785, in JavaScript precies een gesorteerde JSON.stringify),
base58btc, did:key en de proof zelf. De controle volgt c390 regel voor regel,
met een eigen reden per weigering; verificationMethod moet PRECIES de subject
zijn, zonder het #-fragment dat de W3C-voorbeelden gebruiken.

Getoetst tegen de W3C-testvectoren (vc-di-eddsa B.3): dezelfde did:key, dezelfde
canonieke vorm, dezelfde hashes en byte voor byte dezelfde handtekening. Een
implementatie die het alleen met zichzelf eens is, bewijst niets.

POST /ap/users/:slug/identityProofs (alleen de eigenaar) controleert en
publiceert, één per did:key, hoogstens drie; DELETE ?subject= haalt hem weg.
Beide sturen een Update van de actor naar de volgers. De actor noemt het
eindpunt als endpoints.shaer:identityProofs. Elke verklaring draagt haar eigen
@context, zodat de actor er geen bij hoeft -- did/v1 en data-integrity/v1 hebben
beschermde termen die op actorniveau met onze inline context konden botsen.

shaer-jpxj
Co-Authored-By: Claude Opus 5.5 <claude@…>

  • Property mode set to 100644
File size: 66.3 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
24/**
25 * Tijdstempels in EEN spelling (shaer-a937).
26 *
27 * Deze database draagt twee vormen door elkaar: '2026-08-06 09:02:01' van
28 * CURRENT_TIMESTAMP en '2026-08-06T02:08:01.000Z' van toISOString(). SQLite
29 * vergelijkt ze als TEKST, en op positie 10 staat een 'T' (0x54) tegenover een
30 * spatie (0x20) -- dus binnen dezelfde dag wint de ISO-vorm altijd, hoe laat hij
31 * ook is. Een antwoord van 02:08 kwam zo boven een like van 09:02 te staan.
32 *
33 * `NU_ISO` is wat je SCHRIJFT, `isoSql()` is waarmee je VERGELIJKT of SORTEERT.
34 * De twee horen bij elkaar: het eerste zorgt dat er niets nieuws bijkomt, het
35 * tweede dat wat er al staat toch goed op volgorde komt.
36 *
37 * isoSql valt met COALESCE terug op de RAUWE waarde: strftime geeft NULL op iets
38 * dat het niet als tijd herkent, en zonder die terugval zou zo'n rij uit de
39 * sortering vallen -- of erger, als hij ook geSELECTeerd wordt (de cursor in de
40 * gesprekslezing) zou de client een lege stempel terugkrijgen en zijn plek
41 * kwijtraken. Waar zo'n onleesbare waarde dan LANDT is onbepaald: hij wordt als
42 * tekst vergeleken en 'geen datum' staat nu eenmaal boven '2026-...'. Dat is de
43 * juiste ruil -- data die je niet begrijpt bewaar je, je gooit hem niet weg.
44 *
45 * Ze staan HIER en niet in een dienst omdat ze over de opslag gaan: elke plek
46 * die sorteert importeert `db` toch al uit dit bestand.
47 */
48export const NU_ISO = "strftime('%Y-%m-%dT%H:%M:%SZ','now')";
49export const isoSql = (expr) => `COALESCE(strftime('%Y-%m-%dT%H:%M:%SZ', ${expr}), ${expr})`;
50
51export function initializeDatabase() {
52 const tableExists = db.prepare(`
53 SELECT name FROM sqlite_master WHERE type='table' AND name='users'
54 `).get();
55
56 if (!tableExists) {
57 console.log('🔧 Initializing database schema...');
58 const schemaPath = path.join(__dirname, '..', 'db', 'migrations', '001-init.sql');
59 const schema = fs.readFileSync(schemaPath, 'utf-8');
60 db.exec(schema);
61 console.log('✅ Database initialized with v9-soul schema');
62 }
63
64 // Additive column migrations — safe to run every boot.
65 // SQLite throws if the column already exists; we swallow that.
66 ensureColumn('sites', 'enable_audio_player', 'INTEGER DEFAULT 1');
67 // Eigenaarspoort (Robins wens, 18-8-2026): volgers niet automatisch
68 // accepteren maar door de eigenaar laten beslissen, op z'n fediverse.
69 // STANDAARD AAN (ook Robins wens, zelfde dag): een nieuwe of bijgewerkte
70 // klonkt beschermt zijn eigenaar meteen; uitzetten is de bewuste keuze.
71 ensureColumn('sites', 'approve_followers', 'INTEGER DEFAULT 1');
72 // [Add to HUB]: tot wanneer de eigenaar een Follow van de hub vooraf goedkeurt
73 // (ms sinds epoch). Zie services/hub-invite.js.
74 ensureColumn('sites', 'hub_invite_until', 'INTEGER');
75 // (Verwijderd 31-7-2026: sites.guardian_only en ap_guardian_invites hoorden
76 // bij de guardian-lite accounts. Bestaande installaties houden kolom en tabel
77 // ongebruikt; nieuwe krijgen ze niet meer.)
78 // FEP-633c §5.3: follows targeting a ward are held pending until its
79 // guardians approve (Guardian 2). Gating applies only to ward-actors.
80 db.exec(`CREATE TABLE IF NOT EXISTS ap_pending_follows (
81 id TEXT PRIMARY KEY,
82 ward_slug TEXT NOT NULL,
83 follower_uri TEXT NOT NULL,
84 follower_inbox TEXT,
85 follower_shared_inbox TEXT,
86 follower_name TEXT,
87 follower_handle TEXT,
88 follower_icon TEXT,
89 activity_json TEXT,
90 quorum TEXT DEFAULT 'any',
91 status TEXT DEFAULT 'pending',
92 created_at TEXT DEFAULT CURRENT_TIMESTAMP
93 )`);
94 db.exec(`CREATE TABLE IF NOT EXISTS ap_pending_follow_approvals (
95 follow_id TEXT NOT NULL,
96 guardian_uri TEXT NOT NULL,
97 decision TEXT NOT NULL,
98 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
99 PRIMARY KEY (follow_id, guardian_uri)
100 )`);
101 // FEP-633c §5.3, the OTHER direction (shaer-p729): a ward's own follow is
102 // held until its guardians approve. Deliberately not ap_pending_follows —
103 // that table is keyed with the ward as the TARGET ("who wants to follow me"),
104 // and adding a direction column would make every existing query ambiguous.
105 db.exec(`CREATE TABLE IF NOT EXISTS ap_pending_outgoing_follows (
106 id TEXT PRIMARY KEY,
107 ward_slug TEXT NOT NULL,
108 target_uri TEXT NOT NULL,
109 target_inbox TEXT,
110 target_name TEXT,
111 target_handle TEXT,
112 target_icon TEXT,
113 quorum TEXT DEFAULT 'any',
114 status TEXT DEFAULT 'pending',
115 created_at TEXT DEFAULT CURRENT_TIMESTAMP
116 )`);
117 db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ap_outgoing_follows_target
118 ON ap_pending_outgoing_follows(ward_slug, target_uri)`);
119 // Wat er gebeurd is, en waarom (shaer-p729, §4.2). Guardianship-events waren
120 // vluchtig: onGuardianshipEvent wekte de long-poll en stuurde eventueel een
121 // push, en de rest van de gebeurtenis loste op. Een weigering droeg
122 // `reason: 'not_a_teapot'` tot in die functie en verder niet -- de ward en
123 // zijn guardians hoorden het alleen doordat het aanbod uit de wachtrij
124 // verdween. §4.2 eist dat ze het TE HOREN krijgen, met de reden erbij.
125 //
126 // Een logboek, geen wachtrij: hier staat niets dat om een antwoord vraagt.
127 // Daarom hoort het ook ingeklapt te staan -- naast wat nog wel wacht, maakt
128 // afgelopen nieuws de open vraag onleesbaar.
129 db.exec(`CREATE TABLE IF NOT EXISTS ap_guardian_events (
130 id INTEGER PRIMARY KEY AUTOINCREMENT,
131 slug TEXT NOT NULL,
132 kind TEXT NOT NULL,
133 payload TEXT,
134 created_at TEXT DEFAULT CURRENT_TIMESTAMP
135 )`);
136 db.exec('CREATE INDEX IF NOT EXISTS idx_ap_guardian_events_slug ON ap_guardian_events(slug, id DESC)');
137 db.exec(`CREATE TABLE IF NOT EXISTS ap_outgoing_follow_approvals (
138 follow_id TEXT NOT NULL,
139 guardian_uri TEXT NOT NULL,
140 decision TEXT NOT NULL,
141 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
142 PRIMARY KEY (follow_id, guardian_uri)
143 )`);
144 // Cross-instance follow-approval (modelled on the guardian offer): the
145 // guardian-side COPY of a gated follow on a REMOTE ward, forwarded here by
146 // the ward's server as an Offer(Follow). The decision is sent back to the
147 // ward's inbox. (Local wards use ap_pending_follows directly.)
148 db.exec(`CREATE TABLE IF NOT EXISTS ap_follow_reviews (
149 id TEXT NOT NULL,
150 guardian_slug TEXT NOT NULL,
151 ward_uri TEXT NOT NULL,
152 ward_inbox TEXT,
153 follower_uri TEXT NOT NULL,
154 follower_handle TEXT,
155 follower_icon TEXT,
156 follow_json TEXT,
157 status TEXT DEFAULT 'pending',
158 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
159 PRIMARY KEY (guardian_slug, id)
160 )`);
161 // Guardianship Fase 2 (shaer-jdb): een doorgestuurde follow-goedkeuring draagt
162 // een RICHTING. Bij een inkomende is de follower iemand anders en de ward het
163 // doel; bij een uitgaande is de ward zelf de follower en staat het doel in het
164 // Follow-object. Zonder deze twee kolommen werd een uitgaande opgeslagen als
165 // "deze ward wil deze ward volgen" en viel het doel weg -- dan valt er niets
166 // zinnigs te tonen, hoe je de wachtrij ook vult.
167 ensureColumn('ap_follow_reviews', 'direction', "TEXT DEFAULT 'incoming'");
168 ensureColumn('ap_follow_reviews', 'target_uri', 'TEXT');
169 ensureColumn('ap_follow_reviews', 'target_handle', 'TEXT');
170 ensureColumn('sites', 'profile_photo', 'TEXT');
171 // De MusicBrainz-koppeling van de artiest (shaer-mbz). Een MBID is een
172 // verwijzing naar hun register, geen kopie ervan -- de naam staat erbij zodat
173 // het beheerscherm kan tonen WAT er gekoppeld is zonder ervoor te moeten
174 // netwerken, en zodat een verkeerde koppeling opvalt.
175 ensureColumn('sites', 'mb_artist_id', 'TEXT');
176 ensureColumn('sites', 'mb_artist_name', 'TEXT');
177 // Een UITGAVE heeft twee dingen die een afspeellijst niet heeft (shaer-756s).
178 //
179 // release_date en niet `year`: die kolom bestaat al en blijft, maar hun
180 // AlbumSerializer leest `released` als een DateField. Een jaartal als
181 // 2024-01-01 versturen is een dag verzinnen, en dat is precies wat we bij
182 // artiesten en albums niet doen. Volledige datum of niets.
183 //
184 // mb_release_id is de tegenhanger van sites.mb_artist_id: dezelfde soort
185 // verwijzing naar MusicBrainz, een niveau lager.
186 //
187 // Ze horen ALLEEN bij kind='album'. PlaylistService dwingt dat af bij het
188 // opslaan -- zie daar waarom dat niet alleen in het scherm mag zitten.
189 ensureColumn('playlists', 'release_date', 'TEXT');
190 ensureColumn('playlists', 'mb_release_id', 'TEXT');
191 ensureColumn('audio_tracks', 'cover_url', 'TEXT');
192 ensureColumn('audio_tracks', 'album', 'TEXT');
193 ensureColumn('users', 'reset_token', 'TEXT');
194 ensureColumn('users', 'reset_token_expires', 'DATETIME');
195 // Google OAuth: link a Google account to a user (login via Google).
196 ensureColumn('users', 'google_sub', 'TEXT');
197 // Read-only/viewer account: can view everything but make no changes.
198 ensureColumn('users', 'readonly', 'INTEGER DEFAULT 0');
199 // Personal interface language (nl|en|de). Null = follow the default (site/env/browser).
200 ensureColumn('users', 'lang', 'TEXT');
201 // Site-level moderation toggle. 'trust' = auto-approve, 'moderate' = pending until reviewed.
202 // Circles: whether this site may appear in other sites' circles (surfacing opt-out).
203 ensureColumn('sites', 'allow_circle', 'INTEGER DEFAULT 1');
204
205 // One EXPLICIT primary/main site (= the company/label site in hub mode,
206 // the only site in solo) instead of the fragile "oldest = main" convention
207 // that was duplicated in 4 places. Backfill: mark the oldest if no primary
208 // site exists yet, so existing behaviour is preserved exactly.
209 ensureColumn('sites', 'is_primary', 'INTEGER DEFAULT 0');
210 try {
211 const hasPrimary = db.prepare('SELECT 1 FROM sites WHERE is_primary = 1 LIMIT 1').get();
212 if (!hasPrimary) {
213 const oldest = db.prepare('SELECT id FROM sites ORDER BY created_at ASC LIMIT 1').get();
214 if (oldest) db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(oldest.id);
215 }
216 } catch (e) { /* sites table still empty/absent on fresh init — ensurePrimarySite handles it */ }
217
218 // v9 audit additions —————————————————————————————————————————
219 // SEO/social columns the v9 template uses (most live in 001-init.sql already
220 // for fresh DBs but ensureColumn is idempotent for existing DBs).
221 ensureColumn('sites', 'twitter', 'TEXT'); // @handle (with @)
222 ensureColumn('sites', 'schema_type', "TEXT DEFAULT 'Person'"); // Person|Organization
223 ensureColumn('sites', 'publisher_name', 'TEXT');
224 ensureColumn('sites', 'publisher_url', 'TEXT');
225 ensureColumn('sites', 'publisher_logo', 'TEXT');
226 ensureColumn('sites', 'profile_enabled', 'INTEGER DEFAULT 1');
227 ensureColumn('sites', 'profile_name', 'TEXT'); // display name (falls back to title)
228 ensureColumn('sites', 'profile_bio', 'TEXT'); // short bio for header
229 ensureColumn('sites', 'profile_links', 'TEXT'); // JSON array [{platform, url}]
230 ensureColumn('sites', 'feed_view_default', "TEXT DEFAULT 'grid'"); // timeline | grid
231 ensureColumn('sites', 'feed_view_switch', 'INTEGER DEFAULT 1'); // show switcher
232 // Welke TWEEDE weergave deze site aanbiedt naast Grid.
233 // 'reader' Lezen, hele berichten, een per scherm
234 // 'timeline' de chronologische lijst
235 // 'auto' Lezen op mobiel, Tijdlijn op desktop (grens 768px)
236 // Lezen verving Tijdlijn, maar de tijdlijn is nooit weggehaald -- home.ejs
237 // rendert alle drie de secties en de CSS kiest. Dit maakt er weer een keuze
238 // van in plaats van een besluit voor iedereen. 'auto' kost geen JavaScript:
239 // het is een mediaquery, dus geen resize-afhandeling en geen flikkering.
240 ensureColumn('sites', 'feed_alt_view', "TEXT DEFAULT 'reader'"); // reader | timeline | auto
241 // Krijgt elk bericht in Lezen zijn EIGEN scherm? Aan betekent min-height:100svh
242 // per bericht: een kort bericht vult dan het scherm en je ziet het volgende
243 // pas na een snap. Uit is de stroom zoals hij nu is, bericht na bericht zonder
244 // lege ruimte. Stond eerst vast op AAN, is toen vast op UIT gezet omdat een
245 // kort bericht een halve pagina leegte gaf; nu is het een keuze.
246 ensureColumn('sites', 'reader_full_page', 'INTEGER DEFAULT 0');
247 // Identiteitsbewijzen (FEP-c390, shaer-jpxj): de ondertekende verklaringen,
248 // als JSON-lijst, precies zoals ze binnenkwamen -- een handtekening hangt aan
249 // elke byte van de canonieke vorm, dus herschrijven zou hem breken.
250 ensureColumn('sites', 'identity_proofs', 'TEXT');
251 ensureColumn('sites', 'show_search', 'INTEGER DEFAULT 1');
252 ensureColumn('sites', 'show_archive_link', 'INTEGER DEFAULT 1');
253 // Gated feature (FEP-633c): may external (non-fediverse) embeds be shown to
254 // this account? NULL = auto, which means OFF for a ward and ON for anyone
255 // else. The guardians flip it; the gate itself lives server-side, so a ward
256 // never even receives the thumbnail it is not allowed to see.
257 ensureColumn('sites', 'external_embeds', 'INTEGER');
258 // De rest van de gate-familie (shaer-ahy.1, "maak ze allemaal functioneel",
259 // Barts opdracht 8-8). Zelfde drietal als external_embeds: NULL is de
260 // automatiek (dicht voor een ward, open voor de rest), 0/1 is een besluit
261 // van de guardians en wint van de automatiek.
262 ensureColumn('sites', 'external_threads', 'INTEGER'); // replies van vreemden onder een post (shaer-9y2)
263 ensureColumn('sites', 'gate_replies', 'INTEGER'); // zelf antwoorden in een gesprek (shaer-r4c)
264 ensureColumn('sites', 'gate_images', 'INTEGER'); // afbeeldingsbijlagen (shaer-6p5)
265 ensureColumn('sites', 'gate_messages', 'INTEGER'); // heel Messages (shaer-3ow)
266 ensureColumn('sites', 'gate_compose', 'INTEGER'); // zelf posten, de (+) kaart (shaer-qgev)
267 ensureColumn('sites', 'gate_music', 'INTEGER'); // audiobijlagen (shaer-rmz)
268 // Film had als enige soort GEEN poort (shaer-mxh2). Dat is geen gat dat je
269 // kunt laten staan: een kind met plaatjes en muziek dicht kon wel een half
270 // uur video plaatsen, en dat is de zwaarste soort van de drie.
271 ensureColumn('sites', 'gate_video', 'INTEGER'); // videobijlagen (shaer-mxh2)
272 ensureColumn('sites', 'gate_quote_cards', 'INTEGER'); // ingebedde quote-kaarten (shaer-mls)
273 ensureColumn('sites', 'gate_custom_emoji', 'INTEGER'); // FEP-9098 emoji-plaatjes (shaer-ytw)
274 ensureColumn('sites', 'gate_account_move', 'INTEGER'); // FEP-7628 Move (shaer-tge)
275 // Wie de ward ZELF mag volgen (shaer-p729): de tegenhanger van shaer:follows,
276 // dat over de andere richting gaat. §5.3 schrijft alleen het doorsturen voor
277 // van een Follow NAAR een ward; wat je verder gated is een keuze van de
278 // implementatie, en dit is die keuze. Verstelbaar, anders dan de inkomende
279 // kant: een kind dat ouder wordt hoort niet eeuwig te blijven vragen.
280 ensureColumn('sites', 'gate_following', 'INTEGER'); // zelf iemand volgen (shaer-p729)
281 // The heavier sibling (FEP-633c 5.6): may a player from outside this app run
282 // INSIDE it? A preview is a picture; playback hands the screen to a third
283 // party's engine, recommendations and all. Two settings, so the guardians can
284 // allow the one without the other. NULL = auto, which means off for a ward.
285 ensureColumn('sites', 'external_playback', 'INTEGER');
286 ensureColumn('sites', 'og_theme', 'TEXT'); // OG share-card variant: NULL=auto (follow site theme) | 'light' | 'dark'
287 // FEP-7628: former identities this actor claims (JSON array of actor URIs).
288 // Publishing them as alsoKnownAs is what lets the OLD server approve a Move
289 // of its followers to this account — the claim must be visible on OUR side.
290 ensureColumn('sites', 'ap_aliases', 'TEXT');
291 ensureColumn('sites', 'moved_to', 'TEXT'); // FEP-7628 slice 2: waarheen dit account vertrok
292 // FEP-1580: staat de ingest-routine nog open? 1 = klaar (en dat is ook de
293 // stand van een site die nooit iets gemigreerd heeft, want er hangt niets).
294 // Derden pollen op deze vlag, dus hij moet ook "er valt niets te wachten"
295 // kunnen zeggen.
296 ensureColumn('sites', 'migration_complete', 'INTEGER DEFAULT 1');
297
298 // Per-post noindex + type
299 ensureColumn('posts', 'noindex', 'INTEGER DEFAULT 0');
300 ensureColumn('posts', 'publish_at', 'DATETIME'); // release planning (premium #3): scheduled go-live
301 ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0'); // fan-only preview (premium #3)
302 ensureColumn('posts', 'nsfw', 'INTEGER DEFAULT 0'); // sensitive content → blur + click-to-reveal; fediverse sensitive
303 ensureColumn('posts', 'cover_video_url', 'TEXT'); // muted loop MP4 for an animated cover (Safari-smooth)
304 ensureColumn('posts', 'cover_alt', 'TEXT'); // alt text / description for the cover (a11y → AS2 attachment `name`)
305 ensureColumn('posts', 'language', 'TEXT'); // BCP-47 content language → federates as AS2 contentMap (Mastodon language filter/translate)
306 ensureColumn('posts', 'content_warning', 'TEXT'); // custom CW label (empty = default "Gevoelige inhoud")
307 ensureColumn('posts', 'type', "TEXT DEFAULT 'post'"); // post | foto | video | audio
308 ensureColumn('posts', 'poll_json', 'TEXT'); // a poll WE host → federates as AS2 Question: {multiple,options[{name}],endTime,closed}
309
310 // Statistics (premium module) — bare counters, cookie-free.
311 ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0'); // views per post
312 ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0'); // plays per track
313 ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download-for-email (premium #2)
314 ensureColumn('audio_tracks', 'credit', 'TEXT'); // owner/credit (copyright holder)
315 ensureColumn('audio_tracks', 'license', 'TEXT'); // license (e.g. "CC BY 4.0", "All rights reserved")
316 ensureColumn('audio_tracks', 'link_spotify', 'TEXT'); // "open in" links per track
317 ensureColumn('audio_tracks', 'link_youtube', 'TEXT');
318 ensureColumn('audio_tracks', 'link_soundcloud', 'TEXT');
319 // Per-track: federate the actual audio file as an AS2 Audio attachment so it plays inline
320 // in EVERY fediverse client (incl. the Mastodon apps). Default 0 = gated (web player only,
321 // file not exposed). Opt-in 1 = the file is served ungated + shared on the fediverse.
322 ensureColumn('audio_tracks', 'fedi_open', 'INTEGER DEFAULT 0');
323
324 // Playlists (v9 feature) — first-class entity. CREATE IF NOT EXISTS is
325 // idempotent so it's safe to run on every boot regardless of DB age.
326 db.exec(`
327 CREATE TABLE IF NOT EXISTS playlists (
328 id TEXT PRIMARY KEY,
329 site_id TEXT NOT NULL,
330 title TEXT NOT NULL,
331 artist TEXT,
332 year INTEGER,
333 cover_url TEXT,
334 kind TEXT DEFAULT 'album',
335 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
336 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
337 FOREIGN KEY (site_id) REFERENCES sites(id)
338 );
339 CREATE TABLE IF NOT EXISTS playlist_tracks (
340 playlist_id TEXT NOT NULL,
341 track_id TEXT NOT NULL,
342 position INTEGER NOT NULL DEFAULT 0,
343 PRIMARY KEY (playlist_id, track_id),
344 FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
345 FOREIGN KEY (track_id) REFERENCES audio_tracks(id) ON DELETE CASCADE
346 );
347 CREATE INDEX IF NOT EXISTS idx_playlist_tracks_pos
348 ON playlist_tracks(playlist_id, position);
349 `);
350
351 // Global app settings (key/value singleton). One instance is one owner, so
352 // there is no tenancy mode here anymore — see SettingsService.
353 // Oudere installaties dragen nog een dode rij key='tenancy' ('solo' of
354 // 'circle'). Niets leest hem; bewust laten staan (shaer-x7c0) in plaats van
355 // er opruimcode voor te schrijven die na één ronde zelf dood is.
356 db.exec(`
357 CREATE TABLE IF NOT EXISTS app_settings (
358 key TEXT PRIMARY KEY,
359 value TEXT,
360 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
361 );
362 `);
363
364 // ── Statistics (premium) — cookie-free ──────────────────────
365 // stat_daily: pageview count per day per site (bare counter).
366 // stat_visitor_day: one row per UNIQUE visitor hash per day per site
367 // (sha256 of IP+UA+day-salt; the salt rotates daily and is never stored
368 // → no persistent identifier, no cookie, no consent required).
369 db.exec(`
370 CREATE TABLE IF NOT EXISTS stat_daily (
371 site_id TEXT NOT NULL,
372 day TEXT NOT NULL,
373 pageviews INTEGER NOT NULL DEFAULT 0,
374 PRIMARY KEY (site_id, day)
375 );
376 CREATE TABLE IF NOT EXISTS stat_visitor_day (
377 site_id TEXT NOT NULL,
378 day TEXT NOT NULL,
379 visitor_hash TEXT NOT NULL,
380 PRIMARY KEY (site_id, day, visitor_hash)
381 );
382 CREATE INDEX IF NOT EXISTS idx_stat_visitor_day ON stat_visitor_day(site_id, day);
383 CREATE TABLE IF NOT EXISTS stat_referrer (
384 site_id TEXT NOT NULL,
385 host TEXT NOT NULL,
386 count INTEGER NOT NULL DEFAULT 0,
387 PRIMARY KEY (site_id, host)
388 );
389 `);
390
391 // Newsletter / mailing list (premium). Subscribers per site; double opt-in when SMTP
392 // is configured (status 'pending' until confirmed), otherwise single opt-in ('confirmed').
393 // 'unsub' = unsubscribed. token = confirm/unsubscribe key (used in email links).
394 db.exec(`
395 CREATE TABLE IF NOT EXISTS subscribers (
396 id TEXT PRIMARY KEY,
397 site_id TEXT NOT NULL,
398 email TEXT NOT NULL,
399 status TEXT NOT NULL DEFAULT 'pending',
400 source TEXT DEFAULT 'widget',
401 token TEXT NOT NULL,
402 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
403 confirmed_at DATETIME,
404 UNIQUE(site_id, email)
405 );
406 CREATE INDEX IF NOT EXISTS idx_subscribers_site_status ON subscribers(site_id, status);
407 `);
408
409 // Sent newsletters (history + counts).
410 db.exec(`
411 CREATE TABLE IF NOT EXISTS newsletters (
412 id TEXT PRIMARY KEY,
413 site_id TEXT NOT NULL,
414 subject TEXT NOT NULL,
415 body TEXT NOT NULL,
416 sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
417 recipient_count INTEGER DEFAULT 0
418 );
419 `);
420
421 // Show agenda (premium #8): tour dates / gigs per site.
422 db.exec(`
423 CREATE TABLE IF NOT EXISTS shows (
424 id TEXT PRIMARY KEY,
425 site_id TEXT NOT NULL,
426 date TEXT NOT NULL,
427 time TEXT,
428 city TEXT NOT NULL,
429 venue TEXT,
430 country TEXT,
431 ticket_url TEXT,
432 notes TEXT,
433 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
434 );
435 CREATE INDEX IF NOT EXISTS idx_shows_site_date ON shows(site_id, date);
436 `);
437
438 // Link-in-bio click statistics (premium #6). One counter per (site, url); the
439 // link-in-bio page links via /links/go/:i which counts the click and redirects.
440 db.exec(`
441 CREATE TABLE IF NOT EXISTS link_clicks (
442 site_id TEXT NOT NULL,
443 url TEXT NOT NULL,
444 clicks INTEGER DEFAULT 0,
445 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
446 PRIMARY KEY (site_id, url)
447 );
448 `);
449
450
451 // ── ActivityPub (fediverse bridge) ──────────────────────────
452 // RSA keypair per actor (Mastodon-compatible HTTP Signatures; separate from
453 // the Cirkels Ed25519 keys). ap_followers = remote AP actors following us.
454 db.exec(`
455 CREATE TABLE IF NOT EXISTS ap_keys (
456 slug TEXT PRIMARY KEY,
457 public_pem TEXT NOT NULL,
458 private_pem TEXT NOT NULL,
459 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
460 ); -- LUISTERAARS (shaer-0nh). Wie de BIBLIOTHEEK volgt, niet de actor.
461 --
462 -- Een eigen tabel en niet een vlag op ap_followers, en dat is met opzet:
463 -- deze accounts horen onze gewone posts NIET te krijgen. Zolang ze in een
464 -- andere tabel staan kan een bezorging ze niet per ongeluk meenemen -- een
465 -- vlag die iemand vergeet te filteren zou dat wel doen, en dan komt de
466 -- Krant van een site bij mensen die alleen muziek wilden.
467 CREATE TABLE IF NOT EXISTS ap_library_followers (
468 id INTEGER PRIMARY KEY AUTOINCREMENT,
469 slug TEXT NOT NULL,
470 actor_uri TEXT NOT NULL,
471 inbox TEXT,
472 shared_inbox TEXT,
473 name TEXT,
474 handle TEXT,
475 icon TEXT,
476 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
477 last_delivery_at DATETIME,
478 last_error_at DATETIME,
479 UNIQUE (slug, actor_uri)
480 );
481
482 -- OpenWebAuth (FEP-61cf): eenmalige tokens waarmee een BEZOEKER van elders
483 -- bewijst wie hij is. Klonkt is hier de 'target instance': we hebben nooit
484 -- iemands prive-sleutel nodig, alleen zijn publieke -- dus staat hier ook
485 -- geen geheim van een ander in.
486 --
487 -- Het token is kort houdbaar (minuten, zie OpenWebAuthService) en gaat na
488 -- inwisselen meteen weg: eenmalig is de hele bedoeling. De FEP noemt de
489 -- opruiming expliciet als DoS-verdediging -- zonder vervaltijd vult iemand
490 -- deze tabel met tokens die hij nooit inwisselt.
491 CREATE TABLE IF NOT EXISTS owa_tokens (
492 token TEXT PRIMARY KEY,
493 actor_uri TEXT NOT NULL,
494 created_at INTEGER NOT NULL -- ms sinds epoch
495 );
496 CREATE INDEX IF NOT EXISTS idx_owa_tokens_created ON owa_tokens(created_at);
497
498 CREATE TABLE IF NOT EXISTS ap_followers (
499 id INTEGER PRIMARY KEY AUTOINCREMENT,
500 slug TEXT NOT NULL,
501 actor_uri TEXT NOT NULL,
502 inbox TEXT,
503 shared_inbox TEXT,
504 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
505 UNIQUE(slug, actor_uri)
506 );
507 CREATE INDEX IF NOT EXISTS idx_ap_followers_slug ON ap_followers(slug);
508 CREATE TABLE IF NOT EXISTS ap_interactions (
509 id INTEGER PRIMARY KEY AUTOINCREMENT,
510 kind TEXT NOT NULL, -- 'reply' | 'like' | 'announce'
511 post_id TEXT NOT NULL,
512 object_uri TEXT NOT NULL DEFAULT '', -- remote note id (reply) or '' (like/announce)
513 actor_uri TEXT NOT NULL,
514 actor_name TEXT,
515 actor_handle TEXT,
516 actor_url TEXT,
517 actor_icon TEXT,
518 content TEXT, -- sanitized HTML (reply)
519 published TEXT,
520 parent_uri TEXT, -- the note this reply replies to (for nesting)
521 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
522 UNIQUE(kind, post_id, actor_uri, object_uri)
523 );
524 CREATE INDEX IF NOT EXISTS idx_ap_inter_post ON ap_interactions(post_id, kind);
525 -- Moderation tombstones: object URIs the site owner removed. Checked at ingest
526 -- (handleInbox) AND by the thread-crawler, so a removed reply never comes back
527 -- via thread-filling. Private notes can't be flagged via authorize_interaction
528 -- (their fetch 401s), so owner moderation acts on the locally stored copy.
529 CREATE TABLE IF NOT EXISTS ap_rejected_objects (
530 object_uri TEXT PRIMARY KEY,
531 post_id TEXT,
532 reason TEXT,
533 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
534 );
535 -- ActivityPub C2S (client-to-server): OAuth 2.0 for native/web clients (Shaer).
536 -- Public clients + PKCE (RFC 8252); tokens stored hashed; token is per user+site.
537 CREATE TABLE IF NOT EXISTS oauth_clients (
538 client_id TEXT PRIMARY KEY,
539 client_name TEXT,
540 redirect_uris TEXT NOT NULL, -- JSON array
541 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
542 );
543 CREATE TABLE IF NOT EXISTS oauth_codes (
544 code TEXT PRIMARY KEY,
545 client_id TEXT NOT NULL,
546 user_id TEXT NOT NULL,
547 site_slug TEXT NOT NULL,
548 redirect_uri TEXT NOT NULL,
549 code_challenge TEXT, -- PKCE S256 (verplicht voor public clients)
550 scope TEXT,
551 expires_at DATETIME NOT NULL,
552 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
553 );
554 CREATE TABLE IF NOT EXISTS oauth_tokens (
555 token_hash TEXT PRIMARY KEY, -- sha256(bearer); het token zelf slaan we nooit op
556 client_id TEXT NOT NULL,
557 user_id TEXT NOT NULL,
558 site_slug TEXT NOT NULL,
559 scope TEXT,
560 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
561 last_used_at DATETIME
562 );
563 -- Paid posts (klonkt-demo-aki): the site owner's own Patreon campaign.
564 -- Secrets are encrypted at rest (CryptoBox). Never reuses the instance-level
565 -- patreon_* settings, which are Klonkt Premium's separate license flow.
566 CREATE TABLE IF NOT EXISTS paid_patreon (
567 site_id TEXT PRIMARY KEY,
568 client_id TEXT,
569 client_secret_enc TEXT,
570 campaign_id TEXT,
571 access_token_enc TEXT,
572 refresh_token_enc TEXT,
573 token_exp INTEGER, -- unix seconds
574 default_min_cents INTEGER DEFAULT 0,
575 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
576 );
577 -- One row per passkey. NO patron identity is stored (design decision):
578 -- {passkey, site, proven cents, expiry}. Not traceable to a person.
579 CREATE TABLE IF NOT EXISTS paid_entitlements (
580 credential_id TEXT PRIMARY KEY, -- WebAuthn credential id (opaque, base64url)
581 site_id TEXT NOT NULL,
582 public_key TEXT NOT NULL, -- COSE public key, base64url
583 counter INTEGER DEFAULT 0,
584 transports TEXT,
585 min_cents INTEGER DEFAULT 0, -- the amount proven at link time
586 expires_at INTEGER NOT NULL, -- unix seconds; re-link after
587 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
588 );
589 -- Web Push (docs/webpush-design.md): one row per browser/device the owner
590 -- enabled notifications on. Payloads are encrypted to p256dh/auth (RFC 8291).
591 CREATE TABLE IF NOT EXISTS push_subscriptions (
592 endpoint TEXT PRIMARY KEY, -- push-service URL for this device
593 user_id TEXT NOT NULL,
594 p256dh TEXT NOT NULL, -- client public key
595 auth TEXT NOT NULL, -- client auth secret
596 alert_types TEXT, -- JSON {follow,reply,like,boost,dm}
597 ua_label TEXT,
598 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
599 last_ok_at DATETIME
600 );
601 CREATE TABLE IF NOT EXISTS ap_outbox (
602 id TEXT PRIMARY KEY, -- note path segment (uuid) → /ap/notes/<id>
603 site_slug TEXT NOT NULL,
604 post_id TEXT NOT NULL,
605 post_slug TEXT,
606 in_reply_to TEXT, -- remote status uri we reply to
607 to_actor TEXT, -- remote actor uri (mentioned)
608 to_handle TEXT,
609 content TEXT NOT NULL, -- sanitized HTML of our reply
610 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
611 );
612 CREATE INDEX IF NOT EXISTS idx_ap_outbox_post ON ap_outbox(post_id);
613 -- Your like/boost state on a REMOTE post (the interact page), so those become toggles.
614 CREATE TABLE IF NOT EXISTS ap_my_reactions (
615 site_slug TEXT NOT NULL,
616 target_uri TEXT NOT NULL,
617 kind TEXT NOT NULL, -- 'like' | 'boost'
618 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
619 UNIQUE(site_slug, target_uri, kind)
620 );
621 `);
622 ensureColumn('ap_interactions', 'parent_uri', 'TEXT'); // nesting (existing DBs)
623 ensureColumn('ap_interactions', 'acted_boost', 'INTEGER DEFAULT 0'); // owner boosted this comment (🔁) → can undo
624 ensureColumn('ap_interactions', 'acted_like', 'INTEGER DEFAULT 0'); // owner liked this comment (⭐) → can undo
625
626 // Fediverse CLIENT: accounts WE follow (outbound) + the home timeline of their posts.
627 db.exec(`
628 CREATE TABLE IF NOT EXISTS ap_following (
629 id INTEGER PRIMARY KEY AUTOINCREMENT,
630 slug TEXT NOT NULL, -- our site that follows
631 actor_uri TEXT NOT NULL, -- the followed account's actor id
632 handle TEXT, name TEXT, icon TEXT, url TEXT,
633 inbox TEXT, -- their inbox (for Create delivery / Undo)
634 follow_id TEXT, -- the Follow activity id we sent (Accept matching)
635 status TEXT DEFAULT 'pending', -- pending | accepted
636 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
637 UNIQUE(slug, actor_uri)
638 );
639 -- Antwoorden van accounts die we volgen komen gewoon binnen, ondertekend
640 -- door de schrijver, maar horen niet in de Krant (belongsInTimeline) en
641 -- werden daarna nergens bewaard. Kwam er later een doorgestuurd antwoord OP
642 -- zo'n bericht, dan kenden we de ouder niet en wezen we het af (shaer-e9g).
643 -- Alleen de URI, geen inhoud: dit voedt uitsluitend de vraag "kennen wij dit
644 -- bericht?". Wordt na 30 dagen gesnoeid; doorsturen gebeurt kort na het
645 -- antwoord, dus langer bewaren levert niets op.
646 CREATE TABLE IF NOT EXISTS ap_seen_notes (
647 uri TEXT PRIMARY KEY,
648 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
649 );
650 CREATE INDEX IF NOT EXISTS idx_ap_seen_notes_age ON ap_seen_notes(created_at);
651 CREATE TABLE IF NOT EXISTS ap_timeline (
652 id TEXT NOT NULL, -- the remote note's AP id
653 slug TEXT NOT NULL, -- whose home timeline (our site)
654 author_uri TEXT, author_name TEXT, author_handle TEXT, author_icon TEXT, author_url TEXT,
655 content TEXT, url TEXT, published TEXT, media_json TEXT,
656 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
657 UNIQUE(slug, id)
658 );
659 CREATE INDEX IF NOT EXISTS idx_ap_timeline_slug ON ap_timeline(slug, published);
660 -- canonicalReactionUri herleidt een permalink naar het object-id door op (slug, url)
661 -- te zoeken. Zonder deze index viel dat terug op idx_ap_timeline_slug, dus een scan
662 -- van elke rij van die slug. Dat gebeurt PER REACTIE in getInteractions, en de
663 -- reactie-migratie erft het in haar re-key-join, die synchroon vóór listen draait:
664 -- de opstartkosten waren reacties maal tijdlijnrijen.
665 CREATE INDEX IF NOT EXISTS idx_ap_timeline_url ON ap_timeline(slug, url);
666 -- FEP-1580: de vertaaltabel van een verhuizing. Per gemigreerd object waar
667 -- het VANDAAN kwam en welke URI het HIER kreeg. Derden lezen deze mapping en
668 -- werken er hun eigen inReplyTo/Like-verwijzingen mee bij; zonder deze tabel
669 -- is "de berichten krijgen nieuwe adressen" een permanent kapotte draad.
670 --
671 -- De spec eist omgekeerd-chronologisch op het moment dat de kopie HIER is
672 -- aangemaakt (niet de oorspronkelijke publicatiedatum). Daarom sorteren we
673 -- op de autoincrement-id en niet op created_at: die heeft secondeprecisie,
674 -- en een ingest zet er tientallen per seconde in.
675 CREATE TABLE IF NOT EXISTS ap_migration (
676 id INTEGER PRIMARY KEY AUTOINCREMENT,
677 slug TEXT NOT NULL, -- onze site, de DOELkant van de verhuizing
678 origin TEXT NOT NULL, -- object-URI op de broninstantie
679 target TEXT NOT NULL, -- de URI die het object hier kreeg
680 source_actor TEXT NOT NULL DEFAULT '',
681 is_public INTEGER NOT NULL DEFAULT 1, -- niet-publieke items horen niet in een publieke pagina
682 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
683 UNIQUE(slug, origin)
684 );
685 CREATE INDEX IF NOT EXISTS idx_ap_migration_slug ON ap_migration(slug, id DESC);
686 -- De Move-activities zelf, gededupliceerd. FEP-1580 wil dat deze collectie
687 -- een op zichzelf staand bewijs vormt voor de items in ap_migration, met een
688 -- FEP-8b32 integrity proof van de bron-actor plus een kopie van diens
689 -- actor-document. Wij bewaren allebei die stukken al (activity_json en
690 -- actor_json), maar Klonkt kent 8b32 nog niet: zie shaer-j1v0. Zolang dat
691 -- open staat is deze collectie structureel goed en niet verifieerbaar.
692 CREATE TABLE IF NOT EXISTS ap_moves (
693 id INTEGER PRIMARY KEY AUTOINCREMENT,
694 slug TEXT NOT NULL,
695 move_id TEXT NOT NULL, -- id van de Move-activity, tevens dedup-sleutel
696 source_actor TEXT NOT NULL,
697 target_actor TEXT NOT NULL,
698 activity_json TEXT NOT NULL,
699 actor_json TEXT, -- inline kopie van het bron-actordocument
700 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
701 UNIQUE(slug, move_id)
702 );
703 CREATE TABLE IF NOT EXISTS ap_blocks (
704 id INTEGER PRIMARY KEY AUTOINCREMENT,
705 slug TEXT NOT NULL, -- our site that set the block
706 target TEXT NOT NULL, -- actor URI (actor block) or domain (domain block)
707 kind TEXT NOT NULL, -- 'actor' | 'domain'
708 label TEXT, -- display (@handle or domain)
709 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
710 UNIQUE(slug, target)
711 );
712 CREATE INDEX IF NOT EXISTS idx_ap_blocks_target ON ap_blocks(target);
713 -- Committed guardian ↔ ward relations, one row per local side. role
714 -- 'ward' = the local slug is a ward of other_uri; 'guardian' = the local
715 -- slug guards other_uri. status is always 'accepted' here now: PENDING
716 -- offers live in ap_guardian_offers below (FEP-633c multi-party handshake).
717 CREATE TABLE IF NOT EXISTS ap_guardianships (
718 id INTEGER PRIMARY KEY AUTOINCREMENT,
719 slug TEXT NOT NULL, -- our local site in this relation (guardianship module)
720 role TEXT NOT NULL, -- 'guardian' (slug guards other) | 'ward' (other guards slug)
721 other_uri TEXT NOT NULL, -- the counterpart actor URI (local or remote)
722 other_handle TEXT, -- cached @user@host for display
723 status TEXT NOT NULL, -- 'offered' (legacy) | 'accepted'
724 offer_id TEXT, -- the Offer activity id (FEP-633c section 3)
725 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
726 UNIQUE(slug, role, other_uri)
727 );
728 CREATE INDEX IF NOT EXISTS idx_ap_guardianships_slug ON ap_guardianships(slug, role, status);
729 -- The multi-party handshake (FEP-633c section 3), one row per offer this
730 -- instance is a party to. Mirrors the Shaer test daemon's Handshake:
731 -- accepts accumulate in ap_guardian_offer_accepts, and the offer commits
732 -- only when the candidate returns the handle after ward + candidate + at
733 -- least one existing guardian have accepted.
734 CREATE TABLE IF NOT EXISTS ap_guardian_offers (
735 offer_id TEXT NOT NULL, -- the Offer activity id (minted by the candidate)
736 slug TEXT NOT NULL, -- the local site tracking this handshake (each party keeps its own copy)
737 ward_uri TEXT NOT NULL, -- the ward-to-be
738 candidate_uri TEXT NOT NULL, -- the guardian-candidate (fixed initiator)
739 existing_guardians TEXT NOT NULL DEFAULT '[]', -- JSON array of the ward's current guardian URIs
740 status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'committed' | 'void'
741 handle TEXT, -- the escalation handle returned at commit (section 6)
742 ward_handle TEXT, -- cached @ward@host for display
743 candidate_handle TEXT, -- cached @candidate@host for display
744 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
745 PRIMARY KEY (slug, offer_id)
746 );
747 CREATE INDEX IF NOT EXISTS idx_ap_guardian_offers_slug ON ap_guardian_offers(slug, status);
748 CREATE TABLE IF NOT EXISTS ap_guardian_offer_accepts (
749 offer_id TEXT NOT NULL, -- FK to ap_guardian_offers
750 slug TEXT NOT NULL, -- the local site's copy of the tally
751 party_uri TEXT NOT NULL, -- the party who accepted (ward | candidate | an existing guardian)
752 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
753 PRIMARY KEY (slug, offer_id, party_uri)
754 );
755 -- FEP-633c §5.6: a gated setting a ward's guardians decide together, which
756 -- has to work when they live on other servers (the ordinary case). One row
757 -- per guardian answer; the ward's server tallies (§3.5) and enforces.
758 -- The proposals themselves, so an Accept that only references the offer
759 -- id can still be resolved to "which feature, which value".
760 CREATE TABLE IF NOT EXISTS ap_gated_offers (
761 offer_id TEXT PRIMARY KEY,
762 slug TEXT NOT NULL, -- the ward, on this server
763 feature TEXT NOT NULL,
764 value INTEGER NOT NULL,
765 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
766 );
767 -- The guardian-side COPY of a gated-setting proposal on a ward, forwarded
768 -- here by the WARD's server (the same shape ap_follow_reviews has for a
769 -- gated follow). Without it a guardian on another server never learns a
770 -- proposal exists and can never answer it, so a threshold of two can never
771 -- be reached and every proposal expires. The answer goes back to the
772 -- ward's inbox, which tallies (5.6).
773 CREATE TABLE IF NOT EXISTS ap_gated_reviews (
774 id TEXT NOT NULL, -- the offer id, as minted by the proposer
775 guardian_slug TEXT NOT NULL, -- us, one of the ward's guardians
776 ward_uri TEXT NOT NULL,
777 ward_inbox TEXT,
778 proposer TEXT, -- who opened it (for display)
779 feature TEXT NOT NULL,
780 value INTEGER NOT NULL,
781 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
782 PRIMARY KEY (guardian_slug, id)
783 );
784 -- The PROPOSER's own record of a gated proposal it sent (5.6). Without it
785 -- a guardian clicks "propose", the ward's server tallies somewhere else,
786 -- and the proposer has nowhere to even see that something is running: the
787 -- status was a button caption that did not survive a page refresh. The
788 -- ward's server answers the Offer once the decision settles (Accept when
789 -- it settled on the proposed value, Reject otherwise); that answer lands
790 -- in status. An open row past the decision window renders as expired.
791 CREATE TABLE IF NOT EXISTS ap_gated_sent (
792 offer_id TEXT PRIMARY KEY, -- as minted by us, the proposer
793 guardian_slug TEXT NOT NULL, -- us
794 ward_uri TEXT NOT NULL,
795 feature TEXT NOT NULL,
796 value INTEGER NOT NULL, -- what we proposed
797 status TEXT NOT NULL DEFAULT 'open', -- 'open' | 'accepted' | 'rejected'
798 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
799 );
800 CREATE TABLE IF NOT EXISTS ap_gated_votes (
801 slug TEXT NOT NULL, -- the WARD, on this server
802 feature TEXT NOT NULL, -- e.g. 'shaer:externalEmbeds'
803 guardian_uri TEXT NOT NULL, -- who answered (must be a committed guardian)
804 value INTEGER NOT NULL, -- the value they voted for (0/1)
805 opened_at DATETIME NOT NULL, -- when this decision opened (the window start)
806 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
807 PRIMARY KEY (slug, feature, guardian_uri)
808 );
809 -- Guardian availability (FEP-633c 3.6): one guardian's attention as seen
810 -- from one ward on this server. Never public; the ward reads it via the
811 -- owner-only guardians queue. One rule above all: one answer restores
812 -- everything, so every row here is one answer away from disappearing.
813 CREATE TABLE IF NOT EXISTS ap_guardian_attention (
814 ward_slug TEXT NOT NULL,
815 guardian_uri TEXT NOT NULL,
816 state TEXT NOT NULL DEFAULT 'active', -- 'active' | 'away' | 'dormant'
817 away_until INTEGER, -- epoch ms while declared away
818 PRIMARY KEY (ward_slug, guardian_uri)
819 );
820 -- The ONLY admissible dormancy evidence (3.6.2): directly addressed
821 -- requests that went unanswered. Calendar time alone never counts.
822 CREATE TABLE IF NOT EXISTS ap_attention_requests (
823 ward_slug TEXT NOT NULL,
824 guardian_uri TEXT NOT NULL,
825 request_id TEXT NOT NULL,
826 asked_at INTEGER NOT NULL, -- epoch ms
827 PRIMARY KEY (ward_slug, guardian_uri, request_id)
828 );
829 -- A lapse (3.6.3): the available co-guardians deciding to release a
830 -- dormant one. Irreversible, so the window always runs in full; any sign
831 -- of life from the target cancels it outright.
832 CREATE TABLE IF NOT EXISTS ap_lapses (
833 id TEXT PRIMARY KEY,
834 ward_slug TEXT NOT NULL,
835 ward_uri TEXT NOT NULL,
836 target_uri TEXT NOT NULL,
837 opened_by TEXT NOT NULL,
838 set_json TEXT NOT NULL, -- the available set at open, target excluded
839 accepts_json TEXT NOT NULL DEFAULT '[]',
840 rejects_json TEXT NOT NULL DEFAULT '[]',
841 opened_at INTEGER NOT NULL, -- epoch ms
842 window_ms INTEGER NOT NULL,
843 cancelled INTEGER NOT NULL DEFAULT 0,
844 applied INTEGER NOT NULL DEFAULT 0,
845 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
846 );
847 CREATE TABLE IF NOT EXISTS ap_delivery (
848 id INTEGER PRIMARY KEY AUTOINCREMENT,
849 slug TEXT NOT NULL, -- our site/actor that signs the delivery
850 inbox TEXT NOT NULL, -- recipient inbox URL
851 body TEXT NOT NULL, -- the activity JSON to POST
852 attempts INTEGER NOT NULL DEFAULT 0,
853 next_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
854 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
855 );
856 CREATE INDEX IF NOT EXISTS idx_ap_delivery_due ON ap_delivery(next_at);
857 CREATE TABLE IF NOT EXISTS poll_votes (
858 id INTEGER PRIMARY KEY AUTOINCREMENT,
859 post_id INTEGER NOT NULL, -- our local poll post (posts.id)
860 actor_uri TEXT NOT NULL, -- the remote voter's AP actor URI
861 choice TEXT NOT NULL, -- the chosen option's name (matches poll_json options[].name)
862 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
863 UNIQUE(post_id, actor_uri, choice)
864 );
865 CREATE INDEX IF NOT EXISTS idx_poll_votes_post ON poll_votes(post_id);
866 CREATE TABLE IF NOT EXISTS ap_mentions (
867 id INTEGER PRIMARY KEY AUTOINCREMENT,
868 slug TEXT NOT NULL, -- our mentioned site/actor
869 object_uri TEXT NOT NULL, -- the remote note that mentions us
870 note_url TEXT, -- its human URL (open/interact)
871 actor_uri TEXT, actor_name TEXT, actor_handle TEXT, actor_icon TEXT, actor_url TEXT,
872 content TEXT, -- sanitized HTML snippet of the mentioning note
873 published TEXT,
874 in_reply_to TEXT, -- de note waarop dit een antwoord is (AS2 inReplyTo)
875 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
876 UNIQUE(slug, object_uri)
877 );
878 CREATE INDEX IF NOT EXISTS idx_ap_mentions_slug ON ap_mentions(slug, created_at);
879 CREATE TABLE IF NOT EXISTS ap_reports (
880 id INTEGER PRIMARY KEY AUTOINCREMENT,
881 slug TEXT NOT NULL, -- our site the report is about (its owner moderates)
882 actor_uri TEXT, -- the reporter's actor URI
883 actor_name TEXT, actor_handle TEXT, actor_icon TEXT,
884 content TEXT, -- the reason (plain text)
885 objects TEXT, -- JSON array of reported object URIs (our actor + statuses)
886 seen INTEGER DEFAULT 0,
887 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
888 );
889 CREATE INDEX IF NOT EXISTS idx_ap_reports_slug ON ap_reports(slug, created_at);
890 `);
891 // "Feature" a followed account: its posts show in the local Cirkel. Heet in de
892 // UI "Uitgelicht" / "Featured" (tl.autoboost), en zo heet de kolom ook in
893 // following.csv. Niet te verwarren met `featured` op de ACTOR: dat is de
894 // collectie vastgezette POSTS (toot:featured), iets heel anders.
895 ensureColumn('ap_following', 'auto_boost', 'INTEGER DEFAULT 0');
896 // A timeline post you boosted (🔁) — also shown in the Cirkel (mixed by date).
897 ensureColumn('ap_timeline', 'boosted', 'INTEGER DEFAULT 0');
898 ensureColumn('ap_timeline', 'liked', 'INTEGER DEFAULT 0'); // a feed post you liked (⭐) → toggle
899 ensureColumn('ap_timeline', 'nsfw', 'INTEGER DEFAULT 0'); // remote sensitive post → blur in the Cirkel
900 ensureColumn('ap_timeline', 'cw', 'TEXT'); // remote content-warning text
901 ensureColumn('ap_timeline', 'emoji_json', 'TEXT'); // FEP-9098 custom emoji Emoji tags from the inbound note, served back as `tag`
902 ensureColumn('ap_timeline', 'link_json', 'TEXT'); // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
903 ensureColumn('ap_timeline', 'quote_json', 'TEXT'); // FEP-044f resolved quoted-post snapshot (author + content), for the embedded quote card
904 // FEP-044f: the fediverse object THIS post quotes, resolved once at publish
905 // time so buildNote (sync, also used by the outbox) needs no network.
906 ensureColumn('posts', 'quote_uri', 'TEXT'); // the quoted object's id
907 // De kaart op je EIGEN post (shaer-k3f): dezelfde snapshots die ap_timeline
908 // voor binnenkomende posts draagt, maar dan voor wat je zelf publiceert --
909 // zonder deze twee kan de app een eigen post nooit als kaart tonen.
910 ensureColumn('posts', 'quote_json', 'TEXT'); // FEP-044f resolved quote snapshot
911 ensureColumn('posts', 'embed_json', 'TEXT'); // externe linkkaart (oEmbed/OG), thumbnail-only
912 ensureColumn('posts', 'quote_actor', 'TEXT'); // its author, so we can address them
913 ensureColumn('ap_timeline', 'embed_json', 'TEXT'); // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
914 ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the author's display name (shaer:author.emojis)
915 ensureColumn('ap_timeline', 'reblog_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the booster's display name (shaer:booster.emojis)
916 ensureColumn('ap_timeline', 'reblog_name', 'TEXT'); // a followed account boosted this → "X boosted"
917 ensureColumn('ap_timeline', 'reblog_handle', 'TEXT'); // the booster's @handle
918 ensureColumn('ap_timeline', 'reblog_icon', 'TEXT'); // the booster's avatar
919 ensureColumn('ap_timeline', 'poll_json', 'TEXT'); // a Question (poll): {multiple,options[{name,count}],endTime,closed,voters,voted}
920
921 // Delivery health per follower → surface dead accounts for manual cleanup.
922 ensureColumn('ap_followers', 'last_delivery_at', 'DATETIME'); // last SUCCESSFUL delivery to this follower's inbox
923 ensureColumn('ap_followers', 'last_error_at', 'DATETIME'); // last time a delivery to it gave up (max retries)
924
925 // ActivityPub `source` model: content_rendered = baked display HTML (#hashtags / URLs /
926 // @mentions linkified once at save). `content` stays the raw source used for editing and
927 // re-rendering. NULL on old posts → the render route bakes on the fly as a fallback.
928 ensureColumn('posts', 'content_rendered', 'TEXT');
929
930 // AP addressing of an incoming interaction: 'public' | 'unlisted' | 'followers' | 'direct',
931 // derived from the note's to/cc at ingest. The public post page only renders public/unlisted
932 // replies; followers/direct replies surface in notifications (and later Messages) with post
933 // context instead. Existing rows default to 'public' (historically almost all were).
934 ensureColumn('ap_interactions', 'visibility', "TEXT DEFAULT 'public'");
935 ensureColumn('ap_interactions', 'emoji_json', 'TEXT'); // FEP-9098 custom emojis in a reply's content (messages + thread)
936 ensureColumn('ap_interactions', 'actor_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the reply author's display name
937 // Rich replies: the reply's language (BCP47 code) → contentMap on the outgoing Note.
938 ensureColumn('ap_outbox', 'language', 'TEXT');
939 // Rich replies: JSON array [{url, mediaType, name}] → `attachment` on the Note.
940 ensureColumn('ap_outbox', 'attachments', 'TEXT');
941 ensureColumn('posts', 'ap_visibility', 'TEXT'); // public|quiet|friends|direct (C2S addressing, shaer-60b)
942 ensureColumn('posts', 'paid', 'INTEGER DEFAULT 0'); // paid post (klonkt-demo-aki)
943 ensureColumn('posts', 'paid_min_cents', 'INTEGER'); // required support; null = owner default
944 ensureColumn('paid_patreon', 'patreon_url', 'TEXT'); // owner's public Patreon page → "Word supporter" link (klonkt-demo-aki)
945 ensureColumn('ap_outbox', 'visibility', 'TEXT'); // 'direct' = private mention, never Public (shaer-tqc)
946 ensureColumn('ap_outbox', 'to_actors', 'TEXT'); // JSON array of recipient actor URIs for direct notes
947 ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
948 // Wie er op een hulpvraag af is, en wanneer hij is afgesloten (shaer-lgo).
949 // Los van ap_mentions, want dit is GEDEELDE staat: elke guardian van dit kind
950 // heeft er een kopie van, en die komt binnen als bericht van een ander. Een
951 // kolom op de mention zou alleen over onszelf gaan.
952 //
953 // OPGEPIKT mag stapelen: twee mensen die tegelijk reageren op een kind dat om
954 // hulp vraagt is geen probleem. Twee mensen die allebei niets doen omdat de
955 // ander het "geclaimd" had, wel.
956 //
957 // AFGEHANDELD kent geen terugdraai. Sluiten gebeurt met een stevige
958 // bevestiging, en leeft de vraag daarna nog, dan wordt hij opnieuw gesteld --
959 // een nieuwe hulpvraag. Zo blijft het verslag eerlijk: er wordt niets
960 // herschreven, er wordt toegevoegd.
961 db.exec(`CREATE TABLE IF NOT EXISTS ap_help_state (
962 note_uri TEXT NOT NULL,
963 guardian_uri TEXT NOT NULL,
964 kind TEXT NOT NULL, -- pickup | handled
965 guardian_handle TEXT,
966 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
967 PRIMARY KEY (note_uri, guardian_uri, kind)
968 )`);
969 db.exec('CREATE INDEX IF NOT EXISTS idx_ap_help_state_note ON ap_help_state(note_uri)');
970 // Een kind dat zelf om een poort vraagt (shaer-8ru). Een VRAAG, geen stem:
971 // pas als een guardian hem oppakt wordt het een voorstel dat langs de tally
972 // gaat. handled_at in plaats van verwijderen -- wat een kind gevraagd heeft
973 // hoort terug te vinden te zijn, ook als het antwoord nee was.
974 db.exec(`CREATE TABLE IF NOT EXISTS ap_gate_requests (
975 id INTEGER PRIMARY KEY AUTOINCREMENT,
976 slug TEXT NOT NULL, -- de guardian die hem ontving
977 ward_uri TEXT NOT NULL,
978 feature TEXT NOT NULL,
979 note_uri TEXT,
980 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
981 handled_at TEXT,
982 UNIQUE (slug, ward_uri, feature, handled_at)
983 )`);
984 db.exec('CREATE INDEX IF NOT EXISTS idx_ap_gate_requests_slug ON ap_gate_requests(slug, handled_at)');
985 ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
986 ensureColumn('ap_outbox', 'wave', 'INTEGER'); // FEP-633c shaer:wave (guardian -> ward nudge)
987 ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms)
988 ensureColumn('ap_gated_offers', 'proposer', 'TEXT'); // who proposed (5.6): the settle-answer goes back to them
989 // Zou JOUW antwoord het besluit afmaken (shaer-8vt)? De telling loopt op de
990 // server van het kind; zonder dit veld kan een guardian elders niet weten dat
991 // hij de doorslag geeft. Ontbreekt hij, dan waarschuwen we -- bij twijfel.
992 ensureColumn('ap_gated_reviews', 'decisive', 'INTEGER');
993 // Did a guardian actually say yes to this follower? That is what makes the
994 // mutual shortcut sound: a ward may follow back anyone its guardians already
995 // admitted, without asking the same question twice. Only follows that came
996 // through the §5.3 gate carry the mark; a free actor's followers never faced
997 // one. Everyone already following when this column arrives is grandfathered
998 // in (Barts besluit, 3-8): the rule is exact from that moment forward rather
999 // than retroactively suspicious of relationships that already exist.
1000 {
1001 const had = db.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('ap_followers') WHERE name = 'gate_approved'").get();
1002 ensureColumn('ap_followers', 'gate_approved', 'INTEGER DEFAULT 0');
1003 if (!had || !had.n) {
1004 try { db.prepare('UPDATE ap_followers SET gate_approved = 1').run(); } catch { /* table still empty on a fresh init */ }
1005 }
1006 }
1007 ensureColumn('posts', 'c2s_attachments', 'TEXT'); // media a C2S Note carried (JSON [{url,mediaType,name}]); buildNote federates them
1008 // 30-7: C2S posts briefly got their content media copied onto the cover,
1009 // which showed the same video twice on the post page. Clear the covers that
1010 // duplicate their own content; idempotent, only ever touches those.
1011 try {
1012 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();
1013 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();
1014 } catch { /* posts table absent on fresh init */ }
1015 // Bestaande rijen naar EEN spelling (shaer-a937). De schrijfwegen leveren
1016 // sinds deze release ISO; dit haalt na wat er in SQL-notatie is blijven
1017 // staan, zodat de sortering ook zonder de isoSql-wikkel klopt.
1018 //
1019 // Alleen rijen met een spatie op positie 11 en geen 'T': dat is precies de
1020 // CURRENT_TIMESTAMP-vorm. Idempotent -- een tweede keer draaien vindt niets
1021 // meer -- en het raakt een ISO-stempel nooit aan.
1022 //
1023 // strftime geeft NULL op iets dat het niet als tijd herkent; de WHERE laat
1024 // zulke rijen met rust, want een onleesbare stempel vervangen door NULL is
1025 // gegevens weggooien. Ze blijven staan zoals ze stonden.
1026 {
1027 const kolommen = [
1028 ['ap_timeline', 'created_at'], ['ap_timeline', 'published'],
1029 ['ap_mentions', 'created_at'], ['ap_mentions', 'published'],
1030 ['ap_interactions', 'created_at'], ['ap_interactions', 'published'],
1031 ['ap_outbox', 'created_at'],
1032 ];
1033 let veranderd = 0;
1034 for (const [tabel, kolom] of kolommen) {
1035 try {
1036 const r = db.prepare(
1037 `UPDATE ${tabel} SET ${kolom} = strftime('%Y-%m-%dT%H:%M:%SZ', ${kolom})
1038 WHERE ${kolom} IS NOT NULL AND ${kolom} LIKE '____-__-__ %'
1039 AND strftime('%Y-%m-%dT%H:%M:%SZ', ${kolom}) IS NOT NULL`).run();
1040 veranderd += r.changes;
1041 } catch { /* tabel bestaat nog niet op een verse installatie */ }
1042 }
1043 if (veranderd) console.log(`🕒 tijdstempels genormaliseerd: ${veranderd} rijen`);
1044 }
1045 ensureColumn('ap_mentions', 'wave', 'INTEGER'); // inbound guardian wave
1046 // FEP-633c §2.2: object hint that the author is a ward. Register-only for now;
1047 // used later at reddings-boei / escalation routing.
1048 ensureColumn('ap_timeline', 'has_guardians', 'INTEGER');
1049 ensureColumn('ap_mentions', 'has_guardians', 'INTEGER');
1050 // Berichten and de Krant render a post the same way, so a mention or a reply
1051 // needs the same trimmings a timeline row already has: custom emojis, the
1052 // media the note carried, and the quote / link-preview card.
1053 ensureColumn('ap_mentions', 'emoji_json', 'TEXT'); // FEP-9098, in the content
1054 ensureColumn('ap_mentions', 'actor_emoji_json', 'TEXT'); // FEP-9098, in the display name
1055 ensureColumn('ap_mentions', 'media_json', 'TEXT');
1056 ensureColumn('ap_mentions', 'quote_json', 'TEXT'); // FEP-044f quoted post
1057 ensureColumn('ap_mentions', 'embed_json', 'TEXT'); // external link preview
1058 // AS2 inReplyTo op een vermelding/bericht. Bestond hier niet, en daarmee ging
1059 // de ouder bij het opslaan verloren: elk ANTWOORD in een gesprek kwam bij de
1060 // client aan alsof het een gesprek begon (Robins melding, 26-8). Bestaande
1061 // rijen blijven leeg -- die ouder is niet meer te achterhalen zonder hem
1062 // opnieuw op te halen, en een verzonnen ouder is erger dan geen.
1063 ensureColumn('ap_mentions', 'in_reply_to', 'TEXT');
1064 // Zachte groepen (shaer-mbji, src/services/conversation-key.js). Een gesprek
1065 // is de SET deelnemers; die werd bij een binnenkomende note nergens bewaard,
1066 // dus een bericht van Anna aan jou en Ben wist niet meer dat Ben erbij was.
1067 //
1068 // `conv` is de sleutel, één keer berekend bij het schrijven: elke lezing
1069 // hoeft hem dan alleen te lezen, en een sleutel die bij elke query opnieuw
1070 // uit JSON gebouwd wordt is een plek om het verschillend te doen. NULL op
1071 // oude rijen, en dan valt de lezing terug op wat hij altijd deed.
1072 ensureColumn('ap_mentions', 'conv', 'TEXT');
1073 ensureColumn('ap_mentions', 'participants', 'TEXT'); // JSON: de anderen, zonder mij
1074 ensureColumn('ap_mentions', 'context', 'TEXT'); // FEP-7888
1075 ensureColumn('ap_outbox', 'conv', 'TEXT');
1076 ensureColumn('ap_outbox', 'context', 'TEXT');
1077 ensureColumn('ap_interactions', 'media_json', 'TEXT');
1078 ensureColumn('ap_interactions', 'quote_json', 'TEXT');
1079 ensureColumn('ap_interactions', 'embed_json', 'TEXT');
1080 ensureColumn('ap_followers', 'name', 'TEXT'); // cached display name (shaer-aa3)
1081 ensureColumn('ap_followers', 'handle', 'TEXT'); // @user@host
1082 ensureColumn('ap_followers', 'icon', 'TEXT'); // avatar URL
1083 feedStateTriggers();
1084}
1085
1086/**
1087 * Wat er met een tijdlijn gebeurd is, op één plek (shaer-n05).
1088 *
1089 * De inbox-lezing voegt vier bronnen samen. De vraag "is er iets veranderd" werd
1090 * eerst beantwoord met MAX(rowid) over die vier -- een TOEVALLIGE eigenschap van
1091 * de tabellen, geen feit dat ergens is opgeschreven. Dat gaf precies de gebreken
1092 * die je van zo'n afleiding verwacht: bewerkingen en verwijderingen bewogen hem
1093 * niet, en hij kon achteruit lopen. Dezelfde fout als reacties uitlezen uit
1094 * ap_timeline.liked (shaer-9e9).
1095 *
1096 * Nu één rij per bericht per tijdlijn, met een oplopende `rev` en `kind`. Dat
1097 * beantwoordt drie vragen die anders drie eigen oplossingen zouden krijgen:
1098 * is er iets veranderd sinds N, wát is er veranderd, en is dit bericht bewerkt.
1099 *
1100 * Bijgehouden door TRIGGERS en niet door de aanroepende code, om dezelfde reden
1101 * dat er geen gebeurtenis-emitter is: een trigger zit in de database, dus geen
1102 * enkel codepad kan hem vergeten. De prijs is onzichtbare logica -- wie alleen de
1103 * JavaScript leest ziet niet waarom deze tabel vult. Vandaar dat ze hier staan,
1104 * bij de tabel, en niet verspreid.
1105 *
1106 * Let op de `UPDATE OF`-kolomlijsten: die zijn niet decoratief. Een like schrijft
1107 * ap_timeline.liked en een 🔁 schrijft .boosted; zonder die afbakening zou je
1108 * eigen like het bericht als BEWERKT merken en elke wachtende client wekken.
1109 */
1110function feedStateTriggers() {
1111 try {
1112 db.exec(`
1113 -- Tot waar jij een gesprek gelezen hebt (shaer-frontend-3tx).
1114 --
1115 -- Een MARKERING, geen teller: het aantal ongelezen berichten is een
1116 -- COUNT over de berichten die na deze cursor komen. Een opgeslagen
1117 -- getal zou opgehoogd, verlaagd en gerepareerd moeten worden, en zou
1118 -- blijven staan als er iets verwijderd wordt -- badge zegt 3, er is
1119 -- niets.
1120 --
1121 -- De cursor is samengesteld ('<stempel>|<ref>'), dezelfde vorm als de
1122 -- gesprekspaginering en om dezelfde reden: twee berichten in dezelfde
1123 -- seconde is bij DM's een gesprek, geen randgeval.
1124 CREATE TABLE IF NOT EXISTS ap_read_markers (
1125 slug TEXT NOT NULL,
1126 other TEXT NOT NULL, -- de tegenpartij (actor uri)
1127 cursor TEXT NOT NULL,
1128 at DATETIME DEFAULT CURRENT_TIMESTAMP,
1129 PRIMARY KEY (slug, other)
1130 );
1131 CREATE TABLE IF NOT EXISTS ap_feed_state (
1132 slug TEXT NOT NULL,
1133 object_uri TEXT NOT NULL,
1134 rev INTEGER NOT NULL,
1135 kind TEXT NOT NULL, -- new | updated | deleted
1136 at DATETIME DEFAULT CURRENT_TIMESTAMP,
1137 PRIMARY KEY (slug, object_uri)
1138 );
1139 CREATE INDEX IF NOT EXISTS idx_ap_feed_state_rev ON ap_feed_state(slug, rev);
1140 -- Eén doorlopende teller voor de hele instance. Bewust niet MAX(rev) uit de
1141 -- tabel zelf: verdwijnt de hoogste rij, dan zou die teruglopen en denkt een
1142 -- client dat er niets gebeurd is.
1143 CREATE TABLE IF NOT EXISTS ap_feed_rev (n INTEGER NOT NULL);
1144 `);
1145 if (!db.prepare('SELECT COUNT(*) AS n FROM ap_feed_rev').get().n) {
1146 db.prepare('INSERT INTO ap_feed_rev (n) VALUES (0)').run();
1147 }
1148 // slug + object_uri verschillen per bron; de rest is voor alle vier gelijk.
1149 const zet = (naam, gebeurtenis, tabel, slug, uri, kind, extra = '', wanneer = '') => `
1150 DROP TRIGGER IF EXISTS ${naam};
1151 CREATE TRIGGER ${naam} AFTER ${gebeurtenis} ON ${tabel}${wanneer ? ` WHEN ${wanneer}` : ''} BEGIN
1152 UPDATE ap_feed_rev SET n = n + 1;
1153 INSERT INTO ap_feed_state (slug, object_uri, rev, kind)
1154 ${extra || `VALUES (${slug}, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}')`}
1155 ON CONFLICT(slug, object_uri) DO UPDATE
1156 SET rev = excluded.rev, kind = excluded.kind, at = CURRENT_TIMESTAMP;
1157 END;`;
1158 const joinPosts = (uri, kind) => `
1159 SELECT s.slug, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}'
1160 FROM posts p JOIN sites s ON s.id = p.site_id`;
1161 db.exec([
1162 zet('trg_feed_tl_ins', 'INSERT', 'ap_timeline', 'NEW.slug', 'NEW.id', 'new'),
1163 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'),
1164 zet('trg_feed_tl_del', 'DELETE', 'ap_timeline', 'OLD.slug', 'OLD.id', 'deleted'),
1165 zet('trg_feed_mn_ins', 'INSERT', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'new'),
1166 zet('trg_feed_mn_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'updated'),
1167 zet('trg_feed_mn_del', 'DELETE', 'ap_mentions', 'OLD.slug', 'OLD.object_uri', 'deleted'),
1168 zet('trg_feed_ob_ins', 'INSERT', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'new'),
1169 zet('trg_feed_ob_upd', 'UPDATE OF content, attachments', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'updated'),
1170 zet('trg_feed_ob_del', 'DELETE', 'ap_outbox', 'OLD.site_slug', 'OLD.id', 'deleted'),
1171 // ap_interactions draagt geen slug: die hangt aan de POST. Vandaar de join,
1172 // en vandaar dat deze drie niet in de gewone vorm passen.
1173 //
1174 // De WHEN op kind='reply' is nodig omdat deze tabel ook likes en announces
1175 // draagt, en die schrijven object_uri = '' (zie recordInteraction). Zonder de
1176 // WHEN bumpte elke inkomende like de rev, werd elke wachter gewekt en kreeg
1177 // die de hele collectie opnieuw terwijl er niets aan veranderd was: precies de
1178 // kosten die de 304 moest wegnemen. Bovendien belandde er dan een rij op de
1179 // lege string in ap_feed_state, die feedChangesSince vervolgens uitdeelt.
1180 // De oude cursor filterde hier wel op kind; bij ap_timeline is dit ook gedaan
1181 // (de UPDATE OF sluit liked/boosted uit) en één tabel verder vergeten.
1182 zet('trg_feed_ia_ins', 'INSERT', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'new')} WHERE p.id = NEW.post_id`, "NEW.kind = 'reply'"),
1183 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'"),
1184 zet('trg_feed_ia_del', 'DELETE', 'ap_interactions', '', '', '', `${joinPosts('OLD.object_uri', 'deleted')} WHERE p.id = OLD.post_id`, "OLD.kind = 'reply'"),
1185 ].join('\n'));
1186 } catch (e) {
1187 // Niet fataal: zonder deze tabel valt het wachten terug op "altijd de tijd
1188 // volmaken", en dat is traag maar niet stuk.
1189 console.error('❌ feed-state triggers:', e.message);
1190 }
1191}
1192
1193function ensureColumn(table, column, definition) {
1194 try {
1195 db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
1196 console.log(`🔧 Added column ${table}.${column}`);
1197 } catch (e) {
1198 // "duplicate column name" → already there. Anything else, surface it.
1199 if (!/duplicate column/i.test(e.message)) {
1200 console.error(`❌ ensureColumn(${table}.${column}):`, e.message);
1201 }
1202 }
1203}
1204
1205export default db;
Note: See TracBrowser for help on using the repository browser.