@acms-subir/core 0.3.10 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +2 -1
  4. package/cli/main.mjs +11 -3
  5. package/cli/migrate.mjs +102 -0
  6. package/cli/migrations.generated.mjs +1044 -0
  7. package/cli/preview.mjs +1 -1
  8. package/deploy/Dockerfile +5 -1
  9. package/deploy/compose.yml +21 -0
  10. package/docs/live-video.md +1 -1
  11. package/docs/theme-authoring.md +31 -0
  12. package/integration.mjs +39 -0
  13. package/migrate.mjs +5 -0
  14. package/package.json +9 -11
  15. package/src/actions/index.ts +5 -5
  16. package/src/components/DesignManager.tsx +29 -10
  17. package/src/components/EmailSettings.tsx +53 -11
  18. package/src/components/ThemeTransfer.tsx +211 -0
  19. package/src/components/auth/ClerkSession.astro +80 -0
  20. package/src/components/auth/ReverifyForms.astro +61 -0
  21. package/src/components/auth/clerk.ts +169 -0
  22. package/src/components/import/ImportExport.tsx +16 -6
  23. package/src/components/import/runner.ts +3 -0
  24. package/src/layouts/Admin.astro +40 -9
  25. package/src/layouts/Site.astro +6 -1
  26. package/src/lib/api.ts +6 -3
  27. package/src/lib/auth/accounts.ts +94 -0
  28. package/src/lib/auth/config.ts +22 -0
  29. package/src/lib/auth/login-limit.ts +15 -0
  30. package/src/lib/auth/request.ts +161 -0
  31. package/src/lib/auth/store.ts +72 -0
  32. package/src/lib/auth.ts +3 -0
  33. package/src/lib/backup/archive.ts +26 -0
  34. package/src/lib/backup/offsite.ts +173 -0
  35. package/src/lib/backup/store.ts +28 -0
  36. package/src/lib/cache/config.ts +7 -0
  37. package/src/lib/cache/memory.ts +39 -0
  38. package/src/lib/cache/policy.ts +89 -0
  39. package/src/lib/cache/purge.ts +89 -0
  40. package/src/lib/designPreview.ts +2 -2
  41. package/src/lib/dynamicBlocks.ts +1 -1
  42. package/src/lib/editor/auth.ts +35 -5
  43. package/src/lib/editor/http.ts +4 -1
  44. package/src/lib/editor/store.ts +13 -7
  45. package/src/lib/editor/token-route.ts +2 -1
  46. package/src/lib/email/accounts.ts +4 -0
  47. package/src/lib/email/service.ts +1 -0
  48. package/src/lib/email/templates.ts +2 -1
  49. package/src/lib/email/types.ts +1 -0
  50. package/src/lib/mobile/http.ts +4 -3
  51. package/src/lib/mobile/route.ts +41 -4
  52. package/src/lib/net.ts +82 -14
  53. package/src/lib/repo/migrate-runner.ts +96 -0
  54. package/src/lib/repo/migrations.ts +76 -0
  55. package/src/lib/repo/sql.ts +56 -24
  56. package/src/lib/repo/sqlite.ts +14 -1
  57. package/src/lib/repo/types.ts +12 -1
  58. package/src/lib/scheduler.ts +7 -0
  59. package/src/lib/theme-transfer.ts +163 -0
  60. package/src/lib/theme.ts +5 -0
  61. package/src/lib/webhooks.ts +7 -2
  62. package/src/middleware.ts +41 -11
  63. package/src/pages/[...slug].astro +3 -2
  64. package/src/pages/account/[action].astro +6 -0
  65. package/src/pages/admin/app-auth.astro +21 -7
  66. package/src/pages/admin/design.astro +2 -1
  67. package/src/pages/admin/import.astro +5 -3
  68. package/src/pages/admin/index.astro +3 -2
  69. package/src/pages/admin/integrations.astro +3 -2
  70. package/src/pages/admin/login.astro +236 -25
  71. package/src/pages/admin/logout.ts +16 -2
  72. package/src/pages/admin/settings.astro +31 -5
  73. package/src/pages/admin/users.astro +350 -59
  74. package/src/pages/api/backup.ts +10 -26
  75. package/src/pages/api/design/export.ts +72 -0
  76. package/src/pages/api/design/import.ts +41 -0
  77. package/src/pages/api/email/settings.ts +9 -5
  78. package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
  79. package/src/pages/api/live/settings.ts +2 -2
  80. package/src/pages/api/media/upload.ts +2 -2
  81. package/src/pages/api/push/admin.ts +3 -2
  82. package/src/pages/api/restore.ts +6 -3
  83. package/src/pages/api/v1/calendars.ts +1 -1
  84. package/src/pages/api/v1/links.ts +1 -1
  85. package/src/pages/api/v1/openapi.json.ts +1 -1
  86. package/src/pages/api/v1/push/topics.ts +1 -1
  87. package/src/pages/api/v1/resources.ts +1 -1
  88. package/src/pages/api/v1/theme.ts +1 -1
  89. package/src/pages/blog/[slug].astro +3 -2
  90. package/src/pages/events/[slug].astro +3 -2
  91. package/src/pages/preview/[type]/[id].astro +3 -3
  92. package/src/styles/admin.css +103 -16
  93. package/src/styles/email-settings.css +6 -0
  94. package/src/theme-sdk/calendar.ts +48 -0
  95. package/src/theme-sdk/index.ts +2 -0
  96. package/src/virtual.d.ts +1 -0
  97. package/src/worker.ts +7 -1
