source: Klonkt/src/config/database.js@ 096f099

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

Wie je volgt gaat mee bij een verhuizing, als CSV

De exporter dekte posts, tracks en antwoorden, maar niet je relaties. Daarmee was
een verhuizing halfslachtig: de Move vertelt je VOLGERS waar je heen ging, maar
niets vertelde JOU wie jij volgde. Die lijst stond alleen in de database die je
achterlaat, en die negen mensen moest je uit je hoofd opnieuw opzoeken.

Kolomvorm van Mastodon, zodat de lijst beide kanten op werkt: hiervandaan naar een
Mastodon, en die van daar naar hier. Een uitwisselformaat dat alleen met zichzelf
praat is er geen.

Account address,Show boosts,Notify on new posts,Languages,Featured

De vijfde kolom is van ons; Mastodon leest de eerste vier en negeert de rest, dus
het blijft daar importeerbaar. Andersom werkt een bestand uit Mastodon hier ook,
en een kale lijst adressen zonder kopregel eveneens.

Over de naam: featured betekent in Klonkt al de collectie VASTGEZETTE POSTS
(toot:featured), en het commentaar bij auto_boost noemt dat al "feature a
followed account" (hun posts in jouw Cirkel). Drie dingen die featured heten is er
twee te veel, dus de kolom heet in de database highlighted: wie je op je eigen
profiel uitlicht. In de CSV blijft de kop Featured, want dat is het woord dat
mensen en Mastodon kennen. FEP-d471 (Endorsements) modelleert dit als een eigen
object voor webs of trust; dat is een zwaarder ding dan hier bedoeld, dus dit
blijft een lokale vlag.

importFollowing staat BEWUST buiten importArchive. Die draait in een transactie en
raakt alleen de database; opnieuw volgen stuurt activiteiten de deur uit en wacht
op het netwerk. Een trage peer zou de transactie openhouden en een rollback neemt
verzonden Follows niet terug. Het is ook een aparte stap omdat een archief inlezen
stil is en negen mensen aanschrijven niet; dat mag geen bijwerking zijn.

Changed files:
src/config/database.js

  • kolom ap_following.highlighted, met de drie betekenissen van "featured" uit elkaar gehouden

src/services/ArchiveExportService.js

  • followingCsv() en parseFollowingCsv(), met een CSV-lezer die geciteerde velden aankan
  • buildArchive schrijft following.csv en telt hem mee

src/services/ArchiveImportService.js

  • importFollowing(), met injecteerbare followFn zodat de test geen netwerk raakt

New file:
test/following-csv.test.js

  • 13 tests: de kopvorm, openstaande verzoeken die NIET meegaan, terugval op de actor-URI, de ronde export-naar-import, een Mastodon-bestand zonder onze kolom, een kale lijst, een geciteerd veld met een komma, jezelf overslaan, en dat een mislukte follow de rest niet stopt
  • plus de koppeling: dat buildArchive de CSV er echt in stopt en in het manifest zet. Die ontbrak eerst, en dat is precies het gat waar dit op zou stranden: losse functies groen, archief zonder volglijst

remarks: gebouwd op branch following-csv, afgetakt van github/main (0824351). Stond
eerst per ongeluk op ward-pwa; die branch is dev-only en mag niet naar main, en is
onaangeroerd gebleven. Suite 921 groen in UTC en Europe/Amsterdam. Nog te doen: een
UI om highlighted te zetten, en de knop die importFollowing aanroept met
followActor als followFn.

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

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