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

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

Lezen: snappen dat je laat lezen, en geen lege schermen meer

Barts meldingen van 20 augustus, in een stuk of zes rondes bijgesteld. De
uitkomst is korter dan de weg ernaartoe, en die weg is het waard om vast te
leggen omdat ik er twee keer overheen ben geschoten.

SNAPPEN. De regel is nu in drie zinnen te zeggen:

omhoog scrollen nooit snappen -- je zoekt iets terug en bepaalt zelf

waar je stopt

omlaag, ver weg vrij scrollen
omlaag, laatste
150px van een
bericht vangt, en je landt op de bovenkant van het volgende

Er is geen top-zone: de bovenkant van het bericht waar je IN zit is geen
doelwit meer. Dat was de oorspronkelijke klacht -- je las een paar regels
verder en proximity trok je terug naar de bovenrand, waardoor het leek of
het scrollen vastliep. De grens die wel vangt is de onderkant van je huidige
bericht, en dat is hetzelfde punt als de bovenkant van het volgende.

Onderweg twee doodlopende wegen, allebei door Robin gecorrigeerd:

  • Eerst zette ik de snap UIT voor berichten langer dan het scherm. Op een telefoon is bijna elk bericht net iets langer dan de viewport, dus daar verdween het snappen overal: op 812px hoog kregen berichten van 1009 en zelfs 839 al geen snap meer.
  • Toen probeerde ik de vangzone aan de VOET van het bericht te hangen ("ben je de reacties voorbij"). Bij een kort bericht staat die voet middenin de lege ruimte die min-height maakte, dus de zone begon op een willekeurige plek. Gelukkig ving Robin dat voordat het uitgerold stond.

Het moest dus een getal in pixels zijn, en 150 is dat getal.

GEEN LEGE SCHERMEN MEER. min-height:100svh rekte elk bericht op tot een vol
scherm. Bij een kort bericht gaf dat een halve pagina leegte onder "Replies
and reactions" -- je keek naar niets en moest doorscrollen om te ontdekken
dat er nog iets kwam. Weg dus: berichten hebben hun eigen hoogte (gemeten:
360/1009/839/250 waar het eerst 888/888/888/888 was) en de stroom is gewoon
bericht na bericht. Het snappen hangt aan scroll-snap-align en niet aan die
hoogte, dus dat werkt onveranderd.

VLOEIEND: scroll-behavior:smooth, want zonder dat springt een snap er in een
frame naartoe en voelt het als een hik. Uit bij prefers-reduced-motion.

TERUG NAAR BOVEN, rechtsonder, in twee stappen: eerst naar het begin van DIT
bericht, daarna pas naar de kop van de pagina. Die knop is de enige die naar
een bovenkant mag springen, en hij heeft het snappen daar niet voor nodig --
scrollIntoView stuurt zelf. Nagemeten, juist omdat omhoog-snappen uit staat:
1706 -> 1206, exact de bovenkant.

COVERS nemen de volle kolombreedte met de verhouding intact. De ronde ervoor
haalde het opblazen eruit maar liet kleine afbeeldingen op ware grootte
staan, en dat was als postzegel in een brede kolom te klein.

DE CIRKEL. Lezen blijft daar dood (berichten van anderen), maar een tijdlijn
werkt er juist bij uitstek. Die dwong ik eerst af zodra de site geen
Lezen-site was, en dan kon je in de cirkel geen Grid meer kiezen. Nu is Grid
de landing en staat Tijdlijn ernaast: bij feed_alt_view=timeline altijd, bij
auto op desktop. De cirkel krijgt zijn eigen geheugen terug, met een eigen
sleutel zodat een keuze daar je thuisweergave niet aanraakt.

style.css naar v77 en MOD_V naar 13.

Suite 1165/1165.

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

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