@acms-subir/core 0.3.9 → 0.3.11

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 (161) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +2 -1
  4. package/cli/main.mjs +17 -3
  5. package/cli/migrate.mjs +102 -0
  6. package/cli/migrations.generated.mjs +1044 -0
  7. package/cli/mobile-doctor.mjs +9 -0
  8. package/cli/monitor.mjs +189 -0
  9. package/cli/preview.mjs +1 -1
  10. package/deploy/Dockerfile +5 -1
  11. package/deploy/compose.yml +21 -0
  12. package/docs/live-video.md +1 -1
  13. package/docs/mobile.md +23 -2
  14. package/docs/theme-authoring.md +6 -0
  15. package/integration.mjs +74 -4
  16. package/migrate.mjs +5 -0
  17. package/package.json +9 -11
  18. package/src/actions/index.ts +11 -31
  19. package/src/components/AppAccess.tsx +117 -0
  20. package/src/components/DesignManager.tsx +29 -10
  21. package/src/components/EmailSettings.tsx +53 -11
  22. package/src/components/MonitoringSettings.tsx +112 -0
  23. package/src/components/ThemeTransfer.tsx +211 -0
  24. package/src/components/auth/ClerkSession.astro +80 -0
  25. package/src/components/auth/ReverifyForms.astro +61 -0
  26. package/src/components/auth/clerk.ts +169 -0
  27. package/src/components/import/ImportExport.tsx +16 -6
  28. package/src/components/import/runner.ts +3 -0
  29. package/src/layouts/Admin.astro +42 -12
  30. package/src/layouts/Site.astro +6 -1
  31. package/src/lib/api.ts +8 -4
  32. package/src/lib/auth/accounts.ts +94 -0
  33. package/src/lib/auth/config.ts +22 -0
  34. package/src/lib/auth/login-limit.ts +15 -0
  35. package/src/lib/auth/request.ts +161 -0
  36. package/src/lib/auth/store.ts +72 -0
  37. package/src/lib/auth.ts +3 -0
  38. package/src/lib/backup/archive.ts +26 -0
  39. package/src/lib/backup/content.ts +1 -1
  40. package/src/lib/backup/offsite.ts +173 -0
  41. package/src/lib/backup/store.ts +28 -0
  42. package/src/lib/booking/availability.ts +15 -2
  43. package/src/lib/booking/service.ts +53 -1
  44. package/src/lib/cache/config.ts +7 -0
  45. package/src/lib/cache/memory.ts +39 -0
  46. package/src/lib/cache/policy.ts +89 -0
  47. package/src/lib/cache/purge.ts +89 -0
  48. package/src/lib/calendar/admin.ts +41 -0
  49. package/src/lib/designPreview.ts +2 -2
  50. package/src/lib/dynamicBlocks.ts +1 -1
  51. package/src/lib/editor/auth.ts +35 -5
  52. package/src/lib/editor/http.ts +4 -1
  53. package/src/lib/editor/me.ts +4 -0
  54. package/src/lib/editor/openapi.ts +49 -1
  55. package/src/lib/editor/roles.ts +20 -0
  56. package/src/lib/editor/schedule.ts +340 -0
  57. package/src/lib/editor/schemas.ts +36 -1
  58. package/src/lib/editor/site-data.ts +110 -0
  59. package/src/lib/editor/store.ts +13 -7
  60. package/src/lib/editor/token-route.ts +2 -1
  61. package/src/lib/email/accounts.ts +4 -0
  62. package/src/lib/email/service.ts +9 -1
  63. package/src/lib/email/store.ts +10 -0
  64. package/src/lib/email/templates.ts +8 -1
  65. package/src/lib/email/types.ts +1 -0
  66. package/src/lib/forms/store.ts +5 -0
  67. package/src/lib/mobile/access-admin.ts +34 -0
  68. package/src/lib/mobile/access.ts +28 -0
  69. package/src/lib/mobile/calendar.ts +26 -13
  70. package/src/lib/mobile/content.ts +21 -1
  71. package/src/lib/mobile/forms.ts +77 -0
  72. package/src/lib/mobile/guest-store.ts +62 -0
  73. package/src/lib/mobile/guest.ts +160 -0
  74. package/src/lib/mobile/http.ts +6 -5
  75. package/src/lib/mobile/links.ts +69 -0
  76. package/src/lib/mobile/manage.ts +114 -0
  77. package/src/lib/mobile/openapi.ts +40 -4
  78. package/src/lib/mobile/route.ts +63 -4
  79. package/src/lib/mobile/schemas.ts +52 -1
  80. package/src/lib/mobile/search.ts +95 -0
  81. package/src/lib/mobile/site.ts +8 -2
  82. package/src/lib/monitoring.ts +171 -0
  83. package/src/lib/net.ts +82 -14
  84. package/src/lib/repo/migrate-runner.ts +96 -0
  85. package/src/lib/repo/migrations.ts +104 -0
  86. package/src/lib/repo/sql.ts +122 -44
  87. package/src/lib/repo/sqlite.ts +14 -1
  88. package/src/lib/repo/types.ts +31 -3
  89. package/src/lib/scheduler.ts +14 -0
  90. package/src/lib/schema.ts +35 -1
  91. package/src/lib/theme-transfer.ts +163 -0
  92. package/src/lib/theme.ts +5 -0
  93. package/src/lib/webhooks.ts +7 -2
  94. package/src/middleware.ts +41 -11
  95. package/src/pages/[...slug].astro +3 -2
  96. package/src/pages/account/[action].astro +6 -0
  97. package/src/pages/admin/app-auth.astro +21 -7
  98. package/src/pages/admin/calendar/resources/link.astro +2 -0
  99. package/src/pages/admin/design.astro +2 -1
  100. package/src/pages/admin/import.astro +5 -3
  101. package/src/pages/admin/index.astro +3 -2
  102. package/src/pages/admin/integrations.astro +3 -2
  103. package/src/pages/admin/login.astro +236 -25
  104. package/src/pages/admin/logout.ts +16 -2
  105. package/src/pages/admin/mobile.astro +6 -2
  106. package/src/pages/admin/settings.astro +43 -5
  107. package/src/pages/admin/users.astro +350 -59
  108. package/src/pages/api/backup.ts +10 -26
  109. package/src/pages/api/design/export.ts +72 -0
  110. package/src/pages/api/design/import.ts +41 -0
  111. package/src/pages/api/email/settings.ts +9 -5
  112. package/src/pages/api/health.ts +17 -0
  113. package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
  114. package/src/pages/api/live/settings.ts +2 -2
  115. package/src/pages/api/media/upload.ts +2 -2
  116. package/src/pages/api/mobile/access.ts +22 -0
  117. package/src/pages/api/monitoring.ts +22 -0
  118. package/src/pages/api/ops/status.ts +2 -1
  119. package/src/pages/api/push/admin.ts +3 -2
  120. package/src/pages/api/restore.ts +6 -3
  121. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +7 -1
  122. package/src/pages/api/v1/admin/bookings/index.ts +10 -2
  123. package/src/pages/api/v1/admin/calendars/[id].ts +16 -0
  124. package/src/pages/api/v1/admin/calendars/index.ts +12 -0
  125. package/src/pages/api/v1/admin/forms/[id]/responses.ts +7 -0
  126. package/src/pages/api/v1/admin/forms/index.ts +6 -0
  127. package/src/pages/api/v1/admin/links.ts +13 -0
  128. package/src/pages/api/v1/admin/resources/[id]/blocks/[blockId].ts +10 -0
  129. package/src/pages/api/v1/admin/resources/[id]/blocks/index.ts +12 -0
  130. package/src/pages/api/v1/admin/resources/[id]/index.ts +17 -0
  131. package/src/pages/api/v1/admin/resources/[id]/links/[linkId].ts +16 -0
  132. package/src/pages/api/v1/admin/resources/[id]/links/index.ts +12 -0
  133. package/src/pages/api/v1/admin/resources/index.ts +12 -0
  134. package/src/pages/api/v1/admin/schedule.ts +7 -0
  135. package/src/pages/api/v1/bookings/manage/availability.ts +6 -0
  136. package/src/pages/api/v1/bookings/manage/cancel.ts +6 -0
  137. package/src/pages/api/v1/bookings/manage/ics.ts +6 -0
  138. package/src/pages/api/v1/bookings/manage/index.ts +6 -0
  139. package/src/pages/api/v1/bookings/manage/reschedule.ts +6 -0
  140. package/src/pages/api/v1/calendars.ts +1 -1
  141. package/src/pages/api/v1/forms/[id]/index.ts +7 -0
  142. package/src/pages/api/v1/forms/[id]/responses.ts +6 -0
  143. package/src/pages/api/v1/guest/bookings.ts +6 -0
  144. package/src/pages/api/v1/guest/code.ts +6 -0
  145. package/src/pages/api/v1/guest/revoke.ts +6 -0
  146. package/src/pages/api/v1/guest/token.ts +6 -0
  147. package/src/pages/api/v1/links.ts +7 -0
  148. package/src/pages/api/v1/openapi.json.ts +1 -1
  149. package/src/pages/api/v1/push/topics.ts +1 -1
  150. package/src/pages/api/v1/resources.ts +1 -1
  151. package/src/pages/api/v1/search.ts +7 -0
  152. package/src/pages/api/v1/theme.ts +1 -1
  153. package/src/pages/blog/[slug].astro +3 -2
  154. package/src/pages/book/[resource]/[link].astro +3 -3
  155. package/src/pages/book/manage/[token].astro +11 -7
  156. package/src/pages/events/[slug].astro +3 -2
  157. package/src/pages/preview/[type]/[id].astro +3 -3
  158. package/src/styles/admin.css +169 -16
  159. package/src/styles/email-settings.css +6 -0
  160. package/src/virtual.d.ts +1 -0
  161. package/src/worker.ts +7 -1
