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

main
Last change on this file since fa33214 was fa33214, checked in by Bart <bart@…>, 5 weeks ago

FEP-633c §5.3 andersom: een ward vraagt eerst of het iemand mag volgen

Uitgaande follows gingen ongehinderd de deur uit; de guardians kregen achteraf
een bericht (1a2f206). Dat is informeren, niet gaten — de deur staat al open als
het bericht aankomt. Bead shaer-p729, ontwerp in
docs/ward-outbound-follows-design.md.

De regel: per geval goedkeuring, met twee uitzonderingen die geen gunst zijn
maar dezelfde beslissing die al genomen is. Je eigen guardian volgen is geen
vraag. En iemand die de ward al volgt DOOR DE POORT heen is door een guardian
bij naam goedgekeurd; die vraag nog eens stellen leert mensen alleen om de vraag
niet meer te lezen.

Daarvoor moet je weten wie er door de poort kwam, dus ap_followers krijgt
gate_approved, gezet bij acceptGatedFollow. Iedereen die al volgde toen die
kolom erbij kwam wordt eenmalig gegrandfatherd (Barts besluit): exact vanaf nu,
in plaats van met terugwerkende kracht wantrouwig tegen wat er al was.

Eigen tabel, want ap_pending_follows is gesleuteld met de ward als DOEL. Eigen
wachtrij (outgoingFollows), want een guardian moet "iemand wil je ward volgen"
kunnen onderscheiden van "je ward wil iemand volgen" — de AS2-test ving netjes
dat de nieuwe term aangemeld moest worden. En een tegengehouden follow reist als
derde uitkomst naar de app (state: awaiting_guardian), zodat Shaer "wacht op
toestemming" kan tonen in plaats van een tegel die er al volgend uitziet.

Co-Authored-By: Claude Opus 5 <claude@…>

  • Property mode set to 100644
