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

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

Tijdstempels, leeskant: sorteren op tijd en niet op tekst (shaer-a937)

Deze database draagt twee spellingen door elkaar: '2026-08-06 09:02:01'
van CURRENT_TIMESTAMP en '2026-08-06T02:08:01.000Z' van toISOString().
SQLite vergelijkt tekst, en op positie 10 staat een T tegenover een
spatie -- dus binnen dezelfde dag wint de ISO-vorm altijd, hoe laat hij
ook is. Gemeten op dev: ap_timeline 80 ISO tegen 80 SQL, ap_mentions 26
tegen 38, ap_interactions 2 tegen 6. De menging is er dus echt.

De helper bestond al (STEMPEL in ap-timeline, gebouwd voor de
gesprekslezing) maar stond in een dienst, terwijl elke plek die sorteert
hem nodig heeft. Hij staat nu als isoSql in config/database.js -- daar
hoort hij, want het gaat over hoe deze OPSLAG met tijd omgaat, en elke
aanroeper importeert db toch al. STEMPEL is er nu een verwijzing naar,
zodat er niet twee kopieen van dezelfde regel bestaan.

Genormaliseerd waar de menging LEEFT: de tijdlijn zelf, de reacties
onder een post (de fout die de bead mat), de berichten en de Cirkel.
Plus de archief-export, die op dezelfde kolommen sorteert.

De terugval blijft de RAUWE waarde en niet leeg: isoSql levert ook de
cursor in de gesprekslezing, en een lege stempel zou een client zijn
plek kosten. Waar een onleesbare waarde dan landt is onbepaald, en dat
staat nu ook zo in het commentaar -- eerst schreef ik daar dat zoiets
onderaan hoort te eindigen, en dat deed de code niet.

Vier toetsen met gemengde data, ISO bewust VROEGER op de dag: precies de
stand waarin een ongenormaliseerde sortering omvalt. Tegenbewijs: maak
isoSql een doorgeefluik en alle vier vallen.

Volle suite 1248 groen.

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