@@ -19,6 +19,51 @@ export interface Migration {
19
19
  statements: string[];
20
20
  }
21
21
 
22
+ // Columns whose change a visitor can see (v49 triggers bump content_version).
23
+ // Left out on purpose: drafts and autosave bookkeeping (draft, updated_at,
24
+ // revision counters), click counts, credentials, and staff-only settings.
25
+ // Frozen with v49 — a later column joins by a new migration that recreates
26
+ // that table's triggers (the cache test fails until every column is sorted).
27
+ export const CONTENT_VERSION_COLUMNS: Record<string, string[]> = {
28
+ settings: ['site_title', 'tagline', 'links_name', 'links_bio', 'links_avatar', 'footer_text', 'active_theme', 'design_overrides', 'site_logo',
29
+ 'site_url', 'site_favicon', 'site_cover', 'announcement', 'head_code', 'footer_code', 'timezone', 'locale', 'analytics', 'section_pages',
30
+ 'live', 'push', 'calendar', 'site_logo_dark', 'mobile_access'],
31
+ pages: ['id', 'slug', 'title', 'status', 'is_home', 'blocks', 'seo_title', 'seo_desc', 'body_json', 'body_html', 'template', 'locale', 'translations'],
32
+ posts: ['id', 'slug', 'title', 'body', 'tags', 'cover', 'status', 'published_at', 'body_json', 'body_html', 'excerpt', 'cover_alt', 'seo_title',
33
+ 'seo_desc', 'scheduled_at', 'author_id', 'featured', 'template', 'collection', 'translations'],
34
+ post_authors: ['post_id', 'author_id', 'ord'],
35
+ events: ['id', 'slug', 'title', 'body_json', 'body_html', 'excerpt', 'cover', 'cover_alt', 'seo_title', 'seo_desc', 'status', 'featured', 'tags',
36
+ 'template', 'starts', 'ends', 'all_day', 'timezone', 'rrule', 'exdates', 'cancelled', 'location', 'cta', 'calendar_ids', 'fields', 'source_uid',
37
+ 'start_at', 'end_at', 'published_at', 'scheduled_at', 'rsvp', 'translations', 'room_hold'],
38
+ calendars: ['id', 'slug', 'name', 'color', 'description', 'kind', 'feed_url', 'visibility', 'feed_token', 'ord'],
39
+ places: ['id', 'name', 'address', 'url', 'lat', 'lng', 'notes', 'is_default', 'map_image', 'map_image_dark', 'ord'],
40
+ resources: ['id', 'slug', 'name', 'kind', 'description', 'photo', 'capacity', 'amenities', 'timezone', 'hours', 'overrides', 'busy_feeds',
41
+ 'visibility', 'show_on_calendar', 'feed_token', 'ord', 'place_id'],
42
+ booking_links: ['id', 'resource_id', 'slug', 'name', 'description', 'mode', 'duration', 'lengths', 'min_days', 'max_days', 'step', 'buffer_before',
43
+ 'buffer_after', 'min_notice', 'max_advance', 'per_day_cap', 'hours', 'auto_confirm', 'visibility', 'ask_phone', 'questions', 'confirm_message',
44
+ 'redirect_url', 'active', 'ord', 'approval', 'approval_rules', 'change_cutoff'],
45
+ links: ['id', 'label', 'url', 'icon', 'ord', 'kind', 'html', 'meta'],
46
+ nav_items: ['id', 'label', 'url', 'area', 'ord'],
47
+ tags: ['id', 'name', 'slug', 'description'],
48
+ media: ['id', 'key', 'filename', 'mime', 'width', 'height', 'size', 'alt', 'variants'],
49
+ redirects: ['id', 'from_path', 'to_path', 'code'],
50
+ users: ['id', 'name', 'email', 'role', 'bio', 'avatar', 'slug'],
51
+ forms: ['id', 'name', 'kind', 'purpose', 'status', 'definition'],
52
+ };
53
+
54
+ const BUMP_CONTENT_VERSION = 'UPDATE settings SET content_version = content_version + 1 WHERE id = 1;';
55
+ function contentVersionTriggers(table: string, columns: string[]): string[] {
56
+ const changed = columns.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(' OR ');
57
+ const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(', ')} ON ${table} WHEN ${changed} BEGIN ${BUMP_CONTENT_VERSION} END`;
58
+ // the settings row itself is created once and never deleted
59
+ if (table === 'settings') return [update];
60
+ return [
61
+ `CREATE TRIGGER content_version_${table}_insert AFTER INSERT ON ${table} BEGIN ${BUMP_CONTENT_VERSION} END`,
62
+ `CREATE TRIGGER content_version_${table}_delete AFTER DELETE ON ${table} BEGIN ${BUMP_CONTENT_VERSION} END`,
63
+ update,
64
+ ];
65
+ }
66
+
22
67
  export const MIGRATIONS_TABLE = `CREATE TABLE IF NOT EXISTS migrations (
23
68
  version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL
24
69
  )`;
@@ -526,6 +571,65 @@ export const MIGRATIONS: Migration[] = [
526
571
  `CREATE TABLE form_submissions (id TEXT PRIMARY KEY, form_id TEXT NOT NULL, response TEXT NOT NULL, created_at INTEGER NOT NULL)`,
527
572
  `CREATE INDEX form_submissions_form_created ON form_submissions(form_id, created_at)`,
528
573
  ] },
574
+ // app access: owner switches + limits, per-link guest change cutoff, and
575
+ // guest sign-in (emailed codes → per-device tokens; secrets stored hashed)
576
+ { version: 45, statements: [
577
+ `ALTER TABLE settings ADD COLUMN mobile_access TEXT NOT NULL DEFAULT '{}'`,
578
+ `ALTER TABLE booking_links ADD COLUMN change_cutoff INTEGER NOT NULL DEFAULT 0`,
579
+ `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)`,
580
+ `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)`,
581
+ `CREATE INDEX guest_sessions_email ON guest_sessions(email)`,
582
+ `CREATE INDEX bookings_email ON bookings(email)`,
583
+ ] },
584
+ // links page revision for whole-page saves (staff app): triggers bump it on
585
+ // every content change, whichever path made it (web admin, API, restore);
586
+ // click counts don't. revision_checks makes a stale save fail inside its
587
+ // own batch (CHECK fails → the whole batch rolls back).
588
+ { version: 46, statements: [
589
+ `ALTER TABLE settings ADD COLUMN links_revision INTEGER NOT NULL DEFAULT 0`,
590
+ `CREATE TABLE revision_checks (expected INTEGER NOT NULL, actual INTEGER NOT NULL CHECK (expected = actual))`,
591
+ `CREATE TRIGGER links_revision_insert AFTER INSERT ON links BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
592
+ `CREATE TRIGGER links_revision_delete AFTER DELETE ON links BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
593
+ `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`,
594
+ `CREATE TRIGGER links_revision_profile AFTER UPDATE OF links_name, links_bio, links_avatar ON settings
595
+ 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
596
+ BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
597
+ ] },
598
+ // Settings → Monitoring: Uptime Kuma push URL + status page
599
+ { version: 47, statements: [
600
+ `ALTER TABLE settings ADD COLUMN monitoring TEXT NOT NULL DEFAULT '{}'`,
601
+ ] },
602
+ { version: 48, statements: [
603
+ `CREATE TABLE auth_owner_checks (allowed INTEGER CONSTRAINT keep_clerk_owner CHECK(allowed=1))`,
604
+ `ALTER TABLE editor_codes ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'builtin'`,
605
+ `ALTER TABLE editor_devices ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'builtin'`,
606
+ `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))`,
607
+ `CREATE TABLE auth_bootstrap (issuer TEXT PRIMARY KEY)`,
608
+ `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)`,
609
+ `CREATE UNIQUE INDEX auth_invitation_pending ON auth_invitations(issuer,email) WHERE state IN ('sending','pending')`,
610
+ `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))`,
611
+ `CREATE TRIGGER auth_user_deleted AFTER DELETE ON users BEGIN UPDATE auth_identities SET disabled=1 WHERE user_id=OLD.id; END`,
612
+ `CREATE TRIGGER auth_code_deleted AFTER DELETE ON editor_codes BEGIN DELETE FROM auth_mobile WHERE kind='code' AND id=OLD.hash; END`,
613
+ `CREATE TRIGGER auth_device_deleted AFTER DELETE ON editor_devices BEGIN DELETE FROM auth_mobile WHERE kind='device' AND id=OLD.id; END`,
614
+ ] },
615
+ // Public cache: content_version goes up on every change a visitor could
616
+ // see, from any write path (admin, editor API, scheduler, design publish,
617
+ // restore/import, raw SQL), because triggers do the bumping. Cached pages
618
+ // and app ETags are keyed by it; the purge loop records how far it got.
619
+ { version: 49, statements: [
620
+ `ALTER TABLE settings ADD COLUMN content_version INTEGER NOT NULL DEFAULT 0`,
621
+ `ALTER TABLE settings ADD COLUMN cache_purged_version INTEGER NOT NULL DEFAULT 0`,
622
+ // start at a random point so two databases (a reinstall, a test run)
623
+ // never hand out the same version, and an old ETag can't match by chance
624
+ `UPDATE settings SET content_version = abs(random() % 1000000000), cache_purged_version = content_version WHERE id = 1`,
625
+ ...Object.entries(CONTENT_VERSION_COLUMNS).flatMap(([table, columns]) => contentVersionTriggers(table, columns)),
626
+ ] },
627
+ // scheduled off-site backups (lib/backup/offsite.ts): last run + lease
628
+ { version: 50, statements: [
629
+ `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,
630
+ last_key TEXT NOT NULL DEFAULT '', last_error TEXT NOT NULL DEFAULT '', failed_at INTEGER NOT NULL DEFAULT 0)`,
631
+ `INSERT INTO backup_state (id) VALUES (1)`,
632
+ ] },
529
633
  ];
