source: Klonkt/src/config/database.js@ 6e9bc70

main
Last change on this file since 6e9bc70 was 6e9bc70, checked in by Bart <bart@…>, 2 days ago

Zachte groepen: een gesprek is de set deelnemers, niet één tegenpartij

Een direct bericht aan Anna en Ben bestond al als groep -- to_actors droeg de
hele set -- maar elke lezing sleutelde op één other. Een uitgaand bericht
aan twee mensen viel in twee gesprekken uiteen, en een binnenkomend bericht
van Anna aan jou en Ben kwam alleen bij Anna terecht: Ben viel eruit, want de
adressering van een binnenkomer werd nergens bewaard.

Nu is er een sleutel, één keer berekend bij het schrijven
(conversation-key.js): voor één ander zijn kale URI, precies zoals het altijd
was, dus bestaande gesprekken, leesmarkeringen en ?with=-URL's blijven geldig
zonder migratie; voor een groep de gesorteerde set, gescheiden door een
spatie. Koppen, geschiedenis, leesmarkeringen en ongelezen lezen hem allemaal
via dezelfde unie, en werken daarmee per gesprek zonder dat er in die queries
iets hoefde te veranderen. Oude rijen hebben geen sleutel en lezen zoals ze
altijd lazen.

Alleen directe berichten groeperen: een openbare post aan jou en Ben is geen
besloten gesprek met z'n drieën.

/messages neemt meer dan één with, en elk item draagt shaer:conversation en
shaer:participants. Uitgaande directe notes krijgen een FEP-7888 context --
die van de ouder als het een antwoord is, anders een nieuwe -- die bewaard en
uitgestuurd wordt, maar geen bubbels samenvoegt: een bubbel die Carla toont
terwijl je antwoord haar niet bereikt, zou liegen over wie meeleest.

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

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