@@ -0,0 +1,1044 @@
1
+ // GENERATED by scripts/gen-migrations.mjs from src/lib/repo/migrate-runner.ts. Do not edit; run `npm run gen:migrations -w @acms-subir/core`.
2
+
3
+ // src/lib/live/migration.ts
4
+ var LIVE_MIGRATION = { version: 34, statements: [
5
+ `ALTER TABLE settings ADD COLUMN live TEXT NOT NULL DEFAULT '{}'`,
6
+ `CREATE TABLE live_connections (
7
+ provider TEXT PRIMARY KEY CHECK(provider IN ('youtube','twitch')),
8
+ client_id TEXT NOT NULL, channel_input TEXT NOT NULL DEFAULT '', channel_id TEXT NOT NULL DEFAULT '', channel_name TEXT NOT NULL DEFAULT '',
9
+ encrypted TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 0, revision INTEGER NOT NULL DEFAULT 1,
10
+ channels TEXT NOT NULL DEFAULT '[]', google_status TEXT NOT NULL DEFAULT 'testing', tested_at INTEGER NOT NULL DEFAULT 0,
11
+ checked_at INTEGER NOT NULL DEFAULT 0, last_attempt_at INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL DEFAULT 0,
12
+ failures INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '', lease TEXT NOT NULL DEFAULT '', lease_until INTEGER NOT NULL DEFAULT 0,
13
+ snapshot TEXT, upcoming TEXT, upcoming_checked_at INTEGER NOT NULL DEFAULT 0
14
+ )`,
15
+ `CREATE TABLE live_oauth_states (hash TEXT PRIMARY KEY, session_hash TEXT NOT NULL, revision INTEGER NOT NULL, encrypted TEXT NOT NULL, expires_at INTEGER NOT NULL)`,
16
+ `CREATE TABLE live_health (id INTEGER PRIMARY KEY CHECK(id=1), last_at INTEGER NOT NULL DEFAULT 0)`,
17
+ `INSERT INTO live_health (id) VALUES (1)`
18
+ ] };
19
+
20
+ // src/lib/import/migration.ts
21
+ var IMPORT_MIGRATION = { version: 35, statements: [
22
+ `CREATE TABLE IF NOT EXISTS import_runs (
23
+ id TEXT PRIMARY KEY,
24
+ source TEXT NOT NULL,
25
+ label TEXT NOT NULL DEFAULT '',
26
+ status TEXT NOT NULL DEFAULT 'running',
27
+ report TEXT NOT NULL DEFAULT '{}',
28
+ created_at INTEGER NOT NULL,
29
+ updated_at INTEGER NOT NULL
30
+ )`,
31
+ `CREATE TABLE IF NOT EXISTS import_records (
32
+ run_id TEXT NOT NULL,
33
+ kind TEXT NOT NULL,
34
+ source_id TEXT NOT NULL,
35
+ acms_id TEXT NOT NULL,
36
+ action TEXT NOT NULL,
37
+ stamp INTEGER NOT NULL DEFAULT 0,
38
+ created_at INTEGER NOT NULL,
39
+ PRIMARY KEY (run_id, kind, source_id)
40
+ )`,
41
+ `CREATE INDEX IF NOT EXISTS import_records_source ON import_records (kind, source_id)`
42
+ ] };
43
+
44
+ // src/lib/email/migration.ts
45
+ var EMAIL_MIGRATION = {
46
+ version: 29,
47
+ statements: [
48
+ `ALTER TABLE users ADD COLUMN session_version INTEGER NOT NULL DEFAULT 0`,
49
+ `ALTER TABLE bookings ADD COLUMN notification_version INTEGER NOT NULL DEFAULT 0`,
50
+ `ALTER TABLE bookings ADD COLUMN email_mutation TEXT NOT NULL DEFAULT ''`,
51
+ `CREATE TABLE email_configs (version INTEGER PRIMARY KEY AUTOINCREMENT,config TEXT NOT NULL,encrypted TEXT NOT NULL DEFAULT '',created_at INTEGER NOT NULL)`,
52
+ `CREATE TABLE email_jobs (id TEXT PRIMARY KEY,dedupe TEXT NOT NULL UNIQUE,kind TEXT NOT NULL,recipient TEXT NOT NULL,payload TEXT NOT NULL,
53
+ config_version INTEGER NOT NULL DEFAULT 0,state TEXT NOT NULL DEFAULT 'queued',attempts INTEGER NOT NULL DEFAULT 0,next_at INTEGER NOT NULL,
54
+ expires_at INTEGER,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL,lease TEXT NOT NULL DEFAULT '',lease_until INTEGER,
55
+ provider TEXT NOT NULL DEFAULT '',provider_id TEXT NOT NULL DEFAULT '',error TEXT NOT NULL DEFAULT '',accepted_at INTEGER,fingerprint TEXT NOT NULL DEFAULT '',rendered TEXT NOT NULL DEFAULT '')`,
56
+ `CREATE INDEX email_jobs_due ON email_jobs(state,next_at)`,
57
+ `CREATE INDEX email_jobs_provider ON email_jobs(provider,provider_id)`,
58
+ `CREATE TABLE email_attempts (id INTEGER PRIMARY KEY AUTOINCREMENT,job_id TEXT NOT NULL,attempt INTEGER NOT NULL,state TEXT NOT NULL,error TEXT NOT NULL,created_at INTEGER NOT NULL)`,
59
+ `CREATE TABLE email_delivery_events (id TEXT PRIMARY KEY,provider TEXT NOT NULL,provider_id TEXT NOT NULL,job_id TEXT NOT NULL DEFAULT '',recipient TEXT NOT NULL,state TEXT NOT NULL,created_at INTEGER NOT NULL,config_version INTEGER NOT NULL)`,
60
+ `CREATE INDEX email_events_message ON email_delivery_events(provider,provider_id)`,
61
+ `CREATE TABLE email_suppressions (address TEXT PRIMARY KEY,reason TEXT NOT NULL,created_at INTEGER NOT NULL)`,
62
+ `CREATE TABLE email_tokens (id TEXT PRIMARY KEY,token_hash TEXT NOT NULL UNIQUE,encrypted TEXT NOT NULL,purpose TEXT NOT NULL,email TEXT NOT NULL,name TEXT NOT NULL,role TEXT NOT NULL,user_id TEXT NOT NULL,expires_at INTEGER NOT NULL,created_at INTEGER NOT NULL,used_by TEXT NOT NULL DEFAULT '',revoked INTEGER NOT NULL DEFAULT 0)`,
63
+ `CREATE INDEX email_tokens_email ON email_tokens(email,purpose)`,
64
+ `CREATE TABLE email_rate_limits (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires_at INTEGER NOT NULL)`,
65
+ `CREATE TABLE email_meta (key TEXT PRIMARY KEY,value TEXT NOT NULL)`
66
+ ]
67
+ };
68
+
69
+ // src/lib/webhook-sql.ts
70
+ var now = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
71
+ function enqueue(event, data) {
72
+ return `INSERT INTO webhook_deliveries (id,webhook_id,event,payload,status,attempts,next_at,created_at)
73
+ SELECT lower(hex(randomblob(16))),h.id,${event},
74
+ json_object('type',${event},'createdAt',strftime('%Y-%m-%dT%H:%M:%fZ','now'),'data',json(${data})),
75
+ 'pending',0,${now},${now}
76
+ FROM webhooks h WHERE h.active=1 AND EXISTS (
77
+ SELECT 1 FROM json_each(h.events) WHERE value=${event} OR value='*'
78
+ ) AND (SELECT suppressed FROM webhook_control WHERE id=1)=0;`;
79
+ }
80
+ function trigger(name, action, table, when, event, data) {
81
+ return `CREATE TRIGGER ${name} AFTER ${action} ON ${table} WHEN ${when}
82
+ BEGIN ${enqueue(event, data)} END`;
83
+ }
84
+ function content(kind) {
85
+ const table = `${kind}s`;
86
+ const fields = kind === "post" ? ["collection", "slug", "title", "status", "body", "body_json", "body_html", "excerpt", "tags", "cover", "cover_alt", "seo_title", "seo_desc", "featured", "author_id", "template", "published_at"] : ["slug", "title", "status", "is_home", "locale", "blocks", "body_json", "body_html", "seo_title", "seo_desc", "template"];
87
+ const snapshot = (row) => `json_object('id',${row}.id,'slug',${row}.slug,'title',${row}.title,'status',${row}.status,
88
+ ${kind === "post" ? `'collection',${row}.collection` : `'locale',${row}.locale,'isHome',json(CASE WHEN ${row}.is_home THEN 'true' ELSE 'false' END)`})`;
89
+ const changed = fields.map((f) => `OLD.${f} IS NOT NEW.${f}`).join(" OR ");
90
+ const event = `CASE WHEN OLD.status!='published' THEN '${kind}.published'
91
+ WHEN NEW.status!='published' THEN '${kind}.unpublished' ELSE '${kind}.updated' END`;
92
+ return [
93
+ trigger(`webhook_${kind}_insert`, "INSERT", table, "NEW.status='published'", `'${kind}.published'`, `json_object('${kind}',${snapshot("NEW")})`),
94
+ trigger(
95
+ `webhook_${kind}_update`,
96
+ "UPDATE",
97
+ table,
98
+ `(OLD.status='published' OR NEW.status='published') AND (${changed})`,
99
+ event,
100
+ `json_object('${kind}',json(CASE WHEN NEW.status='published' THEN ${snapshot("NEW")}
101
+ ELSE json_set(${snapshot("OLD")},'$.status',NEW.status) END),
102
+ 'previous',json(CASE WHEN OLD.status='published' THEN ${snapshot("OLD")} ELSE 'null' END))`
103
+ ),
104
+ trigger(`webhook_${kind}_delete`, "DELETE", table, "OLD.status='published'", `'${kind}.deleted'`, `json_object('${kind}',${snapshot("OLD")})`)
105
+ ];
106
+ }
107
+ var media = (row) => `json_object('media',json_object('id',${row}.id,'key',${row}.key,'filename',${row}.filename,
108
+ 'mime',${row}.mime,'width',${row}.width,'height',${row}.height,'size',${row}.size,'alt',${row}.alt))`;
109
+ var WEBHOOK_MIGRATION = { version: 33, statements: [
110
+ `CREATE TABLE webhook_control (id INTEGER PRIMARY KEY CHECK(id=1), suppressed INTEGER NOT NULL DEFAULT 0)`,
111
+ `INSERT INTO webhook_control (id,suppressed) VALUES (1,0)`,
112
+ // Populate the envelope from the inserted delivery's stable ID. Retries
113
+ // send these exact stored bytes, including for SQL-generated notifications.
114
+ `CREATE TRIGGER webhook_delivery_envelope AFTER INSERT ON webhook_deliveries
115
+ WHEN json_extract(NEW.payload,'$.id') IS NULL BEGIN
116
+ UPDATE webhook_deliveries SET payload=json_set(payload,'$.id',NEW.id) WHERE id=NEW.id;
117
+ END`,
118
+ ...content("post"),
119
+ ...content("page"),
120
+ trigger("webhook_media_create", "INSERT", "media", "1", "'media.created'", media("NEW")),
121
+ trigger("webhook_media_update", "UPDATE", "media", "OLD.alt IS NOT NEW.alt", "'media.updated'", media("NEW")),
122
+ trigger("webhook_media_delete", "DELETE", "media", "1", "'media.deleted'", media("OLD"))
123
+ ] };
124
+
125
+ // src/lib/push/migration.ts
126
+ var now2 = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
127
+ var status = "CASE WHEN (SELECT suppressed FROM webhook_control WHERE id=1)=1 THEN 'skipped' ELSE 'pending' END";
128
+ function enqueue2(kind) {
129
+ return `INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
130
+ SELECT '${kind}:'||NEW.id,'${kind}',NEW.id,NULL,${status},NULL,${now2},${now2}
131
+ WHERE NOT EXISTS (SELECT 1 FROM push_queue WHERE key='${kind}:'||NEW.id);`;
132
+ }
133
+ function triggers(kind) {
134
+ const table = `${kind}s`;
135
+ return [
136
+ `CREATE TRIGGER push_${kind}_insert AFTER INSERT ON ${table} WHEN NEW.status='published' BEGIN ${enqueue2(kind)} END`,
137
+ `CREATE TRIGGER push_${kind}_publish AFTER UPDATE OF status ON ${table}
138
+ WHEN NEW.status='published' AND OLD.status IS NOT 'published' BEGIN ${enqueue2(kind)} END`
139
+ ];
140
+ }
141
+ var PUSH_MIGRATION = { version: 37, statements: [
142
+ `ALTER TABLE settings ADD COLUMN push TEXT NOT NULL DEFAULT '{}'`,
143
+ `CREATE TABLE push_devices (
144
+ id TEXT PRIMARY KEY, token TEXT NOT NULL UNIQUE, platform TEXT NOT NULL,
145
+ locale TEXT NOT NULL DEFAULT '', topics TEXT NOT NULL DEFAULT '[]',
146
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL
147
+ )`,
148
+ `CREATE INDEX push_devices_seen ON push_devices (last_seen_at)`,
149
+ `CREATE TABLE push_notifications (
150
+ id TEXT PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', url TEXT NOT NULL DEFAULT '',
151
+ topics TEXT NOT NULL DEFAULT '[]', source TEXT NOT NULL DEFAULT 'manual', source_key TEXT NOT NULL DEFAULT '',
152
+ status TEXT NOT NULL DEFAULT 'sending', recipients INTEGER NOT NULL DEFAULT 0, sent INTEGER NOT NULL DEFAULT 0,
153
+ ok INTEGER NOT NULL DEFAULT 0, failed INTEGER NOT NULL DEFAULT 0, removed INTEGER NOT NULL DEFAULT 0,
154
+ error TEXT NOT NULL DEFAULT '', author TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, finished_at INTEGER
155
+ )`,
156
+ `CREATE INDEX push_notifications_created ON push_notifications (created_at)`,
157
+ // Expo push tickets awaiting a receipt check (receipts expire after 24h)
158
+ `CREATE TABLE push_tickets (id TEXT PRIMARY KEY, notification_id TEXT NOT NULL, device_id TEXT NOT NULL, created_at INTEGER NOT NULL)`,
159
+ `CREATE INDEX push_tickets_created ON push_tickets (created_at)`,
160
+ `CREATE TABLE push_queue (
161
+ key TEXT PRIMARY KEY, kind TEXT NOT NULL, doc_id TEXT NOT NULL, occurrence INTEGER,
162
+ status TEXT NOT NULL, notification_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
163
+ )`,
164
+ `CREATE INDEX push_queue_status ON push_queue (status, created_at)`,
165
+ // everything already live counts as announced
166
+ `INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
167
+ SELECT 'post:'||id,'post',id,NULL,'seen',NULL,${now2},${now2} FROM posts WHERE status='published'`,
168
+ `INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
169
+ SELECT 'event:'||id,'event',id,NULL,'seen',NULL,${now2},${now2} FROM events WHERE status='published'`,
170
+ ...triggers("post"),
171
+ ...triggers("event")
172
+ ] };
173
+
174
+ // src/lib/editor/migration.ts
175
+ var now3 = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
176
+ var revoke = (reason) => `UPDATE editor_devices SET revoked_at=${now3}, revoked_reason='${reason}' WHERE user_id=OLD.id AND revoked_at IS NULL;
177
+ DELETE FROM editor_tokens WHERE device_id IN (SELECT id FROM editor_devices WHERE user_id=OLD.id);`;
178
+ var EDITOR_MIGRATION = { version: 38, statements: [
179
+ `CREATE TABLE editor_devices (
180
+ id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, client_id TEXT NOT NULL,
181
+ session_version INTEGER NOT NULL, created_at INTEGER NOT NULL, last_used_at INTEGER NOT NULL,
182
+ revoked_at INTEGER, revoked_reason TEXT NOT NULL DEFAULT ''
183
+ )`,
184
+ `CREATE INDEX editor_devices_user ON editor_devices (user_id, revoked_at)`,
185
+ `CREATE TABLE editor_tokens (
186
+ hash TEXT PRIMARY KEY, device_id TEXT NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('access','refresh')),
187
+ created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER
188
+ )`,
189
+ `CREATE INDEX editor_tokens_device ON editor_tokens (device_id)`,
190
+ `CREATE TABLE editor_codes (
191
+ hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, client_id TEXT NOT NULL, redirect_uri TEXT NOT NULL,
192
+ challenge TEXT NOT NULL, device_name TEXT NOT NULL, session_version INTEGER NOT NULL,
193
+ created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER, device_id TEXT
194
+ )`,
195
+ `CREATE TABLE editor_activity (
196
+ id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, user_id TEXT NOT NULL, device_id TEXT NOT NULL,
197
+ action TEXT NOT NULL, target TEXT NOT NULL DEFAULT ''
198
+ )`,
199
+ `CREATE INDEX editor_activity_device ON editor_activity (device_id, created_at)`,
200
+ `CREATE TRIGGER editor_user_delete AFTER DELETE ON users BEGIN ${revoke("user removed")} END`,
201
+ `CREATE TRIGGER editor_user_password AFTER UPDATE OF session_version ON users
202
+ WHEN NEW.session_version IS NOT OLD.session_version BEGIN ${revoke("password changed")} END`
203
+ ] };
204
+ var EDITOR_GRACE_MIGRATION = { version: 39, statements: [
205
+ `ALTER TABLE editor_tokens ADD COLUMN parent TEXT`,
206
+ `CREATE INDEX editor_tokens_parent ON editor_tokens (parent)`,
207
+ `CREATE TABLE editor_claims (key TEXT PRIMARY KEY, created_at INTEGER NOT NULL)`
208
+ ] };
209
+
210
+ // src/lib/calendar/migration.ts
211
+ var CALENDAR_SETTINGS_MIGRATION = { version: 40, statements: [
212
+ `ALTER TABLE settings ADD COLUMN calendar TEXT NOT NULL DEFAULT '{}'`,
213
+ `ALTER TABLE booking_links ADD COLUMN approval TEXT NOT NULL DEFAULT 'default'`,
214
+ `ALTER TABLE booking_links ADD COLUMN approval_rules TEXT NOT NULL DEFAULT '{}'`,
215
+ `UPDATE booking_links SET approval = CASE auto_confirm WHEN 1 THEN 'default' ELSE 'manual' END`
216
+ ] };
217
+ var ROOM_HOLD_MIGRATION = { version: 41, statements: [
218
+ `ALTER TABLE events ADD COLUMN room_hold TEXT NOT NULL DEFAULT '{}'`
219
+ ] };
220
+
221
+ // src/lib/maps/migration.ts
222
+ var PLACES_MIGRATION = { version: 43, statements: [
223
+ `CREATE TABLE IF NOT EXISTS places (
224
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, address TEXT NOT NULL DEFAULT '', url TEXT NOT NULL DEFAULT '',
225
+ lat REAL, lng REAL, notes TEXT NOT NULL DEFAULT '', is_default INTEGER NOT NULL DEFAULT 0,
226
+ map_image TEXT NOT NULL DEFAULT '', map_image_dark TEXT NOT NULL DEFAULT '',
227
+ ord INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
228
+ )`,
229
+ `ALTER TABLE resources ADD COLUMN place_id TEXT NOT NULL DEFAULT ''`
230
+ ] };
231
+
232
+ // src/lib/repo/migrations.ts
233
+ var CONTENT_VERSION_COLUMNS = {
234
+ settings: [
235
+ "site_title",
236
+ "tagline",
237
+ "links_name",
238
+ "links_bio",
239
+ "links_avatar",
240
+ "footer_text",
241
+ "active_theme",
242
+ "design_overrides",
243
+ "site_logo",
244
+ "site_url",
245
+ "site_favicon",
246
+ "site_cover",
247
+ "announcement",
248
+ "head_code",
249
+ "footer_code",
250
+ "timezone",
251
+ "locale",
252
+ "analytics",
253
+ "section_pages",
254
+ "live",
255
+ "push",
256
+ "calendar",
257
+ "site_logo_dark",
258
+ "mobile_access"
259
+ ],
260
+ pages: ["id", "slug", "title", "status", "is_home", "blocks", "seo_title", "seo_desc", "body_json", "body_html", "template", "locale", "translations"],
261
+ posts: [
262
+ "id",
263
+ "slug",
264
+ "title",
265
+ "body",
266
+ "tags",
267
+ "cover",
268
+ "status",
269
+ "published_at",
270
+ "body_json",
271
+ "body_html",
272
+ "excerpt",
273
+ "cover_alt",
274
+ "seo_title",
275
+ "seo_desc",
276
+ "scheduled_at",
277
+ "author_id",
278
+ "featured",
279
+ "template",
280
+ "collection",
281
+ "translations"
282
+ ],
283
+ post_authors: ["post_id", "author_id", "ord"],
284
+ events: [
285
+ "id",
286
+ "slug",
287
+ "title",
288
+ "body_json",
289
+ "body_html",
290
+ "excerpt",
291
+ "cover",
292
+ "cover_alt",
293
+ "seo_title",
294
+ "seo_desc",
295
+ "status",
296
+ "featured",
297
+ "tags",
298
+ "template",
299
+ "starts",
300
+ "ends",
301
+ "all_day",
302
+ "timezone",
303
+ "rrule",
304
+ "exdates",
305
+ "cancelled",
306
+ "location",
307
+ "cta",
308
+ "calendar_ids",
309
+ "fields",
310
+ "source_uid",
311
+ "start_at",
312
+ "end_at",
313
+ "published_at",
314
+ "scheduled_at",
315
+ "rsvp",
316
+ "translations",
317
+ "room_hold"
318
+ ],
319
+ calendars: ["id", "slug", "name", "color", "description", "kind", "feed_url", "visibility", "feed_token", "ord"],
320
+ places: ["id", "name", "address", "url", "lat", "lng", "notes", "is_default", "map_image", "map_image_dark", "ord"],
321
+ resources: [
322
+ "id",
323
+ "slug",
324
+ "name",
325
+ "kind",
326
+ "description",
327
+ "photo",
328
+ "capacity",
329
+ "amenities",
330
+ "timezone",
331
+ "hours",
332
+ "overrides",
333
+ "busy_feeds",
334
+ "visibility",
335
+ "show_on_calendar",
336
+ "feed_token",
337
+ "ord",
338
+ "place_id"
339
+ ],
340
+ booking_links: [
341
+ "id",
342
+ "resource_id",
343
+ "slug",
344
+ "name",
345
+ "description",
346
+ "mode",
347
+ "duration",
348
+ "lengths",
349
+ "min_days",
350
+ "max_days",
351
+ "step",
352
+ "buffer_before",
353
+ "buffer_after",
354
+ "min_notice",
355
+ "max_advance",
356
+ "per_day_cap",
357
+ "hours",
358
+ "auto_confirm",
359
+ "visibility",
360
+ "ask_phone",
361
+ "questions",
362
+ "confirm_message",
363
+ "redirect_url",
364
+ "active",
365
+ "ord",
366
+ "approval",
367
+ "approval_rules",
368
+ "change_cutoff"
369
+ ],
370
+ links: ["id", "label", "url", "icon", "ord", "kind", "html", "meta"],
371
+ nav_items: ["id", "label", "url", "area", "ord"],
372
+ tags: ["id", "name", "slug", "description"],
373
+ media: ["id", "key", "filename", "mime", "width", "height", "size", "alt", "variants"],
374
+ redirects: ["id", "from_path", "to_path", "code"],
375
+ users: ["id", "name", "email", "role", "bio", "avatar", "slug"],
376
+ forms: ["id", "name", "kind", "purpose", "status", "definition"]
377
+ };
378
+ var BUMP_CONTENT_VERSION = "UPDATE settings SET content_version = content_version + 1 WHERE id = 1;";
379
+ function contentVersionTriggers(table, columns) {
380
+ const changed = columns.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
381
+ const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(", ")} ON ${table} WHEN ${changed} BEGIN ${BUMP_CONTENT_VERSION} END`;
382
+ if (table === "settings") return [update];
383
+ return [
384
+ `CREATE TRIGGER content_version_${table}_insert AFTER INSERT ON ${table} BEGIN ${BUMP_CONTENT_VERSION} END`,
385
+ `CREATE TRIGGER content_version_${table}_delete AFTER DELETE ON ${table} BEGIN ${BUMP_CONTENT_VERSION} END`,
386
+ update
387
+ ];
388
+ }
389
+ var MIGRATIONS_TABLE = `CREATE TABLE IF NOT EXISTS migrations (
390
+ version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL
391
+ )`;
392
+ var MIGRATIONS = [
393
+ {
394
+ version: 1,
395
+ // baseline: the original four tables
396
+ statements: [
397
+ `CREATE TABLE IF NOT EXISTS pages (
398
+ id TEXT PRIMARY KEY, slug TEXT UNIQUE NOT NULL, title TEXT NOT NULL,
399
+ status TEXT NOT NULL DEFAULT 'draft', is_home INTEGER NOT NULL DEFAULT 0,
400
+ blocks TEXT NOT NULL DEFAULT '[]', seo_title TEXT DEFAULT '', seo_desc TEXT DEFAULT '',
401
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
402
+ )`,
403
+ `CREATE TABLE IF NOT EXISTS posts (
404
+ id TEXT PRIMARY KEY, slug TEXT UNIQUE NOT NULL, title TEXT NOT NULL,
405
+ body TEXT DEFAULT '', tags TEXT DEFAULT '[]', cover TEXT DEFAULT '',
406
+ status TEXT NOT NULL DEFAULT 'draft', published_at INTEGER,
407
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
408
+ )`,
409
+ `CREATE TABLE IF NOT EXISTS links (
410
+ id TEXT PRIMARY KEY, label TEXT NOT NULL, url TEXT NOT NULL,
411
+ icon TEXT DEFAULT '', ord INTEGER NOT NULL, clicks INTEGER NOT NULL DEFAULT 0
412
+ )`,
413
+ `CREATE TABLE IF NOT EXISTS settings (
414
+ id INTEGER PRIMARY KEY CHECK (id = 1),
415
+ site_title TEXT NOT NULL DEFAULT 'My Site', tagline TEXT DEFAULT ''
416
+ )`
417
+ ]
418
+ },
419
+ {
420
+ version: 2,
421
+ // editor rework: rich post fields, scheduling, media
422
+ statements: [
423
+ `ALTER TABLE posts ADD COLUMN body_json TEXT DEFAULT ''`,
424
+ `ALTER TABLE posts ADD COLUMN body_html TEXT DEFAULT ''`,
425
+ `ALTER TABLE posts ADD COLUMN excerpt TEXT DEFAULT ''`,
426
+ `ALTER TABLE posts ADD COLUMN cover_alt TEXT DEFAULT ''`,
427
+ `ALTER TABLE posts ADD COLUMN seo_title TEXT DEFAULT ''`,
428
+ `ALTER TABLE posts ADD COLUMN seo_desc TEXT DEFAULT ''`,
429
+ `ALTER TABLE posts ADD COLUMN scheduled_at INTEGER`,
430
+ `CREATE TABLE IF NOT EXISTS media (
431
+ id TEXT PRIMARY KEY, key TEXT UNIQUE NOT NULL, filename TEXT NOT NULL,
432
+ mime TEXT NOT NULL, width INTEGER NOT NULL DEFAULT 0, height INTEGER NOT NULL DEFAULT 0,
433
+ size INTEGER NOT NULL DEFAULT 0, alt TEXT DEFAULT '', variants TEXT DEFAULT '[]',
434
+ created_at INTEGER NOT NULL
435
+ )`
436
+ ]
437
+ },
438
+ {
439
+ version: 3,
440
+ // tags as entities + multi-author users
441
+ statements: [
442
+ `CREATE TABLE IF NOT EXISTS tags (
443
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE NOT NULL,
444
+ description TEXT DEFAULT ''
445
+ )`,
446
+ `CREATE TABLE IF NOT EXISTS users (
447
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE NOT NULL,
448
+ role TEXT NOT NULL DEFAULT 'editor', pass_hash TEXT NOT NULL,
449
+ created_at INTEGER NOT NULL
450
+ )`,
451
+ `ALTER TABLE posts ADD COLUMN author_id TEXT`
452
+ ]
453
+ },
454
+ {
455
+ version: 4,
456
+ // pages move from widget blocks to the TipTap editor
457
+ statements: [
458
+ `ALTER TABLE pages ADD COLUMN body_json TEXT DEFAULT ''`,
459
+ `ALTER TABLE pages ADD COLUMN body_html TEXT DEFAULT ''`
460
+ ]
461
+ },
462
+ {
463
+ version: 5,
464
+ // links page items: custom HTML/JS blocks alongside link buttons
465
+ statements: [
466
+ `ALTER TABLE links ADD COLUMN kind TEXT NOT NULL DEFAULT 'link'`,
467
+ `ALTER TABLE links ADD COLUMN html TEXT DEFAULT ''`
468
+ ]
469
+ },
470
+ {
471
+ version: 6,
472
+ // link-in-bio block types: per-item JSON meta (brand color, spacer size, vcard fields…)
473
+ statements: [`ALTER TABLE links ADD COLUMN meta TEXT DEFAULT '{}'`]
474
+ },
475
+ {
476
+ version: 7,
477
+ // links page gets its own display name + bio, decoupled from site title/tagline
478
+ statements: [
479
+ `ALTER TABLE settings ADD COLUMN links_name TEXT DEFAULT ''`,
480
+ `ALTER TABLE settings ADD COLUMN links_bio TEXT DEFAULT ''`
481
+ ]
482
+ },
483
+ {
484
+ version: 8,
485
+ // links page profile photo
486
+ statements: [`ALTER TABLE settings ADD COLUMN links_avatar TEXT DEFAULT ''`]
487
+ },
488
+ {
489
+ version: 9,
490
+ // editable site footer (markdown)
491
+ statements: [`ALTER TABLE settings ADD COLUMN footer_text TEXT DEFAULT ''`]
492
+ },
493
+ {
494
+ version: 10,
495
+ // theme engine: active theme + sparse token overrides + site logo
496
+ statements: [
497
+ `ALTER TABLE settings ADD COLUMN active_theme TEXT DEFAULT ''`,
498
+ `ALTER TABLE settings ADD COLUMN design_overrides TEXT DEFAULT '{}'`,
499
+ `ALTER TABLE settings ADD COLUMN site_logo TEXT DEFAULT ''`
500
+ ]
501
+ },
502
+ {
503
+ version: 11,
504
+ // featured posts (homepage/featured sections)
505
+ statements: [`ALTER TABLE posts ADD COLUMN featured INTEGER NOT NULL DEFAULT 0`]
506
+ },
507
+ {
508
+ version: 12,
509
+ // managed navigation (header / footer / social areas)
510
+ statements: [
511
+ `CREATE TABLE IF NOT EXISTS nav_items (
512
+ id TEXT PRIMARY KEY, label TEXT NOT NULL, url TEXT NOT NULL,
513
+ area TEXT NOT NULL DEFAULT 'header', ord INTEGER NOT NULL
514
+ )`
515
+ ]
516
+ },
517
+ {
518
+ version: 13,
519
+ // author profiles: bio, avatar, public slug for /author/<slug>
520
+ statements: [
521
+ `ALTER TABLE users ADD COLUMN bio TEXT DEFAULT ''`,
522
+ `ALTER TABLE users ADD COLUMN avatar TEXT DEFAULT ''`,
523
+ `ALTER TABLE users ADD COLUMN slug TEXT DEFAULT ''`
524
+ ]
525
+ },
526
+ {
527
+ version: 14,
528
+ // external drafts API: bearer key + CORS origin allowlist
529
+ statements: [
530
+ `ALTER TABLE settings ADD COLUMN drafts_api_key TEXT DEFAULT ''`,
531
+ `ALTER TABLE settings ADD COLUMN drafts_allowed_origins TEXT DEFAULT '[]'`
532
+ ]
533
+ },
534
+ {
535
+ version: 15,
536
+ // SEO foundation: canonical site URL, favicon, default share image
537
+ statements: [
538
+ `ALTER TABLE settings ADD COLUMN site_url TEXT DEFAULT ''`,
539
+ `ALTER TABLE settings ADD COLUMN site_favicon TEXT DEFAULT ''`,
540
+ `ALTER TABLE settings ADD COLUMN site_cover TEXT DEFAULT ''`
541
+ ]
542
+ },
543
+ {
544
+ version: 16,
545
+ // URL redirects (manual + auto-created on slug changes)
546
+ statements: [
547
+ `CREATE TABLE IF NOT EXISTS redirects (
548
+ id TEXT PRIMARY KEY, from_path TEXT UNIQUE NOT NULL, to_path TEXT NOT NULL,
549
+ code INTEGER NOT NULL DEFAULT 301, created_at INTEGER NOT NULL
550
+ )`
551
+ ]
552
+ },
553
+ {
554
+ version: 17,
555
+ // content model: multi-author posts, per-doc templates, announcement bar
556
+ statements: [
557
+ `CREATE TABLE IF NOT EXISTS post_authors (
558
+ post_id TEXT NOT NULL, author_id TEXT NOT NULL, ord INTEGER NOT NULL DEFAULT 0,
559
+ PRIMARY KEY (post_id, author_id)
560
+ )`,
561
+ // existing single authors become the first (primary) entry
562
+ `INSERT OR IGNORE INTO post_authors (post_id, author_id, ord)
563
+ SELECT id, author_id, 0 FROM posts WHERE author_id IS NOT NULL`,
564
+ `ALTER TABLE posts ADD COLUMN template TEXT DEFAULT ''`,
565
+ `ALTER TABLE pages ADD COLUMN template TEXT DEFAULT ''`,
566
+ `ALTER TABLE settings ADD COLUMN announcement TEXT DEFAULT '{}'`
567
+ ]
568
+ },
569
+ {
570
+ version: 18,
571
+ // site tools: code injection, publication timezone + locale
572
+ statements: [
573
+ `ALTER TABLE settings ADD COLUMN head_code TEXT DEFAULT ''`,
574
+ `ALTER TABLE settings ADD COLUMN footer_code TEXT DEFAULT ''`,
575
+ `ALTER TABLE settings ADD COLUMN timezone TEXT DEFAULT ''`,
576
+ `ALTER TABLE settings ADD COLUMN locale TEXT DEFAULT ''`
577
+ ]
578
+ },
579
+ {
580
+ // Multiple collections: posts gain a `collection` column keyed by the
581
+ // acms({ content.collections }) config key. Slug uniqueness becomes
582
+ // per-collection — v1 declared slug as column-level UNIQUE, which SQLite
583
+ // can't alter away, so this is a table rebuild (create → copy → swap).
584
+ // Column lists are explicit so the copy can never mispair values.
585
+ version: 19,
586
+ statements: [
587
+ `CREATE TABLE posts_v19 (
588
+ id TEXT PRIMARY KEY, slug TEXT NOT NULL, title TEXT NOT NULL,
589
+ body TEXT DEFAULT '', tags TEXT DEFAULT '[]', cover TEXT DEFAULT '',
590
+ status TEXT NOT NULL DEFAULT 'draft', published_at INTEGER,
591
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
592
+ body_json TEXT DEFAULT '', body_html TEXT DEFAULT '', excerpt TEXT DEFAULT '',
593
+ cover_alt TEXT DEFAULT '', seo_title TEXT DEFAULT '', seo_desc TEXT DEFAULT '',
594
+ scheduled_at INTEGER, author_id TEXT, featured INTEGER NOT NULL DEFAULT 0,
595
+ template TEXT DEFAULT '',
596
+ collection TEXT NOT NULL DEFAULT 'posts',
597
+ UNIQUE(collection, slug)
598
+ )`,
599
+ `INSERT INTO posts_v19 (
600
+ id, slug, title, body, tags, cover, status, published_at, created_at, updated_at,
601
+ body_json, body_html, excerpt, cover_alt, seo_title, seo_desc,
602
+ scheduled_at, author_id, featured, template, collection
603
+ ) SELECT
604
+ id, slug, title, body, tags, cover, status, published_at, created_at, updated_at,
605
+ body_json, body_html, excerpt, cover_alt, seo_title, seo_desc,
606
+ scheduled_at, author_id, featured, template, 'posts'
607
+ FROM posts`,
608
+ `DROP TABLE posts`,
609
+ `ALTER TABLE posts_v19 RENAME TO posts`,
610
+ `CREATE INDEX IF NOT EXISTS idx_posts_collection ON posts(collection)`
611
+ ]
612
+ },
613
+ {
614
+ // Bilingual foundation: pages gain a `locale` keyed by acms({ i18n })
615
+ // locale codes. '' means the default locale, so existing rows (and sites
616
+ // that never configure i18n) keep today's behavior. "Is home" becomes
617
+ // per-locale — one home page per language.
618
+ version: 20,
619
+ statements: [`ALTER TABLE pages ADD COLUMN locale TEXT NOT NULL DEFAULT ''`]
620
+ },
621
+ {
622
+ // Public search: one FTS5 row per published post/page (title, excerpt,
623
+ // stripped body text, public tag names). Rows are synced by the repo on
624
+ // every write — no SQL triggers — and backfilled in code right after
625
+ // this version applies, since plaintext can't be derived in SQL.
626
+ // remove_diacritics makes "cafe" match "café" (matters for es content).
627
+ version: 21,
628
+ statements: [
629
+ `CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
630
+ kind UNINDEXED, ref_id UNINDEXED, locale UNINDEXED,
631
+ title, excerpt, plaintext, tags,
632
+ tokenize = 'unicode61 remove_diacritics 2'
633
+ )`
634
+ ]
635
+ },
636
+ {
637
+ // Manual publish: live (published/scheduled) documents stage their edits
638
+ // in a `draft` JSON snapshot; the live columns only change when the user
639
+ // hits Update. NULL = no pending edits.
640
+ version: 22,
641
+ statements: [
642
+ `ALTER TABLE posts ADD COLUMN draft TEXT`,
643
+ `ALTER TABLE pages ADD COLUMN draft TEXT`
644
+ ]
645
+ },
646
+ {
647
+ // Manual publish for the Design screen: theme/token/logo changes stage in
648
+ // a `draft_design` JSON snapshot (previewed with ?designPreview=1) until
649
+ // the user hits Publish design. NULL = no staged design.
650
+ version: 23,
651
+ statements: [`ALTER TABLE settings ADD COLUMN draft_design TEXT`]
652
+ },
653
+ {
654
+ // Snippets: named, reusable fragments of editor content ("Save as
655
+ // snippet" on a selection; re-inserted from the "/" menu).
656
+ version: 24,
657
+ statements: [
658
+ `CREATE TABLE IF NOT EXISTS snippets (
659
+ id TEXT PRIMARY KEY,
660
+ name TEXT NOT NULL,
661
+ content_json TEXT NOT NULL,
662
+ created_at INTEGER NOT NULL
663
+ )`
664
+ ]
665
+ },
666
+ {
667
+ // Analytics providers (Umami, Meta Pixel, TikTok Pixel): structured
668
+ // per-provider settings rendered into the public <head>. JSON object,
669
+ // '{}' = every provider disabled (schema defaults).
670
+ version: 25,
671
+ statements: [`ALTER TABLE settings ADD COLUMN analytics TEXT DEFAULT '{}'`]
672
+ },
673
+ {
674
+ version: 26,
675
+ statements: [
676
+ `ALTER TABLE settings ADD COLUMN ai TEXT NOT NULL DEFAULT '{}'`,
677
+ `ALTER TABLE settings ADD COLUMN agent TEXT NOT NULL DEFAULT '{}'`
678
+ ]
679
+ },
680
+ {
681
+ // Calendar module: calendars (native + live ICS feeds), events with
682
+ // wall-clock times + recurrence, and the integration plumbing shared by
683
+ // events and (later) bookings: scoped API keys and signed webhooks with
684
+ // a retry queue. calendar_ids / tags / exdates / location / cta / fields
685
+ // are JSON columns, same as posts.tags. Event search rows reuse the
686
+ // search_index FTS table with kind = 'event'.
687
+ version: 27,
688
+ statements: [
689
+ `CREATE TABLE IF NOT EXISTS calendars (
690
+ id TEXT PRIMARY KEY,
691
+ slug TEXT UNIQUE NOT NULL,
692
+ name TEXT NOT NULL,
693
+ color TEXT NOT NULL DEFAULT '#4f46e5',
694
+ description TEXT NOT NULL DEFAULT '',
695
+ kind TEXT NOT NULL DEFAULT 'native',
696
+ feed_url TEXT NOT NULL DEFAULT '',
697
+ visibility TEXT NOT NULL DEFAULT 'public',
698
+ feed_token TEXT NOT NULL DEFAULT '',
699
+ ord INTEGER NOT NULL DEFAULT 0,
700
+ created_at INTEGER NOT NULL,
701
+ updated_at INTEGER NOT NULL
702
+ )`,
703
+ `CREATE TABLE IF NOT EXISTS events (
704
+ id TEXT PRIMARY KEY,
705
+ slug TEXT UNIQUE NOT NULL,
706
+ title TEXT NOT NULL,
707
+ body_json TEXT NOT NULL DEFAULT '',
708
+ body_html TEXT NOT NULL DEFAULT '',
709
+ excerpt TEXT NOT NULL DEFAULT '',
710
+ cover TEXT NOT NULL DEFAULT '',
711
+ cover_alt TEXT NOT NULL DEFAULT '',
712
+ seo_title TEXT NOT NULL DEFAULT '',
713
+ seo_desc TEXT NOT NULL DEFAULT '',
714
+ status TEXT NOT NULL DEFAULT 'draft',
715
+ featured INTEGER NOT NULL DEFAULT 0,
716
+ tags TEXT NOT NULL DEFAULT '[]',
717
+ template TEXT NOT NULL DEFAULT '',
718
+ starts TEXT NOT NULL,
719
+ ends TEXT NOT NULL DEFAULT '',
720
+ all_day INTEGER NOT NULL DEFAULT 0,
721
+ timezone TEXT NOT NULL DEFAULT '',
722
+ rrule TEXT NOT NULL DEFAULT '',
723
+ exdates TEXT NOT NULL DEFAULT '[]',
724
+ cancelled INTEGER NOT NULL DEFAULT 0,
725
+ location TEXT NOT NULL DEFAULT '{}',
726
+ cta TEXT NOT NULL DEFAULT '{}',
727
+ calendar_ids TEXT NOT NULL DEFAULT '[]',
728
+ fields TEXT NOT NULL DEFAULT '{}',
729
+ source_uid TEXT NOT NULL DEFAULT '',
730
+ start_at INTEGER NOT NULL,
731
+ end_at INTEGER NOT NULL,
732
+ draft TEXT,
733
+ published_at INTEGER,
734
+ scheduled_at INTEGER,
735
+ created_at INTEGER NOT NULL,
736
+ updated_at INTEGER NOT NULL
737
+ )`,
738
+ `CREATE INDEX IF NOT EXISTS events_start_at ON events (start_at)`,
739
+ `CREATE INDEX IF NOT EXISTS events_status ON events (status)`,
740
+ `CREATE TABLE IF NOT EXISTS api_keys (
741
+ id TEXT PRIMARY KEY,
742
+ name TEXT NOT NULL,
743
+ prefix TEXT NOT NULL,
744
+ key_hash TEXT UNIQUE NOT NULL,
745
+ scopes TEXT NOT NULL DEFAULT '[]',
746
+ last_used_at INTEGER,
747
+ created_at INTEGER NOT NULL
748
+ )`,
749
+ `CREATE TABLE IF NOT EXISTS webhooks (
750
+ id TEXT PRIMARY KEY,
751
+ url TEXT NOT NULL,
752
+ secret TEXT NOT NULL,
753
+ events TEXT NOT NULL DEFAULT '[]',
754
+ active INTEGER NOT NULL DEFAULT 1,
755
+ last_status INTEGER,
756
+ last_at INTEGER,
757
+ created_at INTEGER NOT NULL
758
+ )`,
759
+ `CREATE TABLE IF NOT EXISTS webhook_deliveries (
760
+ id TEXT PRIMARY KEY,
761
+ webhook_id TEXT NOT NULL,
762
+ event TEXT NOT NULL,
763
+ payload TEXT NOT NULL,
764
+ status TEXT NOT NULL DEFAULT 'pending',
765
+ attempts INTEGER NOT NULL DEFAULT 0,
766
+ next_at INTEGER,
767
+ response_code INTEGER,
768
+ created_at INTEGER NOT NULL,
769
+ delivered_at INTEGER
770
+ )`,
771
+ `CREATE INDEX IF NOT EXISTS webhook_deliveries_due ON webhook_deliveries (status, next_at)`
772
+ ]
773
+ },
774
+ {
775
+ // Bookings: resources (rooms, people, vans…), their booking links, and the
776
+ // bookings themselves (also staff time blocks and event RSVPs). Weekly
777
+ // hours / overrides / questions are JSON. The (resource_id, start_at)
778
+ // index serves the overlap check every booking insert runs; hold_start /
779
+ // hold_end are the booked time widened by the link's buffers, so the
780
+ // overlap test is a single range comparison.
781
+ version: 28,
782
+ statements: [
783
+ `CREATE TABLE IF NOT EXISTS resources (
784
+ id TEXT PRIMARY KEY,
785
+ slug TEXT UNIQUE NOT NULL,
786
+ name TEXT NOT NULL,
787
+ kind TEXT NOT NULL DEFAULT 'Room',
788
+ description TEXT NOT NULL DEFAULT '',
789
+ photo TEXT NOT NULL DEFAULT '',
790
+ capacity INTEGER,
791
+ amenities TEXT NOT NULL DEFAULT '[]',
792
+ timezone TEXT NOT NULL DEFAULT '',
793
+ hours TEXT NOT NULL DEFAULT '{}',
794
+ overrides TEXT NOT NULL DEFAULT '[]',
795
+ busy_feeds TEXT NOT NULL DEFAULT '[]',
796
+ notify_emails TEXT NOT NULL DEFAULT '[]',
797
+ visibility TEXT NOT NULL DEFAULT 'public',
798
+ show_on_calendar INTEGER NOT NULL DEFAULT 0,
799
+ feed_token TEXT NOT NULL DEFAULT '',
800
+ ord INTEGER NOT NULL DEFAULT 0,
801
+ created_at INTEGER NOT NULL,
802
+ updated_at INTEGER NOT NULL
803
+ )`,
804
+ `CREATE TABLE IF NOT EXISTS booking_links (
805
+ id TEXT PRIMARY KEY,
806
+ resource_id TEXT NOT NULL,
807
+ slug TEXT NOT NULL,
808
+ name TEXT NOT NULL,
809
+ description TEXT NOT NULL DEFAULT '',
810
+ mode TEXT NOT NULL DEFAULT 'slots',
811
+ duration INTEGER NOT NULL DEFAULT 60,
812
+ lengths TEXT NOT NULL DEFAULT '[]',
813
+ min_days INTEGER NOT NULL DEFAULT 1,
814
+ max_days INTEGER NOT NULL DEFAULT 1,
815
+ step INTEGER NOT NULL DEFAULT 30,
816
+ buffer_before INTEGER NOT NULL DEFAULT 0,
817
+ buffer_after INTEGER NOT NULL DEFAULT 0,
818
+ min_notice INTEGER NOT NULL DEFAULT 120,
819
+ max_advance INTEGER NOT NULL DEFAULT 90,
820
+ per_day_cap INTEGER NOT NULL DEFAULT 0,
821
+ hours TEXT,
822
+ auto_confirm INTEGER NOT NULL DEFAULT 1,
823
+ visibility TEXT NOT NULL DEFAULT 'public',
824
+ ask_phone INTEGER NOT NULL DEFAULT 0,
825
+ questions TEXT NOT NULL DEFAULT '[]',
826
+ confirm_message TEXT NOT NULL DEFAULT '',
827
+ redirect_url TEXT NOT NULL DEFAULT '',
828
+ active INTEGER NOT NULL DEFAULT 1,
829
+ ord INTEGER NOT NULL DEFAULT 0,
830
+ created_at INTEGER NOT NULL,
831
+ updated_at INTEGER NOT NULL,
832
+ UNIQUE (resource_id, slug)
833
+ )`,
834
+ `CREATE TABLE IF NOT EXISTS bookings (
835
+ id TEXT PRIMARY KEY,
836
+ kind TEXT NOT NULL DEFAULT 'booking',
837
+ resource_id TEXT NOT NULL DEFAULT '',
838
+ link_id TEXT NOT NULL DEFAULT '',
839
+ event_id TEXT NOT NULL DEFAULT '',
840
+ status TEXT NOT NULL,
841
+ start_at INTEGER NOT NULL,
842
+ end_at INTEGER NOT NULL,
843
+ hold_start INTEGER NOT NULL,
844
+ hold_end INTEGER NOT NULL,
845
+ timezone TEXT NOT NULL DEFAULT '',
846
+ name TEXT NOT NULL DEFAULT '',
847
+ email TEXT NOT NULL DEFAULT '',
848
+ phone TEXT NOT NULL DEFAULT '',
849
+ seats INTEGER NOT NULL DEFAULT 1,
850
+ answers TEXT NOT NULL DEFAULT '{}',
851
+ notes TEXT NOT NULL DEFAULT '',
852
+ manage_token TEXT NOT NULL DEFAULT '',
853
+ source TEXT NOT NULL DEFAULT 'web',
854
+ reminder_sent_at INTEGER,
855
+ created_at INTEGER NOT NULL,
856
+ updated_at INTEGER NOT NULL
857
+ )`,
858
+ `CREATE INDEX IF NOT EXISTS bookings_resource_start ON bookings (resource_id, start_at)`,
859
+ `CREATE INDEX IF NOT EXISTS bookings_event_start ON bookings (event_id, start_at)`,
860
+ `CREATE INDEX IF NOT EXISTS bookings_manage ON bookings (manage_token)`,
861
+ `ALTER TABLE events ADD COLUMN rsvp TEXT NOT NULL DEFAULT '{}'`
862
+ ]
863
+ },
864
+ EMAIL_MIGRATION,
865
+ { version: 30, statements: [
866
+ `CREATE TABLE mobile_booking_requests (key_hash TEXT PRIMARY KEY, request_hash TEXT NOT NULL, booking_id TEXT NOT NULL, created_at INTEGER NOT NULL)`
867
+ ] },
868
+ // design history: every Publish design snapshots the outgoing live design
869
+ // (theme, overrides, brand images, navigation) so it can be restored
870
+ { version: 31, statements: [
871
+ `CREATE TABLE IF NOT EXISTS design_versions (
872
+ id TEXT PRIMARY KEY,
873
+ created_at INTEGER NOT NULL,
874
+ author TEXT NOT NULL DEFAULT '',
875
+ summary TEXT NOT NULL DEFAULT '',
876
+ snapshot TEXT NOT NULL
877
+ )`,
878
+ `CREATE INDEX IF NOT EXISTS design_versions_created ON design_versions (created_at)`
879
+ ] },
880
+ { version: 32, statements: [
881
+ `ALTER TABLE settings ADD COLUMN section_pages TEXT NOT NULL DEFAULT '{}'`,
882
+ `ALTER TABLE settings ADD COLUMN design_revision INTEGER NOT NULL DEFAULT 0`,
883
+ `CREATE TABLE IF NOT EXISTS design_audit (
884
+ id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, actor TEXT NOT NULL,
885
+ action TEXT NOT NULL, expected_revision INTEGER NOT NULL,
886
+ actual_revision INTEGER NOT NULL CHECK (expected_revision = actual_revision)
887
+ )`
888
+ ] },
889
+ WEBHOOK_MIGRATION,
890
+ LIVE_MIGRATION,
891
+ IMPORT_MIGRATION,
892
+ // translations: per-language words for posts, pages and events (facts stay shared)
893
+ { version: 36, statements: [
894
+ `ALTER TABLE posts ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
895
+ `ALTER TABLE pages ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
896
+ `ALTER TABLE events ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`
897
+ ] },
898
+ PUSH_MIGRATION,
899
+ EDITOR_MIGRATION,
900
+ EDITOR_GRACE_MIGRATION,
901
+ CALENDAR_SETTINGS_MIGRATION,
902
+ ROOM_HOLD_MIGRATION,
903
+ { version: 42, statements: [
904
+ `ALTER TABLE settings ADD COLUMN site_logo_dark TEXT NOT NULL DEFAULT ''`
905
+ ] },
906
+ PLACES_MIGRATION,
907
+ { version: 44, statements: [
908
+ `ALTER TABLE bookings ADD COLUMN form_response TEXT`,
909
+ `CREATE TABLE forms (id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL, purpose TEXT NOT NULL, status TEXT NOT NULL, definition TEXT NOT NULL, draft TEXT, revision INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
910
+ `CREATE TABLE form_submissions (id TEXT PRIMARY KEY, form_id TEXT NOT NULL, response TEXT NOT NULL, created_at INTEGER NOT NULL)`,
911
+ `CREATE INDEX form_submissions_form_created ON form_submissions(form_id, created_at)`
912
+ ] },
913
+ // app access: owner switches + limits, per-link guest change cutoff, and
914
+ // guest sign-in (emailed codes → per-device tokens; secrets stored hashed)
915
+ { version: 45, statements: [
916
+ `ALTER TABLE settings ADD COLUMN mobile_access TEXT NOT NULL DEFAULT '{}'`,
917
+ `ALTER TABLE booking_links ADD COLUMN change_cutoff INTEGER NOT NULL DEFAULT 0`,
918
+ `CREATE TABLE guest_codes (email TEXT PRIMARY KEY, code_hash TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL)`,
919
+ `CREATE TABLE guest_sessions (id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, email TEXT NOT NULL, device_name TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL)`,
920
+ `CREATE INDEX guest_sessions_email ON guest_sessions(email)`,
921
+ `CREATE INDEX bookings_email ON bookings(email)`
922
+ ] },
923
+ // links page revision for whole-page saves (staff app): triggers bump it on
924
+ // every content change, whichever path made it (web admin, API, restore);
925
+ // click counts don't. revision_checks makes a stale save fail inside its
926
+ // own batch (CHECK fails → the whole batch rolls back).
927
+ { version: 46, statements: [
928
+ `ALTER TABLE settings ADD COLUMN links_revision INTEGER NOT NULL DEFAULT 0`,
929
+ `CREATE TABLE revision_checks (expected INTEGER NOT NULL, actual INTEGER NOT NULL CHECK (expected = actual))`,
930
+ `CREATE TRIGGER links_revision_insert AFTER INSERT ON links BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
931
+ `CREATE TRIGGER links_revision_delete AFTER DELETE ON links BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
932
+ `CREATE TRIGGER links_revision_update AFTER UPDATE OF kind, label, url, icon, html, meta, ord ON links BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
933
+ `CREATE TRIGGER links_revision_profile AFTER UPDATE OF links_name, links_bio, links_avatar ON settings
934
+ WHEN OLD.links_name IS NOT NEW.links_name OR OLD.links_bio IS NOT NEW.links_bio OR OLD.links_avatar IS NOT NEW.links_avatar
935
+ BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`
936
+ ] },
937
+ // Settings → Monitoring: Uptime Kuma push URL + status page
938
+ { version: 47, statements: [
939
+ `ALTER TABLE settings ADD COLUMN monitoring TEXT NOT NULL DEFAULT '{}'`
940
+ ] },
941
+ { version: 48, statements: [
942
+ `CREATE TABLE auth_owner_checks (allowed INTEGER CONSTRAINT keep_clerk_owner CHECK(allowed=1))`,
943
+ `ALTER TABLE editor_codes ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'builtin'`,
944
+ `ALTER TABLE editor_devices ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'builtin'`,
945
+ `CREATE TABLE auth_identities (issuer TEXT NOT NULL, subject TEXT NOT NULL, user_id TEXT NOT NULL, disabled INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(issuer,subject), UNIQUE(issuer,user_id))`,
946
+ `CREATE TABLE auth_bootstrap (issuer TEXT PRIMARY KEY)`,
947
+ `CREATE TABLE auth_invitations (id TEXT PRIMARY KEY, issuer TEXT NOT NULL, email TEXT NOT NULL, name TEXT NOT NULL, role TEXT NOT NULL, state TEXT NOT NULL, remote_id TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL)`,
948
+ `CREATE UNIQUE INDEX auth_invitation_pending ON auth_invitations(issuer,email) WHERE state IN ('sending','pending')`,
949
+ `CREATE TABLE auth_mobile (kind TEXT NOT NULL, id TEXT NOT NULL, issuer TEXT NOT NULL, subject TEXT NOT NULL, session_id TEXT NOT NULL, PRIMARY KEY(kind,id))`,
950
+ `CREATE TRIGGER auth_user_deleted AFTER DELETE ON users BEGIN UPDATE auth_identities SET disabled=1 WHERE user_id=OLD.id; END`,
951
+ `CREATE TRIGGER auth_code_deleted AFTER DELETE ON editor_codes BEGIN DELETE FROM auth_mobile WHERE kind='code' AND id=OLD.hash; END`,
952
+ `CREATE TRIGGER auth_device_deleted AFTER DELETE ON editor_devices BEGIN DELETE FROM auth_mobile WHERE kind='device' AND id=OLD.id; END`
953
+ ] },
954
+ // Public cache: content_version goes up on every change a visitor could
955
+ // see, from any write path (admin, editor API, scheduler, design publish,
956
+ // restore/import, raw SQL), because triggers do the bumping. Cached pages
957
+ // and app ETags are keyed by it; the purge loop records how far it got.
958
+ { version: 49, statements: [
959
+ `ALTER TABLE settings ADD COLUMN content_version INTEGER NOT NULL DEFAULT 0`,
960
+ `ALTER TABLE settings ADD COLUMN cache_purged_version INTEGER NOT NULL DEFAULT 0`,
961
+ // start at a random point so two databases (a reinstall, a test run)
962
+ // never hand out the same version, and an old ETag can't match by chance
963
+ `UPDATE settings SET content_version = abs(random() % 1000000000), cache_purged_version = content_version WHERE id = 1`,
964
+ ...Object.entries(CONTENT_VERSION_COLUMNS).flatMap(([table, columns]) => contentVersionTriggers(table, columns))
965
+ ] },
966
+ // scheduled off-site backups (lib/backup/offsite.ts): last run + lease
967
+ { version: 50, statements: [
968
+ `CREATE TABLE backup_state (id INTEGER PRIMARY KEY CHECK (id = 1), last_at INTEGER NOT NULL DEFAULT 0, lease_until INTEGER NOT NULL DEFAULT 0,
969
+ last_key TEXT NOT NULL DEFAULT '', last_error TEXT NOT NULL DEFAULT '', failed_at INTEGER NOT NULL DEFAULT 0)`,
970
+ `INSERT INTO backup_state (id) VALUES (1)`
971
+ ] }
972
+ ];
973
+ var LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
974
+
975
+ // src/lib/repo/migrate-runner.ts
976
+ var MIGRATION_LOCK_TABLE = `CREATE TABLE IF NOT EXISTS migration_lock (
977
+ id INTEGER PRIMARY KEY CHECK (id = 1), owner TEXT NOT NULL, expires_at INTEGER NOT NULL
978
+ )`;
979
+ var LOCK_TTL_MS = 10 * 6e4;
980
+ var MigrationLockTimeout = class extends Error {
981
+ };
982
+ async function appliedVersion(db) {
983
+ const r = (await db.all("SELECT MAX(version) AS v FROM migrations"))[0];
984
+ return Number(r?.v ?? 0);
985
+ }
986
+ async function pendingMigrations(db) {
987
+ await db.run(MIGRATIONS_TABLE);
988
+ let applied = await appliedVersion(db);
989
+ if (applied === 0 && (await db.all("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'pages'")).length) applied = 1;
990
+ return { applied, pending: MIGRATIONS.filter((m) => m.version > applied) };
991
+ }
992
+ async function runMigrations(db, opts = {}) {
993
+ const now4 = opts.now ?? Date.now;
994
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
995
+ const first = await pendingMigrations(db);
996
+ if (!first.pending.length) return { from: first.applied, to: first.applied, applied: [] };
997
+ const refused = first.applied > 0 && first.pending.find((m) => opts.refuse?.includes(m.version));
998
+ if (refused) return { from: first.applied, to: first.applied, applied: [], deferred: refused.version };
999
+ await db.run(MIGRATION_LOCK_TABLE);
1000
+ const owner = crypto.randomUUID();
1001
+ const deadline = now4() + (opts.waitMs ?? 6e4);
1002
+ let waited = false;
1003
+ for (; ; ) {
1004
+ const t = now4();
1005
+ await db.run(
1006
+ `INSERT INTO migration_lock (id, owner, expires_at) VALUES (1, ?, ?)
1007
+ ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at WHERE migration_lock.expires_at < ?`,
1008
+ [owner, t + LOCK_TTL_MS, t]
1009
+ );
1010
+ const holder = (await db.all("SELECT owner FROM migration_lock WHERE id = 1"))[0];
1011
+ if (holder?.owner === owner) break;
1012
+ if (t >= deadline) throw new MigrationLockTimeout("Another process is migrating this database. Try again when it finishes.");
1013
+ if (!waited) {
1014
+ opts.log?.("Waiting for another migration to finish\u2026");
1015
+ waited = true;
1016
+ }
1017
+ await sleep(500);
1018
+ }
1019
+ try {
1020
+ const { applied, pending } = await pendingMigrations(db);
1021
+ if (applied === 1 && !await appliedVersion(db)) {
1022
+ await db.run("INSERT INTO migrations (version, applied_at) VALUES (1, ?)", [now4()]);
1023
+ }
1024
+ for (const m of pending) {
1025
+ opts.log?.(`Applying migration ${m.version}`);
1026
+ await db.batch([
1027
+ ...m.statements.map((sql) => ({ sql })),
1028
+ { sql: "INSERT INTO migrations (version, applied_at) VALUES (?, ?)", args: [m.version, now4()] }
1029
+ ]);
1030
+ }
1031
+ return { from: applied, to: pending.length ? pending[pending.length - 1].version : applied, applied: pending.map((m) => m.version) };
1032
+ } finally {
1033
+ await db.run("DELETE FROM migration_lock WHERE id = 1 AND owner = ?", [owner]).catch(() => {
1034
+ });
1035
+ }
1036
+ }
1037
+ export {
1038
+ LATEST_VERSION,
1039
+ MIGRATIONS,
1040
+ MIGRATION_LOCK_TABLE,
1041
+ MigrationLockTimeout,
1042
+ pendingMigrations,
1043
+ runMigrations
1044
+ };