source: Klonkt/src/config/database.js@ 76290bf

main
Last change on this file since 76290bf was 76290bf, checked in by Robin <roboburr@โ€ฆ>, 2 weeks ago

Inbox: een binnengekomen antwoord houdt zijn inReplyTo

Robins melding, 26-8: de C2S-lezing serveerde antwoorden zonder ouder.

De oorzaak lag een laag dieper dan de serialisatie: ap_mentions had geen
kolom voor inReplyTo, dus de ouder viel al bij het OPSLAAN op de grond en
messageItem had niets te serveren. Een client kan een gesprek alleen
teruglopen langs inReplyTo, dus elk antwoord kwam aan als het begin van
een gesprek -- de ketenlezing in de app kon nooit verder dan een.

Vier plekken, want het is de hele weg: de kolom (met ensureColumn voor
bestaande databases), het schrijven in de inbox, MESSAGE_COLUMNS -- de
ene leesplek die zowel de inbox-lijst als de gesprekken voedt -- en
messageItem.

De ouder gaat door dezelfde poort als de note-url: alleen http(s), en zowel de
string- als de objectvorm die AS2 toestaat. Alleen de string erkennen
zou hetzelfde gat laten voor wie de objectvorm stuurt.

Nagegaan wat WEL goed ging, zodat de reparatie niet breder wordt dan de
kwaal: replyItem droeg hem al uit ap_interactions.parent_uri, sentItem
via buildNote uit ap_outbox.in_reply_to, en de tijdlijn kan hem per
definitie niet missen -- belongsInTimeline weigert alles met inReplyTo.
Deze leg was de enige.

Bestaande rijen blijven leeg: die ouder is niet meer te achterhalen
zonder hem opnieuw op te halen, en een verzonnen ouder is erger dan
geen. Twee toetsen over de hele keten, tegenbewijs gedraaid: allebei
vallen ze tegen de code van hiervoor. Volle suite 1231 groen.

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