File size: 40.9 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 db.exec(`CREATE TABLE IF NOT EXISTS ap_outgoing_follow_approvals (
85 follow_id TEXT NOT NULL,
86 guardian_uri TEXT NOT NULL,
87 decision TEXT NOT NULL,
88 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
89 PRIMARY KEY (follow_id, guardian_uri)
90 )`);
91 // Cross-instance follow-approval (modelled on the guardian offer): the
92 // guardian-side COPY of a gated follow on a REMOTE ward, forwarded here by
93 // the ward's server as an Offer(Follow). The decision is sent back to the
94 // ward's inbox. (Local wards use ap_pending_follows directly.)
95 db.exec(`CREATE TABLE IF NOT EXISTS ap_follow_reviews (
96 id TEXT NOT NULL,
97 guardian_slug TEXT NOT NULL,
98 ward_uri TEXT NOT NULL,
99 ward_inbox TEXT,
100 follower_uri TEXT NOT NULL,
101 follower_handle TEXT,
102 follower_icon TEXT,
103 follow_json TEXT,
104 status TEXT DEFAULT 'pending',
105 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
106 PRIMARY KEY (guardian_slug, id)
107 )`);
108 ensureColumn('sites', 'profile_photo', 'TEXT');
109 ensureColumn('audio_tracks', 'cover_url', 'TEXT');
110 ensureColumn('audio_tracks', 'album', 'TEXT');
111 ensureColumn('users', 'reset_token', 'TEXT');
112 ensureColumn('users', 'reset_token_expires', 'DATETIME');
113 // Google OAuth: link a Google account to a user (login via Google).
114 ensureColumn('users', 'google_sub', 'TEXT');
115 // Read-only/viewer account: can view everything but make no changes.
116 ensureColumn('users', 'readonly', 'INTEGER DEFAULT 0');
117 // Personal interface language (nl|en|de). Null = follow the default (site/env/browser).
118 ensureColumn('users', 'lang', 'TEXT');
119 // Site-level moderation toggle. 'trust' = auto-approve, 'moderate' = pending until reviewed.
120 // Circles: whether this site may appear in other sites' circles (surfacing opt-out).
121 ensureColumn('sites', 'allow_circle', 'INTEGER DEFAULT 1');
122
123 // One EXPLICIT primary/main site (= the company/label site in hub mode,
124 // the only site in solo) instead of the fragile "oldest = main" convention
125 // that was duplicated in 4 places. Backfill: mark the oldest if no primary
126 // site exists yet, so existing behaviour is preserved exactly.
127 ensureColumn('sites', 'is_primary', 'INTEGER DEFAULT 0');
128 try {
129 const hasPrimary = db.prepare('SELECT 1 FROM sites WHERE is_primary = 1 LIMIT 1').get();
130 if (!hasPrimary) {
131 const oldest = db.prepare('SELECT id FROM sites ORDER BY created_at ASC LIMIT 1').get();
132 if (oldest) db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(oldest.id);
133 }
134 } catch (e) { /* sites table still empty/absent on fresh init — ensurePrimarySite handles it */ }
135
136 // v9 audit additions —————————————————————————————————————————
137 // SEO/social columns the v9 template uses (most live in 001-init.sql already
138 // for fresh DBs but ensureColumn is idempotent for existing DBs).
139 ensureColumn('sites', 'twitter', 'TEXT'); // @handle (with @)
140 ensureColumn('sites', 'schema_type', "TEXT DEFAULT 'Person'"); // Person|Organization
141 ensureColumn('sites', 'publisher_name', 'TEXT');
142 ensureColumn('sites', 'publisher_url', 'TEXT');
143 ensureColumn('sites', 'publisher_logo', 'TEXT');
144 ensureColumn('sites', 'profile_enabled', 'INTEGER DEFAULT 1');
145 ensureColumn('sites', 'profile_name', 'TEXT'); // display name (falls back to title)
146 ensureColumn('sites', 'profile_bio', 'TEXT'); // short bio for header
147 ensureColumn('sites', 'profile_links', 'TEXT'); // JSON array [{platform, url}]
148 ensureColumn('sites', 'feed_view_default', "TEXT DEFAULT 'grid'"); // timeline | grid
149 ensureColumn('sites', 'feed_view_switch', 'INTEGER DEFAULT 1'); // show switcher
150 ensureColumn('sites', 'show_search', 'INTEGER DEFAULT 1');
151 ensureColumn('sites', 'show_archive_link', 'INTEGER DEFAULT 1');
152 // Gated feature (FEP-633c): may external (non-fediverse) embeds be shown to
153 // this account? NULL = auto, which means OFF for a ward and ON for anyone
154 // else. The guardians flip it; the gate itself lives server-side, so a ward
155 // never even receives the thumbnail it is not allowed to see.
156 ensureColumn('sites', 'external_embeds', 'INTEGER');
157 // The heavier sibling (FEP-633c 5.6): may a player from outside this app run
158 // INSIDE it? A preview is a picture; playback hands the screen to a third
159 // party's engine, recommendations and all. Two settings, so the guardians can
160 // allow the one without the other. NULL = auto, which means off for a ward.
161 ensureColumn('sites', 'external_playback', 'INTEGER');
162 ensureColumn('sites', 'og_theme', 'TEXT'); // OG share-card variant: NULL=auto (follow site theme) | 'light' | 'dark'
163 // FEP-7628: former identities this actor claims (JSON array of actor URIs).
164 // Publishing them as alsoKnownAs is what lets the OLD server approve a Move
165 // of its followers to this account — the claim must be visible on OUR side.
166 ensureColumn('sites', 'ap_aliases', 'TEXT');
167 ensureColumn('sites', 'moved_to', 'TEXT'); // FEP-7628 slice 2: waarheen dit account vertrok
168
169 // Per-post noindex + type
170 ensureColumn('posts', 'noindex', 'INTEGER DEFAULT 0');
171 ensureColumn('posts', 'publish_at', 'DATETIME'); // release planning (premium #3): scheduled go-live
172 ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0'); // fan-only preview (premium #3)
173 ensureColumn('posts', 'nsfw', 'INTEGER DEFAULT 0'); // sensitive content → blur + click-to-reveal; fediverse sensitive
174 ensureColumn('posts', 'cover_video_url', 'TEXT'); // muted loop MP4 for an animated cover (Safari-smooth)
175 ensureColumn('posts', 'cover_alt', 'TEXT'); // alt text / description for the cover (a11y → AS2 attachment `name`)
176 ensureColumn('posts', 'language', 'TEXT'); // BCP-47 content language → federates as AS2 contentMap (Mastodon language filter/translate)
177 ensureColumn('posts', 'content_warning', 'TEXT'); // custom CW label (empty = default "Gevoelige inhoud")
178 ensureColumn('posts', 'type', "TEXT DEFAULT 'post'"); // post | foto | video | audio
179 ensureColumn('posts', 'poll_json', 'TEXT'); // a poll WE host → federates as AS2 Question: {multiple,options[{name}],endTime,closed}
180
181 // Statistics (premium module) — bare counters, cookie-free.
182 ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0'); // views per post
183 ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0'); // plays per track
184 ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download-for-email (premium #2)
185 ensureColumn('audio_tracks', 'credit', 'TEXT'); // owner/credit (copyright holder)
186 ensureColumn('audio_tracks', 'license', 'TEXT'); // license (e.g. "CC BY 4.0", "All rights reserved")
187 ensureColumn('audio_tracks', 'link_spotify', 'TEXT'); // "open in" links per track
188 ensureColumn('audio_tracks', 'link_youtube', 'TEXT');
189 ensureColumn('audio_tracks', 'link_soundcloud', 'TEXT');
190 // Per-track: federate the actual audio file as an AS2 Audio attachment so it plays inline
191 // in EVERY fediverse client (incl. the Mastodon apps). Default 0 = gated (web player only,
192 // file not exposed). Opt-in 1 = the file is served ungated + shared on the fediverse.
193 ensureColumn('audio_tracks', 'fedi_open', 'INTEGER DEFAULT 0');
194
195 // Playlists (v9 feature) — first-class entity. CREATE IF NOT EXISTS is
196 // idempotent so it's safe to run on every boot regardless of DB age.
197 db.exec(`
198 CREATE TABLE IF NOT EXISTS playlists (
199 id TEXT PRIMARY KEY,
200 site_id TEXT NOT NULL,
201 title TEXT NOT NULL,
202 artist TEXT,
203 year INTEGER,
204 cover_url TEXT,
205 kind TEXT DEFAULT 'album',
206 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
207 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
208 FOREIGN KEY (site_id) REFERENCES sites(id)
209 );
210 CREATE TABLE IF NOT EXISTS playlist_tracks (
211 playlist_id TEXT NOT NULL,
212 track_id TEXT NOT NULL,
213 position INTEGER NOT NULL DEFAULT 0,
214 PRIMARY KEY (playlist_id, track_id),
215 FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
216 FOREIGN KEY (track_id) REFERENCES audio_tracks(id) ON DELETE CASCADE
217 );
218 CREATE INDEX IF NOT EXISTS idx_playlist_tracks_pos
219 ON playlist_tracks(playlist_id, position);
220 `);
221
222 // Global app settings (key/value singleton). Includes the tenancy mode
223 // (solo = one site, hub = company site + /user/). Default = solo.
224 db.exec(`
225 CREATE TABLE IF NOT EXISTS app_settings (
226 key TEXT PRIMARY KEY,
227 value TEXT,
228 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
229 );
230 `);
231 db.prepare("INSERT OR IGNORE INTO app_settings (key, value) VALUES ('tenancy', 'solo')").run();
232
233 // ── Statistics (premium) — cookie-free ──────────────────────
234 // stat_daily: pageview count per day per site (bare counter).
235 // stat_visitor_day: one row per UNIQUE visitor hash per day per site
236 // (sha256 of IP+UA+day-salt; the salt rotates daily and is never stored
237 // → no persistent identifier, no cookie, no consent required).
238 db.exec(`
239 CREATE TABLE IF NOT EXISTS stat_daily (
240 site_id TEXT NOT NULL,
241 day TEXT NOT NULL,
242 pageviews INTEGER NOT NULL DEFAULT 0,
243 PRIMARY KEY (site_id, day)
244 );
245 CREATE TABLE IF NOT EXISTS stat_visitor_day (
246 site_id TEXT NOT NULL,
247 day TEXT NOT NULL,
248 visitor_hash TEXT NOT NULL,
249 PRIMARY KEY (site_id, day, visitor_hash)
250 );
251 CREATE INDEX IF NOT EXISTS idx_stat_visitor_day ON stat_visitor_day(site_id, day);
252 CREATE TABLE IF NOT EXISTS stat_referrer (
253 site_id TEXT NOT NULL,
254 host TEXT NOT NULL,
255 count INTEGER NOT NULL DEFAULT 0,
256 PRIMARY KEY (site_id, host)
257 );
258 `);
259
260 // Newsletter / mailing list (premium). Subscribers per site; double opt-in when SMTP
261 // is configured (status 'pending' until confirmed), otherwise single opt-in ('confirmed').
262 // 'unsub' = unsubscribed. token = confirm/unsubscribe key (used in email links).
263 db.exec(`
264 CREATE TABLE IF NOT EXISTS subscribers (
265 id TEXT PRIMARY KEY,
266 site_id TEXT NOT NULL,
267 email TEXT NOT NULL,
268 status TEXT NOT NULL DEFAULT 'pending',
269 source TEXT DEFAULT 'widget',
270 token TEXT NOT NULL,
271 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
272 confirmed_at DATETIME,
273 UNIQUE(site_id, email)
274 );
275 CREATE INDEX IF NOT EXISTS idx_subscribers_site_status ON subscribers(site_id, status);
276 `);
277
278 // Sent newsletters (history + counts).
279 db.exec(`
280 CREATE TABLE IF NOT EXISTS newsletters (
281 id TEXT PRIMARY KEY,
282 site_id TEXT NOT NULL,
283 subject TEXT NOT NULL,
284 body TEXT NOT NULL,
285 sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
286 recipient_count INTEGER DEFAULT 0
287 );
288 `);
289
290 // Show agenda (premium #8): tour dates / gigs per site.
291 db.exec(`
292 CREATE TABLE IF NOT EXISTS shows (
293 id TEXT PRIMARY KEY,
294 site_id TEXT NOT NULL,
295 date TEXT NOT NULL,
296 time TEXT,
297 city TEXT NOT NULL,
298 venue TEXT,
299 country TEXT,
300 ticket_url TEXT,
301 notes TEXT,
302 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
303 );
304 CREATE INDEX IF NOT EXISTS idx_shows_site_date ON shows(site_id, date);
305 `);
306
307 // Link-in-bio click statistics (premium #6). One counter per (site, url); the
308 // link-in-bio page links via /links/go/:i which counts the click and redirects.
309 db.exec(`
310 CREATE TABLE IF NOT EXISTS link_clicks (
311 site_id TEXT NOT NULL,
312 url TEXT NOT NULL,
313 clicks INTEGER DEFAULT 0,
314 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
315 PRIMARY KEY (site_id, url)
316 );
317 `);
318
319
320 // ── ActivityPub (fediverse bridge) ──────────────────────────
321 // RSA keypair per actor (Mastodon-compatible HTTP Signatures; separate from
322 // the Cirkels Ed25519 keys). ap_followers = remote AP actors following us.
323 db.exec(`
324 CREATE TABLE IF NOT EXISTS ap_keys (
325 slug TEXT PRIMARY KEY,
326 public_pem TEXT NOT NULL,
327 private_pem TEXT NOT NULL,
328 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
329 );
330 CREATE TABLE IF NOT EXISTS ap_followers (
331 id INTEGER PRIMARY KEY AUTOINCREMENT,
332 slug TEXT NOT NULL,
333 actor_uri TEXT NOT NULL,
334 inbox TEXT,
335 shared_inbox TEXT,
336 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
337 UNIQUE(slug, actor_uri)
338 );
339 CREATE INDEX IF NOT EXISTS idx_ap_followers_slug ON ap_followers(slug);
340 CREATE TABLE IF NOT EXISTS ap_interactions (
341 id INTEGER PRIMARY KEY AUTOINCREMENT,
342 kind TEXT NOT NULL, -- 'reply' | 'like' | 'announce'
343 post_id TEXT NOT NULL,
344 object_uri TEXT NOT NULL DEFAULT '', -- remote note id (reply) or '' (like/announce)
345 actor_uri TEXT NOT NULL,
346 actor_name TEXT,
347 actor_handle TEXT,
348 actor_url TEXT,
349 actor_icon TEXT,
350 content TEXT, -- sanitized HTML (reply)
351 published TEXT,
352 parent_uri TEXT, -- the note this reply replies to (for nesting)
353 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
354 UNIQUE(kind, post_id, actor_uri, object_uri)
355 );
356 CREATE INDEX IF NOT EXISTS idx_ap_inter_post ON ap_interactions(post_id, kind);
357 -- Moderation tombstones: object URIs the site owner removed. Checked at ingest
358 -- (handleInbox) AND by the thread-crawler, so a removed reply never comes back
359 -- via thread-filling. Private notes can't be flagged via authorize_interaction
360 -- (their fetch 401s), so owner moderation acts on the locally stored copy.
361 CREATE TABLE IF NOT EXISTS ap_rejected_objects (
362 object_uri TEXT PRIMARY KEY,
363 post_id TEXT,
364 reason TEXT,
365 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
366 );
367 -- ActivityPub C2S (client-to-server): OAuth 2.0 for native/web clients (Shaer).
368 -- Public clients + PKCE (RFC 8252); tokens stored hashed; token is per user+site.
369 CREATE TABLE IF NOT EXISTS oauth_clients (
370 client_id TEXT PRIMARY KEY,
371 client_name TEXT,
372 redirect_uris TEXT NOT NULL, -- JSON array
373 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
374 );
375 CREATE TABLE IF NOT EXISTS oauth_codes (
376 code TEXT PRIMARY KEY,
377 client_id TEXT NOT NULL,
378 user_id TEXT NOT NULL,
379 site_slug TEXT NOT NULL,
380 redirect_uri TEXT NOT NULL,
381 code_challenge TEXT, -- PKCE S256 (verplicht voor public clients)
382 scope TEXT,
383 expires_at DATETIME NOT NULL,
384 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
385 );
386 CREATE TABLE IF NOT EXISTS oauth_tokens (
387 token_hash TEXT PRIMARY KEY, -- sha256(bearer); het token zelf slaan we nooit op
388 client_id TEXT NOT NULL,
389 user_id TEXT NOT NULL,
390 site_slug TEXT NOT NULL,
391 scope TEXT,
392 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
393 last_used_at DATETIME
394 );
395 -- Paid posts (klonkt-demo-aki): the site owner's own Patreon campaign.
396 -- Secrets are encrypted at rest (CryptoBox). Never reuses the instance-level
397 -- patreon_* settings, which are Klonkt Premium's separate license flow.
398 CREATE TABLE IF NOT EXISTS paid_patreon (
399 site_id TEXT PRIMARY KEY,
400 client_id TEXT,
401 client_secret_enc TEXT,
402 campaign_id TEXT,
403 access_token_enc TEXT,
404 refresh_token_enc TEXT,
405 token_exp INTEGER, -- unix seconds
406 default_min_cents INTEGER DEFAULT 0,
407 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
408 );
409 -- One row per passkey. NO patron identity is stored (design decision):
410 -- {passkey, site, proven cents, expiry}. Not traceable to a person.
411 CREATE TABLE IF NOT EXISTS paid_entitlements (
412 credential_id TEXT PRIMARY KEY, -- WebAuthn credential id (opaque, base64url)
413 site_id TEXT NOT NULL,
414 public_key TEXT NOT NULL, -- COSE public key, base64url
415 counter INTEGER DEFAULT 0,
416 transports TEXT,
417 min_cents INTEGER DEFAULT 0, -- the amount proven at link time
418 expires_at INTEGER NOT NULL, -- unix seconds; re-link after
419 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
420 );
421 -- Web Push (docs/webpush-design.md): one row per browser/device the owner
422 -- enabled notifications on. Payloads are encrypted to p256dh/auth (RFC 8291).
423 CREATE TABLE IF NOT EXISTS push_subscriptions (
424 endpoint TEXT PRIMARY KEY, -- push-service URL for this device
425 user_id TEXT NOT NULL,
426 p256dh TEXT NOT NULL, -- client public key
427 auth TEXT NOT NULL, -- client auth secret
428 alert_types TEXT, -- JSON {follow,reply,like,boost,dm}
429 ua_label TEXT,
430 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
431 last_ok_at DATETIME
432 );
433 CREATE TABLE IF NOT EXISTS ap_outbox (
434 id TEXT PRIMARY KEY, -- note path segment (uuid) → /ap/notes/<id>
435 site_slug TEXT NOT NULL,
436 post_id TEXT NOT NULL,
437 post_slug TEXT,
438 in_reply_to TEXT, -- remote status uri we reply to
439 to_actor TEXT, -- remote actor uri (mentioned)
440 to_handle TEXT,
441 content TEXT NOT NULL, -- sanitized HTML of our reply
442 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
443 );
444 CREATE INDEX IF NOT EXISTS idx_ap_outbox_post ON ap_outbox(post_id);
445 -- Your like/boost state on a REMOTE post (the interact page), so those become toggles.
446 CREATE TABLE IF NOT EXISTS ap_my_reactions (
447 site_slug TEXT NOT NULL,
448 target_uri TEXT NOT NULL,
449 kind TEXT NOT NULL, -- 'like' | 'boost'
450 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
451 UNIQUE(site_slug, target_uri, kind)
452 );
453 `);
454 ensureColumn('ap_interactions', 'parent_uri', 'TEXT'); // nesting (existing DBs)
455 ensureColumn('ap_interactions', 'acted_boost', 'INTEGER DEFAULT 0'); // owner boosted this comment (🔁) → can undo
456 ensureColumn('ap_interactions', 'acted_like', 'INTEGER DEFAULT 0'); // owner liked this comment (⭐) → can undo
457
458 // Fediverse CLIENT: accounts WE follow (outbound) + the home timeline of their posts.
459 db.exec(`
460 CREATE TABLE IF NOT EXISTS ap_following (
461 id INTEGER PRIMARY KEY AUTOINCREMENT,
462 slug TEXT NOT NULL, -- our site that follows
463 actor_uri TEXT NOT NULL, -- the followed account's actor id
464 handle TEXT, name TEXT, icon TEXT, url TEXT,
465 inbox TEXT, -- their inbox (for Create delivery / Undo)
466 follow_id TEXT, -- the Follow activity id we sent (Accept matching)
467 status TEXT DEFAULT 'pending', -- pending | accepted
468 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
469 UNIQUE(slug, actor_uri)
470 );
471 CREATE TABLE IF NOT EXISTS ap_timeline (
472 id TEXT NOT NULL, -- the remote note's AP id
473 slug TEXT NOT NULL, -- whose home timeline (our site)
474 author_uri TEXT, author_name TEXT, author_handle TEXT, author_icon TEXT, author_url TEXT,
475 content TEXT, url TEXT, published TEXT, media_json TEXT,
476 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
477 UNIQUE(slug, id)
478 );
479 CREATE INDEX IF NOT EXISTS idx_ap_timeline_slug ON ap_timeline(slug, published);
480 CREATE TABLE IF NOT EXISTS ap_blocks (
481 id INTEGER PRIMARY KEY AUTOINCREMENT,
482 slug TEXT NOT NULL, -- our site that set the block
483 target TEXT NOT NULL, -- actor URI (actor block) or domain (domain block)
484 kind TEXT NOT NULL, -- 'actor' | 'domain'
485 label TEXT, -- display (@handle or domain)
486 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
487 UNIQUE(slug, target)
488 );
489 CREATE INDEX IF NOT EXISTS idx_ap_blocks_target ON ap_blocks(target);
490 -- Committed guardian ↔ ward relations, one row per local side. role
491 -- 'ward' = the local slug is a ward of other_uri; 'guardian' = the local
492 -- slug guards other_uri. status is always 'accepted' here now: PENDING
493 -- offers live in ap_guardian_offers below (FEP-633c multi-party handshake).
494 CREATE TABLE IF NOT EXISTS ap_guardianships (
495 id INTEGER PRIMARY KEY AUTOINCREMENT,
496 slug TEXT NOT NULL, -- our local site in this relation (guardianship module)
497 role TEXT NOT NULL, -- 'guardian' (slug guards other) | 'ward' (other guards slug)
498 other_uri TEXT NOT NULL, -- the counterpart actor URI (local or remote)
499 other_handle TEXT, -- cached @user@host for display
500 status TEXT NOT NULL, -- 'offered' (legacy) | 'accepted'
501 offer_id TEXT, -- the Offer activity id (FEP-633c section 3)
502 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
503 UNIQUE(slug, role, other_uri)
504 );
505 CREATE INDEX IF NOT EXISTS idx_ap_guardianships_slug ON ap_guardianships(slug, role, status);
506 -- The multi-party handshake (FEP-633c section 3), one row per offer this
507 -- instance is a party to. Mirrors the Shaer test daemon's Handshake:
508 -- accepts accumulate in ap_guardian_offer_accepts, and the offer commits
509 -- only when the candidate returns the handle after ward + candidate + at
510 -- least one existing guardian have accepted.
511 CREATE TABLE IF NOT EXISTS ap_guardian_offers (
512 offer_id TEXT NOT NULL, -- the Offer activity id (minted by the candidate)
513 slug TEXT NOT NULL, -- the local site tracking this handshake (each party keeps its own copy)
514 ward_uri TEXT NOT NULL, -- the ward-to-be
515 candidate_uri TEXT NOT NULL, -- the guardian-candidate (fixed initiator)
516 existing_guardians TEXT NOT NULL DEFAULT '[]', -- JSON array of the ward's current guardian URIs
517 status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'committed' | 'void'
518 handle TEXT, -- the escalation handle returned at commit (section 6)
519 ward_handle TEXT, -- cached @ward@host for display
520 candidate_handle TEXT, -- cached @candidate@host for display
521 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
522 PRIMARY KEY (slug, offer_id)
523 );
524 CREATE INDEX IF NOT EXISTS idx_ap_guardian_offers_slug ON ap_guardian_offers(slug, status);
525 CREATE TABLE IF NOT EXISTS ap_guardian_offer_accepts (
526 offer_id TEXT NOT NULL, -- FK to ap_guardian_offers
527 slug TEXT NOT NULL, -- the local site's copy of the tally
528 party_uri TEXT NOT NULL, -- the party who accepted (ward | candidate | an existing guardian)
529 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
530 PRIMARY KEY (slug, offer_id, party_uri)
531 );
532 -- FEP-633c §5.6: a gated setting a ward's guardians decide together, which
533 -- has to work when they live on other servers (the ordinary case). One row
534 -- per guardian answer; the ward's server tallies (§3.5) and enforces.
535 -- The proposals themselves, so an Accept that only references the offer
536 -- id can still be resolved to "which feature, which value".
537 CREATE TABLE IF NOT EXISTS ap_gated_offers (
538 offer_id TEXT PRIMARY KEY,
539 slug TEXT NOT NULL, -- the ward, on this server
540 feature TEXT NOT NULL,
541 value INTEGER NOT NULL,
542 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
543 );
544 -- The guardian-side COPY of a gated-setting proposal on a ward, forwarded
545 -- here by the WARD's server (the same shape ap_follow_reviews has for a
546 -- gated follow). Without it a guardian on another server never learns a
547 -- proposal exists and can never answer it, so a threshold of two can never
548 -- be reached and every proposal expires. The answer goes back to the
549 -- ward's inbox, which tallies (5.6).
550 CREATE TABLE IF NOT EXISTS ap_gated_reviews (
551 id TEXT NOT NULL, -- the offer id, as minted by the proposer
552 guardian_slug TEXT NOT NULL, -- us, one of the ward's guardians
553 ward_uri TEXT NOT NULL,
554 ward_inbox TEXT,
555 proposer TEXT, -- who opened it (for display)
556 feature TEXT NOT NULL,
557 value INTEGER NOT NULL,
558 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
559 PRIMARY KEY (guardian_slug, id)
560 );
561 -- The PROPOSER's own record of a gated proposal it sent (5.6). Without it
562 -- a guardian clicks "propose", the ward's server tallies somewhere else,
563 -- and the proposer has nowhere to even see that something is running: the
564 -- status was a button caption that did not survive a page refresh. The
565 -- ward's server answers the Offer once the decision settles (Accept when
566 -- it settled on the proposed value, Reject otherwise); that answer lands
567 -- in status. An open row past the decision window renders as expired.
568 CREATE TABLE IF NOT EXISTS ap_gated_sent (
569 offer_id TEXT PRIMARY KEY, -- as minted by us, the proposer
570 guardian_slug TEXT NOT NULL, -- us
571 ward_uri TEXT NOT NULL,
572 feature TEXT NOT NULL,
573 value INTEGER NOT NULL, -- what we proposed
574 status TEXT NOT NULL DEFAULT 'open', -- 'open' | 'accepted' | 'rejected'
575 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
576 );
577 CREATE TABLE IF NOT EXISTS ap_gated_votes (
578 slug TEXT NOT NULL, -- the WARD, on this server
579 feature TEXT NOT NULL, -- e.g. 'shaer:externalEmbeds'
580 guardian_uri TEXT NOT NULL, -- who answered (must be a committed guardian)
581 value INTEGER NOT NULL, -- the value they voted for (0/1)
582 opened_at DATETIME NOT NULL, -- when this decision opened (the window start)
583 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
584 PRIMARY KEY (slug, feature, guardian_uri)
585 );
586 -- Guardian availability (FEP-633c 3.6): one guardian's attention as seen
587 -- from one ward on this server. Never public; the ward reads it via the
588 -- owner-only guardians queue. One rule above all: one answer restores
589 -- everything, so every row here is one answer away from disappearing.
590 CREATE TABLE IF NOT EXISTS ap_guardian_attention (
591 ward_slug TEXT NOT NULL,
592 guardian_uri TEXT NOT NULL,
593 state TEXT NOT NULL DEFAULT 'active', -- 'active' | 'away' | 'dormant'
594 away_until INTEGER, -- epoch ms while declared away
595 PRIMARY KEY (ward_slug, guardian_uri)
596 );
597 -- The ONLY admissible dormancy evidence (3.6.2): directly addressed
598 -- requests that went unanswered. Calendar time alone never counts.
599 CREATE TABLE IF NOT EXISTS ap_attention_requests (
600 ward_slug TEXT NOT NULL,
601 guardian_uri TEXT NOT NULL,
602 request_id TEXT NOT NULL,
603 asked_at INTEGER NOT NULL, -- epoch ms
604 PRIMARY KEY (ward_slug, guardian_uri, request_id)
605 );
606 -- A lapse (3.6.3): the available co-guardians deciding to release a
607 -- dormant one. Irreversible, so the window always runs in full; any sign
608 -- of life from the target cancels it outright.
609 CREATE TABLE IF NOT EXISTS ap_lapses (
610 id TEXT PRIMARY KEY,
611 ward_slug TEXT NOT NULL,
612 ward_uri TEXT NOT NULL,
613 target_uri TEXT NOT NULL,
614 opened_by TEXT NOT NULL,
615 set_json TEXT NOT NULL, -- the available set at open, target excluded
616 accepts_json TEXT NOT NULL DEFAULT '[]',
617 rejects_json TEXT NOT NULL DEFAULT '[]',
618 opened_at INTEGER NOT NULL, -- epoch ms
619 window_ms INTEGER NOT NULL,
620 cancelled INTEGER NOT NULL DEFAULT 0,
621 applied INTEGER NOT NULL DEFAULT 0,
622 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
623 );
624 CREATE TABLE IF NOT EXISTS ap_delivery (
625 id INTEGER PRIMARY KEY AUTOINCREMENT,
626 slug TEXT NOT NULL, -- our site/actor that signs the delivery
627 inbox TEXT NOT NULL, -- recipient inbox URL
628 body TEXT NOT NULL, -- the activity JSON to POST
629 attempts INTEGER NOT NULL DEFAULT 0,
630 next_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
631 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
632 );
633 CREATE INDEX IF NOT EXISTS idx_ap_delivery_due ON ap_delivery(next_at);
634 CREATE TABLE IF NOT EXISTS poll_votes (
635 id INTEGER PRIMARY KEY AUTOINCREMENT,
636 post_id INTEGER NOT NULL, -- our local poll post (posts.id)
637 actor_uri TEXT NOT NULL, -- the remote voter's AP actor URI
638 choice TEXT NOT NULL, -- the chosen option's name (matches poll_json options[].name)
639 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
640 UNIQUE(post_id, actor_uri, choice)
641 );
642 CREATE INDEX IF NOT EXISTS idx_poll_votes_post ON poll_votes(post_id);
643 CREATE TABLE IF NOT EXISTS ap_mentions (
644 id INTEGER PRIMARY KEY AUTOINCREMENT,
645 slug TEXT NOT NULL, -- our mentioned site/actor
646 object_uri TEXT NOT NULL, -- the remote note that mentions us
647 note_url TEXT, -- its human URL (open/interact)
648 actor_uri TEXT, actor_name TEXT, actor_handle TEXT, actor_icon TEXT, actor_url TEXT,
649 content TEXT, -- sanitized HTML snippet of the mentioning note
650 published TEXT,
651 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
652 UNIQUE(slug, object_uri)
653 );
654 CREATE INDEX IF NOT EXISTS idx_ap_mentions_slug ON ap_mentions(slug, created_at);
655 CREATE TABLE IF NOT EXISTS ap_reports (
656 id INTEGER PRIMARY KEY AUTOINCREMENT,
657 slug TEXT NOT NULL, -- our site the report is about (its owner moderates)
658 actor_uri TEXT, -- the reporter's actor URI
659 actor_name TEXT, actor_handle TEXT, actor_icon TEXT,
660 content TEXT, -- the reason (plain text)
661 objects TEXT, -- JSON array of reported object URIs (our actor + statuses)
662 seen INTEGER DEFAULT 0,
663 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
664 );
665 CREATE INDEX IF NOT EXISTS idx_ap_reports_slug ON ap_reports(slug, created_at);
666 `);
667 // "Feature" a followed account: its posts show in the local Cirkel.
668 ensureColumn('ap_following', 'auto_boost', 'INTEGER DEFAULT 0');
669 // A timeline post you boosted (🔁) — also shown in the Cirkel (mixed by date).
670 ensureColumn('ap_timeline', 'boosted', 'INTEGER DEFAULT 0');
671 ensureColumn('ap_timeline', 'liked', 'INTEGER DEFAULT 0'); // a feed post you liked (⭐) → toggle
672 ensureColumn('ap_timeline', 'nsfw', 'INTEGER DEFAULT 0'); // remote sensitive post → blur in the Cirkel
673 ensureColumn('ap_timeline', 'cw', 'TEXT'); // remote content-warning text
674 ensureColumn('ap_timeline', 'emoji_json', 'TEXT'); // FEP-9098 custom emoji Emoji tags from the inbound note, served back as `tag`
675 ensureColumn('ap_timeline', 'link_json', 'TEXT'); // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
676 ensureColumn('ap_timeline', 'quote_json', 'TEXT'); // FEP-044f resolved quoted-post snapshot (author + content), for the embedded quote card
677 // FEP-044f: the fediverse object THIS post quotes, resolved once at publish
678 // time so buildNote (sync, also used by the outbox) needs no network.
679 ensureColumn('posts', 'quote_uri', 'TEXT'); // the quoted object's id
680 ensureColumn('posts', 'quote_actor', 'TEXT'); // its author, so we can address them
681 ensureColumn('ap_timeline', 'embed_json', 'TEXT'); // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
682 ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the author's display name (shaer:author.emojis)
683 ensureColumn('ap_timeline', 'reblog_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the booster's display name (shaer:booster.emojis)
684 ensureColumn('ap_timeline', 'reblog_name', 'TEXT'); // a followed account boosted this → "X boosted"
685 ensureColumn('ap_timeline', 'reblog_handle', 'TEXT'); // the booster's @handle
686 ensureColumn('ap_timeline', 'reblog_icon', 'TEXT'); // the booster's avatar
687 ensureColumn('ap_timeline', 'poll_json', 'TEXT'); // a Question (poll): {multiple,options[{name,count}],endTime,closed,voters,voted}
688
689 // Delivery health per follower → surface dead accounts for manual cleanup.
690 ensureColumn('ap_followers', 'last_delivery_at', 'DATETIME'); // last SUCCESSFUL delivery to this follower's inbox
691 ensureColumn('ap_followers', 'last_error_at', 'DATETIME'); // last time a delivery to it gave up (max retries)
692
693 // ActivityPub `source` model: content_rendered = baked display HTML (#hashtags / URLs /
694 // @mentions linkified once at save). `content` stays the raw source used for editing and
695 // re-rendering. NULL on old posts → the render route bakes on the fly as a fallback.
696 ensureColumn('posts', 'content_rendered', 'TEXT');
697
698 // AP addressing of an incoming interaction: 'public' | 'unlisted' | 'followers' | 'direct',
699 // derived from the note's to/cc at ingest. The public post page only renders public/unlisted
700 // replies; followers/direct replies surface in notifications (and later Messages) with post
701 // context instead. Existing rows default to 'public' (historically almost all were).
702 ensureColumn('ap_interactions', 'visibility', "TEXT DEFAULT 'public'");
703 ensureColumn('ap_interactions', 'emoji_json', 'TEXT'); // FEP-9098 custom emojis in a reply's content (messages + thread)
704 ensureColumn('ap_interactions', 'actor_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the reply author's display name
705 // Rich replies: the reply's language (BCP47 code) → contentMap on the outgoing Note.
706 ensureColumn('ap_outbox', 'language', 'TEXT');
707 // Rich replies: JSON array [{url, mediaType, name}] → `attachment` on the Note.
708 ensureColumn('ap_outbox', 'attachments', 'TEXT');
709 ensureColumn('posts', 'ap_visibility', 'TEXT'); // public|quiet|friends|direct (C2S addressing, shaer-60b)
710 ensureColumn('posts', 'paid', 'INTEGER DEFAULT 0'); // paid post (klonkt-demo-aki)
711 ensureColumn('posts', 'paid_min_cents', 'INTEGER'); // required support; null = owner default
712 ensureColumn('paid_patreon', 'patreon_url', 'TEXT'); // owner's public Patreon page → "Word supporter" link (klonkt-demo-aki)
713 ensureColumn('ap_outbox', 'visibility', 'TEXT'); // 'direct' = private mention, never Public (shaer-tqc)
714 ensureColumn('ap_outbox', 'to_actors', 'TEXT'); // JSON array of recipient actor URIs for direct notes
715 ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
716 ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
717 ensureColumn('ap_outbox', 'wave', 'INTEGER'); // FEP-633c shaer:wave (guardian -> ward nudge)
718 ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms)
719 ensureColumn('ap_gated_offers', 'proposer', 'TEXT'); // who proposed (5.6): the settle-answer goes back to them
720 // Did a guardian actually say yes to this follower? That is what makes the
721 // mutual shortcut sound: a ward may follow back anyone its guardians already
722 // admitted, without asking the same question twice. Only follows that came
723 // through the §5.3 gate carry the mark; a free actor's followers never faced
724 // one. Everyone already following when this column arrives is grandfathered
725 // in (Barts besluit, 3-8): the rule is exact from that moment forward rather
726 // than retroactively suspicious of relationships that already exist.
727 {
728 const had = db.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('ap_followers') WHERE name = 'gate_approved'").get();
729 ensureColumn('ap_followers', 'gate_approved', 'INTEGER DEFAULT 0');
730 if (!had || !had.n) {
731 try { db.prepare('UPDATE ap_followers SET gate_approved = 1').run(); } catch { /* table still empty on a fresh init */ }
732 }
733 }
734 ensureColumn('posts', 'c2s_attachments', 'TEXT'); // media a C2S Note carried (JSON [{url,mediaType,name}]); buildNote federates them
735 // 30-7: C2S posts briefly got their content media copied onto the cover,
736 // which showed the same video twice on the post page. Clear the covers that
737 // duplicate their own content; idempotent, only ever touches those.
738 try {
739 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();
740 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();
741 } catch { /* posts table absent on fresh init */ }
742 ensureColumn('ap_mentions', 'wave', 'INTEGER'); // inbound guardian wave
743 // FEP-633c §2.2: object hint that the author is a ward. Register-only for now;
744 // used later at reddings-boei / escalation routing.
745 ensureColumn('ap_timeline', 'has_guardians', 'INTEGER');
746 ensureColumn('ap_mentions', 'has_guardians', 'INTEGER');
747 // Berichten and de Krant render a post the same way, so a mention or a reply
748 // needs the same trimmings a timeline row already has: custom emojis, the
749 // media the note carried, and the quote / link-preview card.
750 ensureColumn('ap_mentions', 'emoji_json', 'TEXT'); // FEP-9098, in the content
751 ensureColumn('ap_mentions', 'actor_emoji_json', 'TEXT'); // FEP-9098, in the display name
752 ensureColumn('ap_mentions', 'media_json', 'TEXT');
753 ensureColumn('ap_mentions', 'quote_json', 'TEXT'); // FEP-044f quoted post
754 ensureColumn('ap_mentions', 'embed_json', 'TEXT'); // external link preview
755 ensureColumn('ap_interactions', 'media_json', 'TEXT');
756 ensureColumn('ap_interactions', 'quote_json', 'TEXT');
757 ensureColumn('ap_interactions', 'embed_json', 'TEXT');
758 ensureColumn('ap_followers', 'name', 'TEXT'); // cached display name (shaer-aa3)
759 ensureColumn('ap_followers', 'handle', 'TEXT'); // @user@host
760 ensureColumn('ap_followers', 'icon', 'TEXT'); // avatar URL
761}
762
763function ensureColumn(table, column, definition) {
764 try {
765 db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
766 console.log(`🔧 Added column ${table}.${column}`);
767 } catch (e) {
768 // "duplicate column name" → already there. Anything else, surface it.
769 if (!/duplicate column/i.test(e.message)) {
770 console.error(`❌ ensureColumn(${table}.${column}):`, e.message);
771 }
772 }
773}
774
775export default db;
Note: See TracBrowser for help on using the repository browser.