source: Klonkt/src/config/database.js

main
Last change on this file was 9946a68, checked in by Robin <roboburr@โ€ฆ>, 4 days ago

Tijdstempels, bronkant: een spelling bij het schrijven, plus migratie (shaer-a937)

De leeskant van 8151340 is het vangnet; dit haalt de oorzaak weg. Zolang
er twee vormen binnenkomen blijft elke nieuwe query een kans om het
opnieuw fout te doen.

ZES schrijfwegen, niet vier: naast ap_timeline, ap_interactions,
ap_outbox en ap_mentions bleken ook guardianship/delivery (directe notes)
en de archief-import in dezelfde kolommen te schrijven. Alle zes leveren
nu ISO via NU_ISO uit config/database.js, waar isoSql ook staat -- wat je
schrijft en waarmee je vergelijkt horen bij elkaar en dus op een plek.

MIGRATIE voor wat er al stond, in het bestaande idempotente patroon in
initializeDatabase: alleen rijen in de CURRENT_TIMESTAMP-vorm, alleen als
strftime ze begrijpt. Een ISO-stempel blijft ongemoeid en een onleesbare
waarde wordt niet weggegooid -- die bewaar je, ook al weet je er niets
mee.

VIER VERGELIJKINGEN die het omgekeerde deden: een kolom RAUW tegen
datetime('now') leggen. Dat is dezelfde fout gespiegeld, en hij zou juist
door deze release gaan bijten. Het gaat om de wachtwoord-reset (de bead
noemt shaer-1evq), het opruimen van geziene notes en twee queries in de
sessieopslag -- de bead schreef al dat daar de opruiming en de telling
ernaast zaten. Alle vier nu datetime(kolom) tegen datetime('now'),
hetzelfde patroon dat Scheduler al gebruikt.

BEWUST NIET: de 49 DEFAULT CURRENT_TIMESTAMP in het schema. Die vragen om
een tabel-herbouw per stuk, en ze vuren alleen als een INSERT de kolom
overslaat -- wat bij de tabellen die het betreft niet gebeurt, want daar
staat created_at expliciet in het statement.

Drie toetsen erbij, elk met eigen tegenbewijs. Een ervan moest overnieuw:
hij schreef zijn eigen ISO en kon dus niet falen op wat ik veranderde;
nu gaat hij door upsertBoostedNote, de echte schrijfweg. Onderweg zette
ik SQL met enkele quotes in een JS-string met enkele quotes -- vandaar
NU_ISO als constante in template literals, en node --check op elk
aangeraakt bestand.

Volle suite 1255 groen.

  • Property mode set to 100644
