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

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

Playlists: uitgavedatum en MusicBrainz release-id op een album

Stap 1 van shaer-756s. Het album bestaat al als ding -- een playlist met
kind='album', met eigen id, titel, artiest, jaar, hoes en een eigen
AP-collectie. Wat ontbrak om het als Album uit te geven zijn twee velden.

release_date en niet year: die kolom blijft en wordt gewoon getoond,
maar hun AlbumSerializer leest released als een DateField. Een jaartal
als 2024-01-01 versturen is een dag verzinnen, en dat is precies wat we
bij artiesten en albums niet doen. Volledige datum of niets -- ook
2024-02-31 valt af, want Date rolt die stil door naar 2 maart en dan
bewaren we iets anders dan er ingetypt is.

mb_release_id is de tegenhanger van sites.mb_artist_id: dezelfde
verwijzing naar MusicBrainz, een niveau lager.

ALLEEN BIJ kind='album', en dat wordt in PlaylistService afgedwongen en
niet alleen in het scherm. De API ligt open -- de post-editor gebruikt
hem ook -- en een scherm is geen bewaking. Omschakelen naar afspeellijst
maakt de velden ALTIJD leeg, ook als de aanroeper er niets over zei:
anders houdt een album dat je tot mixtape ombouwt zijn uitgavedatum, en
duikt die weer op zodra iemand hem terugzet. Het leespad geeft ze ook
niet door bij een afspeellijst, zodat een oude rij niet alsnog lekt.

In de editor staan de velden onder Type en verdwijnen ze als je naar
playlist schakelt. Daar hoorde een CSS-regel bij: .pl-field is display:
flex en dat wint van het hidden-attribuut -- zonder .pl-field[hidden]
{ display: none } bleven ze gewoon staan.

Co-Authored-By: Claude Opus 5 <noreply@โ€ฆ>

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