530
634
 
531
635
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -1,7 +1,11 @@
1
+ import { AUTH } from '../auth/config';
2
+ import { AuthStore } from '../auth/store';
1
3
  import { FormStore, formInsert, submissionInsert } from '../forms/store';
4
+ import { GuestStore } from '../mobile/guest-store';
2
5
  import { LiveStore } from '../live/store';
3
6
  import { PushStore } from '../push/store';
4
7
  import { EditorStore } from '../editor/store';
8
+ import { BackupStore } from '../backup/store';
5
9
  import { LiveConfig } from '../live/types';
6
10
  import { ApiError } from '../api';
7
11
  import { EmailStore, bookingJobs } from '../email/store';
@@ -10,7 +14,7 @@ import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings,
10
14
  import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
11
15
  import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
12
16
  import { cleanRrule } from '../calendar/rrule';
13
- import { MIGRATIONS, MIGRATIONS_TABLE } from './migrations';
17
+ import { runMigrations } from './migrate-runner';
14
18
  import { htmlToPlaintext } from '../plaintext';
15
19
  import { SEED } from '../brand';
16
20
  import { CONTENT, postsIndexUrl } from '../content';
@@ -183,7 +187,7 @@ function rowToLink2(r: Row): BookingLink {
183
187
  id: r.id, resourceId: r.resource_id, slug: r.slug, name: r.name, description: r.description ?? '', mode: r.mode ?? 'slots',
184
188
  duration: Number(r.duration), lengths: json(r.lengths, []), minDays: Number(r.min_days), maxDays: Number(r.max_days),
185
189
  step: Number(r.step), bufferBefore: Number(r.buffer_before), bufferAfter: Number(r.buffer_after),
186
- minNotice: Number(r.min_notice), maxAdvance: Number(r.max_advance), perDayCap: Number(r.per_day_cap),
190
+ minNotice: Number(r.min_notice), maxAdvance: Number(r.max_advance), perDayCap: Number(r.per_day_cap), changeCutoff: Number(r.change_cutoff ?? 0),
187
191
  hours: r.hours ? json(r.hours, null) : null, autoConfirm: Boolean(r.auto_confirm), visibility: r.visibility ?? 'public',
188
192
  approval: r.approval ?? 'default', approvalRules: json(r.approval_rules, {}),
189
193
  askPhone: Boolean(r.ask_phone), questions: json(r.questions, []), confirmMessage: r.confirm_message ?? '',
@@ -257,18 +261,24 @@ function safeSnippet(raw: unknown): string {
257
261
  export class SqlRepository implements ContentRepository {
258
262
  private driver!: SqlDriver;
259
263
  private ready: Promise<void>;
264
+ readonly auth: AuthStore;
260
265
  readonly email: EmailStore;
261
266
  readonly forms: FormStore;
267
+ readonly guests: GuestStore;
262
268
  readonly live: LiveStore;
263
269
  readonly push: PushStore;
264
270
  readonly editor: EditorStore;
271
+ readonly backups: BackupStore;
265
272
 
266
273
  constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
267
274
  this.ready = this.init(driver);
268
275
  this.forms = new FormStore(async () => { await this.ready; return this.driver; });
276
+ this.guests = new GuestStore(async () => { await this.ready; return this.driver; });
269
277
  this.live = new LiveStore(async () => { await this.ready; return this.driver; });
270
278
  this.push = new PushStore(async () => { await this.ready; return this.driver; });
271
279
  this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
280
+ this.backups = new BackupStore(async () => { await this.ready; return this.driver; });
281
+ this.auth = new AuthStore(async () => { await this.ready; return this.driver; });
272
282
  this.email = new EmailStore(async () => { await this.ready; return this.driver; });
273
283
  }
274
284
 
@@ -284,27 +294,11 @@ export class SqlRepository implements ContentRepository {
284
294
  }
285
295
 
286
296
  private async migrate() {
287
- await this.driver.run(MIGRATIONS_TABLE);
288
- const r = await this.first('SELECT MAX(version) AS v FROM migrations');
289
- let applied = Number(r?.v ?? 0);
290
- if (applied === 0) {
291
- // Pre-migrations DB: the baseline tables already exist, mark v1 applied.
292
- const baseline = await this.first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'pages'");
293
- if (baseline) {
294
- await this.driver.run('INSERT INTO migrations (version, applied_at) VALUES (1, ?)', [Date.now()]);
295
- applied = 1;
296
- }
297
- }
298
- const pending = MIGRATIONS.filter((m) => m.version > applied);
299
- for (const m of pending) {
300
- await this.driver.batch([
301
- ...m.statements.map((sql) => ({ sql })),
302
- { sql: 'INSERT INTO migrations (version, applied_at) VALUES (?, ?)', args: [m.version, Date.now()] },
303
- ]);
304
- }
297
+ // under the shared migration lock (also used by `acms migrate`)
298
+ const { applied } = await runMigrations(this.driver);
305
299
  // v21 introduced the search index; plaintext can't be derived in SQL, so
306
300
  // the backfill runs here once, right after that version lands.
307
- if (pending.some((m) => m.version === 21)) await this.reindexSearch();
301
+ if (applied.includes(21)) await this.reindexSearch();
308
302
  }
309
303
 
310
304
  private async seed() {
@@ -317,7 +311,8 @@ export class SqlRepository implements ContentRepository {
317
311
  { id: uuid(), type: 'text', config: { markdown: 'This home page was built in the **widget editor** — open `/admin` to change it.' } },
318
312
  ];
319
313
  await this.driver.batch([
320
- { sql: 'INSERT INTO settings (id, site_title, tagline) VALUES (1, ?, ?)', args: [SEED.siteTitle, SEED.tagline] },
314
+ // random start: see migration 49
315
+ { sql: 'INSERT INTO settings (id, site_title, tagline, content_version) VALUES (1, ?, ?, abs(random() % 1000000000))', args: [SEED.siteTitle, SEED.tagline] },
321
316
  {
322
317
  sql: 'INSERT INTO pages (id, slug, title, status, is_home, blocks, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?, ?)',
323
318
  args: [uuid(), 'home', 'Home', 'published', JSON.stringify(homeBlocks), now, now],
@@ -1031,11 +1026,11 @@ export class SqlRepository implements ContentRepository {
1031
1026
  return {
1032
1027
  sql: `INSERT INTO booking_links (id, resource_id, slug, name, description, mode, duration, lengths, min_days, max_days, step, buffer_before,
1033
1028
  buffer_after, min_notice, max_advance, per_day_cap, hours, auto_confirm, visibility, ask_phone, questions, confirm_message, redirect_url,
1034
- active, ord, created_at, updated_at, approval, approval_rules) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1029
+ active, ord, created_at, updated_at, approval, approval_rules, change_cutoff) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1035
1030
  args: [l.id, l.resourceId, l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step,
1036
1031
  l.bufferBefore, l.bufferAfter, l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0,
1037
1032
  l.visibility, l.askPhone ? 1 : 0, JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.createdAt, l.updatedAt,
1038
- l.approval, JSON.stringify(l.approvalRules)],
1033
+ l.approval, JSON.stringify(l.approvalRules), l.changeCutoff],
1039
1034
  };
1040
1035
  }
1041
1036
  private bookingInsert(b: Booking): SqlStatement {
@@ -1126,10 +1121,10 @@ export class SqlRepository implements ContentRepository {
1126
1121
  await this.driver.run(
1127
1122
  `UPDATE booking_links SET slug = ?, name = ?, description = ?, mode = ?, duration = ?, lengths = ?, min_days = ?, max_days = ?, step = ?,
1128
1123
  buffer_before = ?, buffer_after = ?, min_notice = ?, max_advance = ?, per_day_cap = ?, hours = ?, auto_confirm = ?, visibility = ?,
1129
- ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ?, approval = ?, approval_rules = ? WHERE id = ?`,
1124
+ ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ?, approval = ?, approval_rules = ?, change_cutoff = ? WHERE id = ?`,
1130
1125
  [l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step, l.bufferBefore, l.bufferAfter,
1131
1126
  l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0, l.visibility, l.askPhone ? 1 : 0,
1132
- JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, l.approval, JSON.stringify(l.approvalRules), id]
1127
+ JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, l.approval, JSON.stringify(l.approvalRules), l.changeCutoff, id]
1133
1128
  );
1134
1129
  }
1135
1130
  async deleteBookingLink(id: string) {
@@ -1147,9 +1142,12 @@ export class SqlRepository implements ContentRepository {
1147
1142
  if (filter.email) { where.push('email = ?'); args.push(filter.email.toLowerCase()); }
1148
1143
  if (filter.from !== undefined) { where.push('end_at > ?'); args.push(filter.from); }
1149
1144
  if (filter.to !== undefined) { where.push('start_at < ?'); args.push(filter.to); }
1145
+ if (filter.endedBy !== undefined) { where.push('end_at <= ?'); args.push(filter.endedBy); }
1150
1146
  if (filter.statuses?.length) { where.push(`status IN (${filter.statuses.map(() => '?').join(', ')})`); args.push(...filter.statuses); }
1151
1147
  if (filter.kinds?.length) { where.push(`kind IN (${filter.kinds.map(() => '?').join(', ')})`); args.push(...filter.kinds); }
1152
- const rows = await this.driver.all(`SELECT * FROM bookings${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY start_at ASC LIMIT 5000`, args);
1148
+ const dir = filter.newestFirst ? 'DESC' : 'ASC';
1149
+ const limit = Math.min(Math.max(Math.trunc(filter.limit ?? 5000), 1), 5000), offset = Math.max(Math.trunc(filter.offset ?? 0), 0);
1150
+ const rows = await this.driver.all(`SELECT * FROM bookings${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY start_at ${dir}, id ${dir} LIMIT ${limit} OFFSET ${offset}`, args);
1153
1151
  return rows.map(rowToBooking);
1154
1152
  }
1155
1153
  async getBooking(id: string) {
@@ -1163,6 +1161,12 @@ export class SqlRepository implements ContentRepository {
1163
1161
  const r = await this.first('SELECT * FROM bookings WHERE manage_token = ?', [token]);
1164
1162
  return r ? rowToBooking(r) : null;
1165
1163
  }
1164
+ async countBookingsByEmail(email: string, since: number) {
1165
+ await this.ready;
1166
+ // staff bookings made for someone don't use up that person's public allowance
1167
+ const r = await this.first("SELECT COUNT(*) AS n FROM bookings WHERE email = ? AND created_at >= ? AND kind != 'block' AND source != 'admin'", [email.trim().toLowerCase(), since]);
1168
+ return Number(r?.n ?? 0);
1169
+ }
1166
1170
  // One INSERT … SELECT … WHERE NOT EXISTS: the overlap test and the write
1167
1171
  // are a single statement, so two people racing for the same slot can't
1168
1172
  // both win — the loser's insert simply affects no rows.
@@ -1445,6 +1449,30 @@ export class SqlRepository implements ContentRepository {
1445
1449
  await this.ready;
1446
1450
  await this.driver.run('DELETE FROM links WHERE id = ?', [id]);
1447
1451
  }
1452
+ async linksRevision() {
1453
+ await this.ready;
1454
+ return Number((await this.first('SELECT links_revision FROM settings WHERE id = 1'))?.links_revision ?? 0);
1455
+ }
1456
+ async replaceLinks(expectedRevision: number, items: Parameters<ContentRepository['replaceLinks']>[1], profile: Parameters<ContentRepository['replaceLinks']>[2] = {}) {
1457
+ await this.ready;
1458
+ const keep = items.flatMap((i) => (i.keep ? [i.keep] : []));
1459
+ const settings = this.settingsStatement(profile);
1460
+ try {
1461
+ await this.driver.batch([
1462
+ // fails the CHECK (and so the whole batch) when someone saved in between
1463
+ { sql: 'INSERT INTO revision_checks (expected, actual) SELECT ?, links_revision FROM settings WHERE id = 1', args: [expectedRevision] },
1464
+ { sql: 'DELETE FROM revision_checks' },
1465
+ keep.length ? { sql: `DELETE FROM links WHERE id NOT IN (${keep.map(() => '?').join(', ')})`, args: keep } : { sql: 'DELETE FROM links' },
1466
+ ...items.map((i, ord) => i.keep
1467
+ ? { sql: 'UPDATE links SET label = ?, url = ?, icon = ?, html = ?, meta = ?, ord = ? WHERE id = ?', args: [i.label, i.url, i.icon, i.html, JSON.stringify(i.meta), ord, i.keep] }
1468
+ : { sql: 'INSERT INTO links (id, kind, label, url, icon, html, meta, ord) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', args: [crypto.randomUUID(), i.kind, i.label, i.url, i.icon, i.html, JSON.stringify(i.meta), ord] }),
1469
+ ...(settings ? [settings] : []),
1470
+ ]);
1471
+ } catch (e) {
1472
+ if (/CHECK constraint/i.test(String(e))) throw new ApiError(409, 'The links page changed since you opened it. Reload and try again.');
1473
+ throw e;
1474
+ }
1475
+ }
1448
1476
  async trackClick(id: string) {
1449
1477
  await this.ready;
1450
1478
  const r = await this.first('SELECT url FROM links WHERE id = ?', [id]);
@@ -1523,6 +1551,23 @@ export class SqlRepository implements ContentRepository {
1523
1551
  await this.driver.run('DELETE FROM snippets WHERE id = ?', [id]);
1524
1552
  }
1525
1553
 
1554
+ private ownerGuard(id: string): SqlStatement[] {
1555
+ if (AUTH.provider !== 'clerk') return [];
1556
+ // Execute in the same write transaction as demotion/deletion: concurrent
1557
+ // owners cannot each remove the other and leave the site without one.
1558
+ return [{ sql: `INSERT INTO auth_owner_checks(allowed) SELECT 0 FROM users target
1559
+ WHERE target.id=? AND target.role='owner' AND EXISTS (
1560
+ SELECT 1 FROM auth_identities a WHERE a.user_id=target.id AND a.disabled=0 AND NOT EXISTS (
1561
+ SELECT 1 FROM users other JOIN auth_identities b ON b.user_id=other.id
1562
+ WHERE other.id<>target.id AND other.role='owner' AND b.disabled=0 AND b.issuer=a.issuer))`, args: [id] }];
1563
+ }
1564
+ private async guardedUserWrite(statements: SqlStatement[]) {
1565
+ try { await this.driver.batch(statements); }
1566
+ catch (error) {
1567
+ if (String(error).includes('keep_clerk_owner')) throw new Error('Keep at least one linked owner. Add another owner before removing or demoting this account.');
1568
+ throw error;
1569
+ }
1570
+ }
1526
1571
  // ---- users ----
1527
1572
  async listUsers() {
1528
1573
  await this.ready;
@@ -1556,16 +1601,28 @@ export class SqlRepository implements ContentRepository {
1556
1601
  if (data.slug !== undefined) { sets.push('slug = ?'); args.push(data.slug); }
1557
1602
  if (!sets.length) return;
1558
1603
  args.push(id);
1559
- await this.driver.batch([
1604
+ await this.guardedUserWrite([
1605
+ ...(data.role !== undefined && data.role !== 'owner' ? this.ownerGuard(id) : []),
1560
1606
  {sql:`UPDATE users SET ${sets.join(', ')} WHERE id = ?`,args},
1561
1607
  ...(data.passHash === undefined ? [] : [{sql:`INSERT INTO email_jobs(id,dedupe,kind,recipient,payload,config_version,next_at,created_at,updated_at)
1562
1608
  SELECT ?,?,'password-changed',email,'{}',COALESCE((SELECT MAX(version) FROM email_configs),0),?,?,? FROM users WHERE id=?`,
1563
1609
  args:[crypto.randomUUID(),crypto.randomUUID(),Date.now(),Date.now(),Date.now(),id]}]),
1564
1610
  ]);
1565
1611
  }
1612
+ async changeUserEmail(id: string, email: string, { signOut, notify }: { signOut: boolean; notify: boolean }) {
1613
+ await this.ready;
1614
+ const now = Date.now();
1615
+ // the notice is queued before the update so it reads the previous address
1616
+ await this.driver.batch([
1617
+ ...(notify ? [{sql:`INSERT INTO email_jobs(id,dedupe,kind,recipient,payload,config_version,next_at,created_at,updated_at)
1618
+ SELECT ?,?,'email-changed',email,'{}',COALESCE((SELECT MAX(version) FROM email_configs),0),?,?,? FROM users WHERE id=? AND email<>?`,
1619
+ args:[crypto.randomUUID(),crypto.randomUUID(),now,now,now,id,email.toLowerCase()]}] : []),
1620
+ {sql:`UPDATE users SET email = ?${signOut ? ', session_version = session_version + 1' : ''} WHERE id = ?`,args:[email.toLowerCase(),id]},
1621
+ ]);
1622
+ }
1566
1623
  async deleteUser(id: string) {
1567
1624
  await this.ready;
1568
- await this.driver.run('DELETE FROM users WHERE id = ?', [id]);
1625
+ await this.guardedUserWrite([...this.ownerGuard(id), { sql: 'DELETE FROM users WHERE id = ?', args: [id] }]);
1569
1626
  }
1570
1627
  async getUserAuth(email: string) {
1571
1628
  await this.ready;
@@ -1611,8 +1668,12 @@ export class SqlRepository implements ContentRepository {
1611
1668
  }
1612
1669
 
1613
1670
  // ---- search ----
1614
- async searchPublished(query: string, opts?: { locale?: string }): Promise<SearchResults> {
1671
+ async searchPublished(query: string, opts?: { locale?: string; limit?: number; collections?: string[] }): Promise<SearchResults> {
1615
1672
  await this.ready;
1673
+ // per-kind cap; the public API widens it for deeper result pages
1674
+ const limit = Math.min(Math.max(Math.trunc(opts?.limit ?? 50), 1), 200);
1675
+ const inCollections = opts?.collections ? ` AND p.collection IN (${opts.collections.map(() => '?').join(', ') || 'NULL'})` : '';
1676
+ const collectionArgs = opts?.collections ?? [];
1616
1677
  const trimmed = query.trim();
1617
1678
  if (!trimmed) return { posts: [], pages: [], events: [], tags: [], authors: [] };
1618
1679
  const locale = opts?.locale ?? '';
@@ -1623,29 +1684,29 @@ export class SqlRepository implements ContentRepository {
1623
1684
  [postRows, pageRows, eventRows] = await Promise.all([
1624
1685
  this.driver.all(
1625
1686
  `SELECT p.*, ${SNIPPET} AS snip FROM search_index JOIN posts p ON p.id = search_index.ref_id
1626
- WHERE search_index MATCH ? AND search_index.kind = 'post' AND p.status = 'published'
1627
- ORDER BY ${RANK} LIMIT 50`,
1628
- [ftsQuery(trimmed)]
1687
+ WHERE search_index MATCH ? AND search_index.kind = 'post' AND p.status = 'published'${inCollections}
1688
+ ORDER BY ${RANK} LIMIT ${limit}`,
1689
+ [ftsQuery(trimmed), ...collectionArgs]
1629
1690
  ),
1630
1691
  // locale is a boost, never a filter: the visitor's language sorts
1631
1692
  // first, every locale stays visible (en/es cover different topics)
1632
1693
  this.driver.all(
1633
1694
  `SELECT g.*, ${SNIPPET} AS snip FROM search_index JOIN pages g ON g.id = search_index.ref_id
1634
1695
  WHERE search_index MATCH ? AND search_index.kind = 'page' AND g.status = 'published'
1635
- ORDER BY (search_index.locale = ?) DESC, ${RANK} LIMIT 50`,
1696
+ ORDER BY (search_index.locale = ?) DESC, ${RANK} LIMIT ${limit}`,
1636
1697
  [ftsQuery(trimmed), locale]
1637
1698
  ),
1638
1699
  this.driver.all(
1639
1700
  `SELECT e.*, ${SNIPPET} AS snip FROM search_index JOIN events e ON e.id = search_index.ref_id
1640
1701
  WHERE search_index MATCH ? AND search_index.kind = 'event' AND e.status = 'published'
1641
- ORDER BY ${RANK} LIMIT 50`,
1702
+ ORDER BY ${RANK} LIMIT ${limit}`,
1642
1703
  [ftsQuery(trimmed)]
1643
1704
  ),
1644
1705
  ]);
1645
1706
  } catch {
1646
1707
  // FTS5 unavailable or a query form it rejects — degrade to the old
1647
1708
  // LIKE scan (recency-ordered, snippets fall back to excerpt/seo_desc)
1648
- [postRows, pageRows, eventRows] = await this.likeSearch(trimmed, locale);
1709
+ [postRows, pageRows, eventRows] = await this.likeSearch(trimmed, locale, limit, opts?.collections);
1649
1710
  }
1650
1711
  const like = `%${trimmed.replace(/[%_]/g, ' ').trim()}%`;
1651
1712
  const [authors, tagRows, userRows] = await Promise.all([
@@ -1675,32 +1736,33 @@ export class SqlRepository implements ContentRepository {
1675
1736
  authors: userRows.map(rowToUser),
1676
1737
  };
1677
1738
  }
1678
- private async likeSearch(query: string, locale: string): Promise<[Row[], Row[], Row[]]> {
1739
+ private async likeSearch(query: string, locale: string, limit: number, collections?: string[]): Promise<[Row[], Row[], Row[]]> {
1679
1740
  const q = `%${query.replace(/[%_]/g, ' ').trim()}%`;
1680
1741
  if (q === '%%') return [[], [], []];
1681
1742
  return Promise.all([
1682
1743
  this.driver.all(
1683
- "SELECT * FROM posts WHERE status = 'published' AND (title LIKE ? OR excerpt LIKE ? OR body_html LIKE ? OR body LIKE ?) ORDER BY published_at DESC LIMIT 50",
1684
- [q, q, q, q]
1744
+ `SELECT * FROM posts WHERE status = 'published'${collections ? ` AND collection IN (${collections.map(() => '?').join(', ') || 'NULL'})` : ''} AND (title LIKE ? OR excerpt LIKE ? OR body_html LIKE ? OR body LIKE ?) ORDER BY published_at DESC LIMIT ${limit}`,
1745
+ [...(collections ?? []), q, q, q, q]
1685
1746
  ),
1686
1747
  this.driver.all(
1687
- "SELECT * FROM pages WHERE status = 'published' AND (title LIKE ? OR body_html LIKE ?) ORDER BY (locale = ?) DESC, updated_at DESC LIMIT 50",
1748
+ `SELECT * FROM pages WHERE status = 'published' AND (title LIKE ? OR body_html LIKE ?) ORDER BY (locale = ?) DESC, updated_at DESC LIMIT ${limit}`,
1688
1749
  [q, q, locale]
1689
1750
  ),
1690
1751
  this.driver.all(
1691
- "SELECT * FROM events WHERE status = 'published' AND (title LIKE ? OR excerpt LIKE ? OR body_html LIKE ?) ORDER BY start_at DESC LIMIT 50",
1752
+ `SELECT * FROM events WHERE status = 'published' AND (title LIKE ? OR excerpt LIKE ? OR body_html LIKE ?) ORDER BY start_at DESC LIMIT ${limit}`,
1692
1753
  [q, q, q]
1693
1754
  ),
1694
1755
  ]);
1695
1756
  }
1696
1757
 
1697
1758
  // ---- restore ----
1698
- async restoreAll(content: Parameters<ContentRepository['restoreAll']>[0]) {
1759
+ async restoreAll(content: Parameters<ContentRepository['restoreAll']>[0], authRecovery?: { issuer: string; subject: string; userId: string }) {
1699
1760
  await this.ready;
1700
1761
  const stmts: SqlStatement[] = [
1701
1762
  // Suppression and re-enabling share the restore transaction. A failed
1702
1763
  // restore cannot leave notifications disabled or emit phantom deletes.
1703
1764
  { sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' },
1765
+ { sql: "UPDATE auth_invitations SET state='revoked' WHERE state<>'accepted'" },
1704
1766
  ...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests', 'import_runs', 'import_records', 'places', 'forms', 'form_submissions'].map((t) => ({
1705
1767
  sql: `DELETE FROM ${t}`,
1706
1768
  })),
@@ -1776,6 +1838,7 @@ export class SqlRepository implements ContentRepository {
1776
1838
  args: [r.id, r.fromPath, r.toPath, r.code, r.createdAt],
1777
1839
  })),
1778
1840
  ];
1841
+ if (authRecovery) stmts.push({ sql: `UPDATE auth_identities SET disabled=0 WHERE issuer=? AND subject=? AND user_id=? AND EXISTS(SELECT 1 FROM users WHERE id=? AND role='owner')`, args: [authRecovery.issuer, authRecovery.subject, authRecovery.userId, authRecovery.userId] });
1779
1842
  const settingsWrite = this.settingsStatement({ ...content.settings, live: { ...LiveConfig.parse(content.settings.live ?? {}), mode: 'off', manual: { watchUrl: '', title: 'We’re live', until: 0 } } });
1780
1843
  stmts.push({ sql: "UPDATE live_connections SET enabled=0,tested_at=0,revision=revision+1,lease='',lease_until=0,snapshot=NULL,upcoming=NULL,error='Restored content. Test this connection before enabling automatic checks.'" }, { sql: 'DELETE FROM live_oauth_states' });
1781
1844
  if (settingsWrite) stmts.push(settingsWrite);
@@ -1938,6 +2001,8 @@ export class SqlRepository implements ContentRepository {
1938
2001
  live: JSON.parse(String(r?.live ?? '{}')),
1939
2002
  push: JSON.parse(String(r?.push ?? '{}')),
1940
2003
  calendar: JSON.parse(String(r?.calendar ?? '{}')),
2004
+ mobileAccess: JSON.parse(String(r?.mobile_access ?? '{}')),
2005
+ monitoring: JSON.parse(String(r?.monitoring ?? '{}')),
1941
2006
  agent: JSON.parse(String(r?.agent ?? '{}')),
1942
2007
  draftsApiKey: r?.drafts_api_key ?? '',
1943
2008
  draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
@@ -1949,14 +2014,27 @@ export class SqlRepository implements ContentRepository {
1949
2014
  const statement = this.settingsStatement(data);
1950
2015
  if (statement) await this.driver.run(statement.sql, statement.args);
1951
2016
  }
2017
+ async patchMobileAccess(patch: Partial<Settings['mobileAccess']>) {
2018
+ await this.ready;
2019
+ await this.driver.run('UPDATE settings SET mobile_access = json_patch(mobile_access, ?) WHERE id = 1', [JSON.stringify(patch)]);
2020
+ }
2021
+ async getCacheState() {
2022
+ await this.ready;
2023
+ const r = await this.first('SELECT content_version, cache_purged_version FROM settings WHERE id = 1');
2024
+ return { version: Number(r?.content_version ?? 0), purged: Number(r?.cache_purged_version ?? 0) };
2025
+ }
2026
+ async markCachePurged(version: number) {
2027
+ await this.ready;
2028
+ await this.driver.run('UPDATE settings SET cache_purged_version = MAX(cache_purged_version, ?) WHERE id = 1', [version]);
2029
+ }
1952
2030
  private settingsStatement(data: Partial<Settings>): SqlStatement | undefined {
1953
2031
  const columns: Record<string, string> = {
1954
2032
  siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
1955
2033
  activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteLogoDark:'site_logo_dark', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
1956
2034
  announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
1957
- draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push', calendar:'calendar',
2035
+ draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push', calendar:'calendar', mobileAccess:'mobile_access', monitoring:'monitoring',
1958
2036
  };
1959
- const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar']);
2037
+ const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar','mobileAccess','monitoring']);
1960
2038
  const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
1961
2039
  if (!entries.length) return;
1962
2040
  const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteLogoDark','siteFavicon','siteCover','draftDesign'].includes(k));
@@ -39,7 +39,20 @@ export class SqliteRepository extends SqlRepository {
39
39
  }
40
40
  // remote libSQL (Turso, sqld) needs a token; local files don't
41
41
  const authToken = env('SQLITE_AUTH_TOKEN', '');
42
- return new LibsqlDriver(createClient(authToken ? { url, authToken } : { url }));
42
+ const client = createClient(authToken ? { url, authToken } : { url });
43
+ if (url.startsWith('file:')) await tuneLocalFile(client);
44
+ return new LibsqlDriver(client);
43
45
  });
44
46
  }
45
47
  }
48
+
49
+ // Local files only (remote libSQL/Turso manage their own journal): WAL lets
50
+ // page reads continue while a write commits, busy_timeout waits out a
51
+ // concurrent writer (the scheduler, a second process) instead of failing
52
+ // with SQLITE_BUSY, and synchronous=NORMAL is the safe pairing for WAL.
53
+ // File-copy backups must take acms.db, acms.db-wal and acms.db-shm together.
54
+ export async function tuneLocalFile(client: Pick<Client, 'execute'>) {
55
+ await client.execute('PRAGMA journal_mode=WAL');
56
+ await client.execute('PRAGMA busy_timeout=5000');
57
+ await client.execute('PRAGMA synchronous=NORMAL');
58
+ }
@@ -1,4 +1,7 @@
1
+ import { AuthStore } from '../auth/store';
1
2
  import type { FormStore } from '../forms/store';
3
+ import type { BackupStore } from '../backup/store';
4
+ import type { GuestStore } from '../mobile/guest-store';
2
5
  import type { SavedForm, FormSubmission } from '../forms/schema';
3
6
  import type { LiveStore } from '../live/store';
4
7
  import type { BookingRequest } from '../backup/content';
@@ -49,8 +52,11 @@ export type EventUpdate = Partial<
49
52
  // The single contract both backends implement. The app only ever imports
50
53
  // this — never a concrete adapter.
51
54
  export interface ContentRepository {
55
+ readonly auth: AuthStore;
52
56
  readonly email: EmailStore;
53
57
  readonly forms: FormStore;
58
+ readonly backups: BackupStore;
59
+ readonly guests: GuestStore;
54
60
  readonly live: LiveStore;
55
61
  readonly push: PushStore;
56
62
  readonly editor: EditorStore;
@@ -168,9 +174,12 @@ export interface ContentRepository {
168
174
  // write that can fail on a race: it inserts atomically only if the held
169
175
  // range is still free (resource bookings) or seats remain (RSVPs), and
170
176
  // returns null when it lost.
171
- listBookings(filter?: { resourceId?: string; eventId?: string; email?: string; from?: number; to?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][] }): Promise<Booking[]>;
177
+ /** from/to: overlaps the window; endedBy: ended at or before; default order start ascending, at most 5000 rows */
178
+ listBookings(filter?: { resourceId?: string; eventId?: string; email?: string; from?: number; to?: number; endedBy?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][]; newestFirst?: boolean; limit?: number; offset?: number }): Promise<Booking[]>;
172
179
  getBooking(id: string): Promise<Booking | null>;
173
180
  getBookingByToken(token: string): Promise<Booking | null>;
181
+ // bookings + RSVPs (never staff blocks) this address made since `since`
182
+ countBookingsByEmail(email: string, since: number): Promise<number>;
174
183
  createBooking(
175
184
  data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion'>,
176
185
  guard?: { capacity?: number; idempotency?: { keyHash: string; requestHash: string } }
@@ -222,6 +231,15 @@ export interface ContentRepository {
222
231
  // full ordering in one call (drag-and-drop); ids in desired display order
223
232
  reorderLinks(ids: string[]): Promise<void>;
224
233
  deleteLink(id: string): Promise<void>;
234
+ /** changes whenever the links page's blocks or profile change, by any path (not on clicks) */
235
+ linksRevision(): Promise<number>;
236
+ /**
237
+ * the whole links page in one atomic write, only if it is still at
238
+ * `expectedRevision` (409 otherwise, nothing saved): `keep` ids are updated
239
+ * in place, other items are added, everything else is removed; `profile`
240
+ * (links name/bio/avatar) saves in the same batch.
241
+ */
242
+ replaceLinks(expectedRevision: number, items: (Pick<Link, 'kind' | 'label' | 'url' | 'icon' | 'html' | 'meta'> & { keep?: string })[], profile?: Partial<Pick<Settings, 'linksName' | 'linksBio' | 'linksAvatar'>>): Promise<void>;
225
243
  trackClick(id: string): Promise<string | null>; // returns url to redirect to
226
244
 
227
245
  // tags — registry of tag entities; posts store tag names, this table adds
@@ -244,6 +262,9 @@ export interface ContentRepository {
244
262
  getUser(id: string): Promise<User | null>;
245
263
  createUser(data: { name: string; email: string; role: Role; passHash: string }): Promise<User>;
246
264
  updateUser(id: string, data: { name?: string; email?: string; role?: Role; passHash?: string; bio?: string; avatar?: string; slug?: string }): Promise<void>;
265
+ /** Replace a user's email. `signOut` ends their sessions (the email is their
266
+ * login); `notify` queues an `email-changed` notice to the previous address. */
267
+ changeUserEmail(id: string, email: string, opts: { signOut: boolean; notify: boolean }): Promise<void>;
247
268
  deleteUser(id: string): Promise<void>;
248
269
  getUserAuth(email: string): Promise<{ user: User; passHash: string; sessionVersion: number } | null>;
249
270
 
@@ -260,7 +281,8 @@ export interface ContentRepository {
260
281
  // #tags excluded) and author profiles. opts.locale is a ranking boost,
261
282
  // never a filter: pages in the visitor's locale sort first, every locale
262
283
  // stays visible (en/es cover different topics, not translations).
263
- searchPublished(query: string, opts?: { locale?: string }): Promise<SearchResults>;
284
+ // collections: only posts in these (configured) collections
285
+ searchPublished(query: string, opts?: { locale?: string; limit?: number; collections?: string[] }): Promise<SearchResults>;
264
286
 
265
287
  // backup restore — id-preserving replace-all from a backup's content.json.
266
288
  // Users restore WITHOUT password hashes (backups never contain them).
@@ -285,7 +307,7 @@ export interface ContentRepository {
285
307
  places?: Place[];
286
308
  forms?: SavedForm[];
287
309
  formSubmissions?: FormSubmission[];
288
- }): Promise<void>;
310
+ }, authRecovery?: { issuer: string; subject: string; userId: string }): Promise<void>;
289
311
 
290
312
  // imports (lib/import/apply.ts drives these). writeImported is the
291
313
  // id-preserving bulk write for imported posts/pages: original timestamps
@@ -310,6 +332,12 @@ export interface ContentRepository {
310
332
  // settings & dashboard
311
333
  getSettings(): Promise<Settings>;
312
334
  updateSettings(data: Partial<Settings>): Promise<void>;
335
+ /** merges just these App access fields in one statement, so concurrent saves don't undo each other */
336
+ patchMobileAccess(patch: Partial<Settings['mobileAccess']>): Promise<void>;
337
+ /** public cache: version = bumped by triggers on every visible change; purged = last version cleared from caches */
338
+ getCacheState(): Promise<{ version: number; purged: number }>;
339
+ /** record that caches were cleared up to `version` (never moves backwards) */
340
+ markCachePurged(version: number): Promise<void>;
313
341
  // Design-screen staging: save stages into settings.draftDesign, apply
314
342
  // copies staged → live design fields and clears, discard just clears
315
343
  saveDesignDraft(data: Partial<Pick<Settings, 'activeTheme' | 'designOverrides' | 'siteLogo' | 'siteLogoDark' | 'siteFavicon' | 'siteCover' | 'sectionPages'>> & {