File size: 64.7 KB
Lineย 
1import Database from 'better-sqlite3';
2import path from 'path';
3import { fileURLToPath } from 'url';
4import fs from 'fs';
5
6const __dirname = path.dirname(fileURLToPath(import.meta.url));
7const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../storage/database.sqlite');
8
9// Ensure storage directory exists
10const storageDir = path.dirname(dbPath);
11if (!fs.existsSync(storageDir)) {
12 fs.mkdirSync(storageDir, { recursive: true });
13}
14
15// Initialize database
16const db = new Database(dbPath);
17db.pragma('journal_mode = WAL');
18db.pragma('foreign_keys = ON');
19// With WAL + several concurrent writers (request handlers, the delivery worker, the
20// background thread-crawler) a short write-lock should retry rather than throw SQLITE_BUSY.
21db.pragma('busy_timeout = 5000'); // wait up to 5s for a lock instead of failing immediately
22db.pragma('synchronous = NORMAL'); // safe with WAL (no torn writes); fewer fsyncs = faster writes
23
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 // Bestaande rijen naar EEN spelling (shaer-a937). De schrijfwegen leveren
1005 // sinds deze release ISO; dit haalt na wat er in SQL-notatie is blijven
1006 // staan, zodat de sortering ook zonder de isoSql-wikkel klopt.
1007 //
1008 // Alleen rijen met een spatie op positie 11 en geen 'T': dat is precies de
1009 // CURRENT_TIMESTAMP-vorm. Idempotent -- een tweede keer draaien vindt niets
1010 // meer -- en het raakt een ISO-stempel nooit aan.
1011 //
1012 // strftime geeft NULL op iets dat het niet als tijd herkent; de WHERE laat
1013 // zulke rijen met rust, want een onleesbare stempel vervangen door NULL is
1014 // gegevens weggooien. Ze blijven staan zoals ze stonden.
1015 {
1016 const kolommen = [
1017 ['ap_timeline', 'created_at'], ['ap_timeline', 'published'],
1018 ['ap_mentions', 'created_at'], ['ap_mentions', 'published'],
1019 ['ap_interactions', 'created_at'], ['ap_interactions', 'published'],
1020 ['ap_outbox', 'created_at'],
1021 ];
1022 let veranderd = 0;
1023 for (const [tabel, kolom] of kolommen) {
1024 try {
1025 const r = db.prepare(
1026 `UPDATE ${tabel} SET ${kolom} = strftime('%Y-%m-%dT%H:%M:%SZ', ${kolom})
1027 WHERE ${kolom} IS NOT NULL AND ${kolom} LIKE '____-__-__ %'
1028 AND strftime('%Y-%m-%dT%H:%M:%SZ', ${kolom}) IS NOT NULL`).run();
1029 veranderd += r.changes;
1030 } catch { /* tabel bestaat nog niet op een verse installatie */ }
1031 }
1032 if (veranderd) console.log(`๐Ÿ•’ tijdstempels genormaliseerd: ${veranderd} rijen`);
1033 }
1034 ensureColumn('ap_mentions', 'wave', 'INTEGER'); // inbound guardian wave
1035 // FEP-633c ยง2.2: object hint that the author is a ward. Register-only for now;
1036 // used later at reddings-boei / escalation routing.
1037 ensureColumn('ap_timeline', 'has_guardians', 'INTEGER');
1038 ensureColumn('ap_mentions', 'has_guardians', 'INTEGER');
1039 // Berichten and de Krant render a post the same way, so a mention or a reply
1040 // needs the same trimmings a timeline row already has: custom emojis, the
1041 // media the note carried, and the quote / link-preview card.
1042 ensureColumn('ap_mentions', 'emoji_json', 'TEXT'); // FEP-9098, in the content
1043 ensureColumn('ap_mentions', 'actor_emoji_json', 'TEXT'); // FEP-9098, in the display name
1044 ensureColumn('ap_mentions', 'media_json', 'TEXT');
1045 ensureColumn('ap_mentions', 'quote_json', 'TEXT'); // FEP-044f quoted post
1046 ensureColumn('ap_mentions', 'embed_json', 'TEXT'); // external link preview
1047 // AS2 inReplyTo op een vermelding/bericht. Bestond hier niet, en daarmee ging
1048 // de ouder bij het opslaan verloren: elk ANTWOORD in een gesprek kwam bij de
1049 // client aan alsof het een gesprek begon (Robins melding, 26-8). Bestaande
1050 // rijen blijven leeg -- die ouder is niet meer te achterhalen zonder hem
1051 // opnieuw op te halen, en een verzonnen ouder is erger dan geen.
1052 ensureColumn('ap_mentions', 'in_reply_to', 'TEXT');
1053 ensureColumn('ap_interactions', 'media_json', 'TEXT');
1054 ensureColumn('ap_interactions', 'quote_json', 'TEXT');
1055 ensureColumn('ap_interactions', 'embed_json', 'TEXT');
1056 ensureColumn('ap_followers', 'name', 'TEXT'); // cached display name (shaer-aa3)
1057 ensureColumn('ap_followers', 'handle', 'TEXT'); // @user@host
1058 ensureColumn('ap_followers', 'icon', 'TEXT'); // avatar URL
1059 feedStateTriggers();
1060}
1061
1062/**
1063 * Wat er met een tijdlijn gebeurd is, op รฉรฉn plek (shaer-n05).
1064 *
1065 * De inbox-lezing voegt vier bronnen samen. De vraag "is er iets veranderd" werd
1066 * eerst beantwoord met MAX(rowid) over die vier -- een TOEVALLIGE eigenschap van
1067 * de tabellen, geen feit dat ergens is opgeschreven. Dat gaf precies de gebreken
1068 * die je van zo'n afleiding verwacht: bewerkingen en verwijderingen bewogen hem
1069 * niet, en hij kon achteruit lopen. Dezelfde fout als reacties uitlezen uit
1070 * ap_timeline.liked (shaer-9e9).
1071 *
1072 * Nu รฉรฉn rij per bericht per tijdlijn, met een oplopende `rev` en `kind`. Dat
1073 * beantwoordt drie vragen die anders drie eigen oplossingen zouden krijgen:
1074 * is er iets veranderd sinds N, wรกt is er veranderd, en is dit bericht bewerkt.
1075 *
1076 * Bijgehouden door TRIGGERS en niet door de aanroepende code, om dezelfde reden
1077 * dat er geen gebeurtenis-emitter is: een trigger zit in de database, dus geen
1078 * enkel codepad kan hem vergeten. De prijs is onzichtbare logica -- wie alleen de
1079 * JavaScript leest ziet niet waarom deze tabel vult. Vandaar dat ze hier staan,
1080 * bij de tabel, en niet verspreid.
1081 *
1082 * Let op de `UPDATE OF`-kolomlijsten: die zijn niet decoratief. Een like schrijft
1083 * ap_timeline.liked en een ๐Ÿ” schrijft .boosted; zonder die afbakening zou je
1084 * eigen like het bericht als BEWERKT merken en elke wachtende client wekken.
1085 */
1086function feedStateTriggers() {
1087 try {
1088 db.exec(`
1089 -- Tot waar jij een gesprek gelezen hebt (shaer-frontend-3tx).
1090 --
1091 -- Een MARKERING, geen teller: het aantal ongelezen berichten is een
1092 -- COUNT over de berichten die na deze cursor komen. Een opgeslagen
1093 -- getal zou opgehoogd, verlaagd en gerepareerd moeten worden, en zou
1094 -- blijven staan als er iets verwijderd wordt -- badge zegt 3, er is
1095 -- niets.
1096 --
1097 -- De cursor is samengesteld ('<stempel>|<ref>'), dezelfde vorm als de
1098 -- gesprekspaginering en om dezelfde reden: twee berichten in dezelfde
1099 -- seconde is bij DM's een gesprek, geen randgeval.
1100 CREATE TABLE IF NOT EXISTS ap_read_markers (
1101 slug TEXT NOT NULL,
1102 other TEXT NOT NULL, -- de tegenpartij (actor uri)
1103 cursor TEXT NOT NULL,
1104 at DATETIME DEFAULT CURRENT_TIMESTAMP,
1105 PRIMARY KEY (slug, other)
1106 );
1107 CREATE TABLE IF NOT EXISTS ap_feed_state (
1108 slug TEXT NOT NULL,
1109 object_uri TEXT NOT NULL,
1110 rev INTEGER NOT NULL,
1111 kind TEXT NOT NULL, -- new | updated | deleted
1112 at DATETIME DEFAULT CURRENT_TIMESTAMP,
1113 PRIMARY KEY (slug, object_uri)
1114 );
1115 CREATE INDEX IF NOT EXISTS idx_ap_feed_state_rev ON ap_feed_state(slug, rev);
1116 -- Eรฉn doorlopende teller voor de hele instance. Bewust niet MAX(rev) uit de
1117 -- tabel zelf: verdwijnt de hoogste rij, dan zou die teruglopen en denkt een
1118 -- client dat er niets gebeurd is.
1119 CREATE TABLE IF NOT EXISTS ap_feed_rev (n INTEGER NOT NULL);
1120 `);
1121 if (!db.prepare('SELECT COUNT(*) AS n FROM ap_feed_rev').get().n) {
1122 db.prepare('INSERT INTO ap_feed_rev (n) VALUES (0)').run();
1123 }
1124 // slug + object_uri verschillen per bron; de rest is voor alle vier gelijk.
1125 const zet = (naam, gebeurtenis, tabel, slug, uri, kind, extra = '', wanneer = '') => `
1126 DROP TRIGGER IF EXISTS ${naam};
1127 CREATE TRIGGER ${naam} AFTER ${gebeurtenis} ON ${tabel}${wanneer ? ` WHEN ${wanneer}` : ''} BEGIN
1128 UPDATE ap_feed_rev SET n = n + 1;
1129 INSERT INTO ap_feed_state (slug, object_uri, rev, kind)
1130 ${extra || `VALUES (${slug}, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}')`}
1131 ON CONFLICT(slug, object_uri) DO UPDATE
1132 SET rev = excluded.rev, kind = excluded.kind, at = CURRENT_TIMESTAMP;
1133 END;`;
1134 const joinPosts = (uri, kind) => `
1135 SELECT s.slug, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}'
1136 FROM posts p JOIN sites s ON s.id = p.site_id`;
1137 db.exec([
1138 zet('trg_feed_tl_ins', 'INSERT', 'ap_timeline', 'NEW.slug', 'NEW.id', 'new'),
1139 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'),
1140 zet('trg_feed_tl_del', 'DELETE', 'ap_timeline', 'OLD.slug', 'OLD.id', 'deleted'),
1141 zet('trg_feed_mn_ins', 'INSERT', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'new'),
1142 zet('trg_feed_mn_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'updated'),
1143 zet('trg_feed_mn_del', 'DELETE', 'ap_mentions', 'OLD.slug', 'OLD.object_uri', 'deleted'),
1144 zet('trg_feed_ob_ins', 'INSERT', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'new'),
1145 zet('trg_feed_ob_upd', 'UPDATE OF content, attachments', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'updated'),
1146 zet('trg_feed_ob_del', 'DELETE', 'ap_outbox', 'OLD.site_slug', 'OLD.id', 'deleted'),
1147 // ap_interactions draagt geen slug: die hangt aan de POST. Vandaar de join,
1148 // en vandaar dat deze drie niet in de gewone vorm passen.
1149 //
1150 // De WHEN op kind='reply' is nodig omdat deze tabel ook likes en announces
1151 // draagt, en die schrijven object_uri = '' (zie recordInteraction). Zonder de
1152 // WHEN bumpte elke inkomende like de rev, werd elke wachter gewekt en kreeg
1153 // die de hele collectie opnieuw terwijl er niets aan veranderd was: precies de
1154 // kosten die de 304 moest wegnemen. Bovendien belandde er dan een rij op de
1155 // lege string in ap_feed_state, die feedChangesSince vervolgens uitdeelt.
1156 // De oude cursor filterde hier wel op kind; bij ap_timeline is dit ook gedaan
1157 // (de UPDATE OF sluit liked/boosted uit) en รฉรฉn tabel verder vergeten.
1158 zet('trg_feed_ia_ins', 'INSERT', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'new')} WHERE p.id = NEW.post_id`, "NEW.kind = 'reply'"),
1159 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'"),
1160 zet('trg_feed_ia_del', 'DELETE', 'ap_interactions', '', '', '', `${joinPosts('OLD.object_uri', 'deleted')} WHERE p.id = OLD.post_id`, "OLD.kind = 'reply'"),
1161 ].join('\n'));
1162 } catch (e) {
1163 // Niet fataal: zonder deze tabel valt het wachten terug op "altijd de tijd
1164 // volmaken", en dat is traag maar niet stuk.
1165 console.error('โŒ feed-state triggers:', e.message);
1166 }
1167}
1168
1169function ensureColumn(table, column, definition) {
1170 try {
1171 db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
1172 console.log(`๐Ÿ”ง Added column ${table}.${column}`);
1173 } catch (e) {
1174 // "duplicate column name" โ†’ already there. Anything else, surface it.
1175 if (!/duplicate column/i.test(e.message)) {
1176 console.error(`โŒ ensureColumn(${table}.${column}):`, e.message);
1177 }
1178 }
1179}
1180
1181export default db;
Note: See TracBrowser for help on using the repository browser.