@acms-subir/core 0.3.0 → 0.3.2

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 (57) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/docs/live-video.md +91 -0
  3. package/integration.mjs +39 -0
  4. package/package.json +2 -1
  5. package/src/actions/index.ts +3 -2
  6. package/src/components/LinksManager.tsx +5 -1
  7. package/src/components/LiveCard.astro +174 -0
  8. package/src/components/LiveManager.tsx +910 -0
  9. package/src/components/ThemeSections.astro +2 -1
  10. package/src/components/WidgetRenderer.astro +3 -0
  11. package/src/components/editor/extensions/cards.tsx +3 -38
  12. package/src/components/editor/extensions/mediaCards.tsx +4 -0
  13. package/src/layouts/Admin.astro +1 -1
  14. package/src/layouts/Site.astro +28 -0
  15. package/src/lib/api.ts +6 -2
  16. package/src/lib/backup/content.ts +1 -1
  17. package/src/lib/embeds.ts +50 -0
  18. package/src/lib/live/client.ts +88 -0
  19. package/src/lib/live/crypto.ts +28 -0
  20. package/src/lib/live/migration.ts +15 -0
  21. package/src/lib/live/providers.ts +152 -0
  22. package/src/lib/live/service.ts +170 -0
  23. package/src/lib/live/signals.ts +89 -0
  24. package/src/lib/live/store.ts +49 -0
  25. package/src/lib/live/types.ts +52 -0
  26. package/src/lib/live/view.ts +78 -0
  27. package/src/lib/repo/index.ts +12 -8
  28. package/src/lib/repo/migrations.ts +4 -0
  29. package/src/lib/repo/sql.ts +58 -12
  30. package/src/lib/repo/types.ts +3 -0
  31. package/src/lib/scheduler.ts +2 -0
  32. package/src/lib/schema.ts +7 -1
  33. package/src/lib/themeTokens.ts +9 -0
  34. package/src/lib/webhook-sql.ts +68 -0
  35. package/src/lib/webhooks.ts +15 -7
  36. package/src/lib/widgets.ts +1 -0
  37. package/src/middleware.ts +10 -1
  38. package/src/pages/[...slug].astro +6 -0
  39. package/src/pages/admin/calendar/integrations.astro +2 -219
  40. package/src/pages/admin/integrations.astro +237 -0
  41. package/src/pages/admin/live.astro +12 -0
  42. package/src/pages/api/calendar/docs.ts +4 -2
  43. package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
  44. package/src/pages/api/live/settings.ts +45 -0
  45. package/src/pages/api/live/status.ts +7 -0
  46. package/src/pages/api/live/thumb.ts +48 -0
  47. package/src/pages/api/webhooks/docs.ts +61 -0
  48. package/src/pages/index.astro +6 -0
  49. package/src/pages/links.astro +6 -0
  50. package/src/styles/live-admin.css +126 -0
  51. package/src/styles/live.css +199 -0
  52. package/src/theme-sdk/contract.mjs +1 -0
  53. package/src/theme-sdk/tokens.mjs +3 -0
  54. package/src/themes/editorial/theme.css +5 -0
  55. package/src/themes/editorial/theme.json +2 -0
  56. package/src/themes/field/theme.json +1 -0
  57. package/src/themes/night/theme.json +1 -0
@@ -1,3 +1,5 @@
1
+ import { LiveStore } from '../live/store';
2
+ import { LiveConfig } from '../live/types';
1
3
  import { ApiError } from '../api';
2
4
  import { EmailStore, bookingJobs } from '../email/store';
3
5
  import type { ContentRepository, DesignVersion, EventUpdate, SearchResults, Stats } from './types';
@@ -9,6 +11,7 @@ import { MIGRATIONS, MIGRATIONS_TABLE } from './migrations';
9
11
  import { htmlToPlaintext } from '../plaintext';
10
12
  import { SEED } from '../brand';
11
13
  import { CONTENT, postsIndexUrl } from '../content';
14
+ import { webhookStatement } from '../webhook-sql';
12
15
 
13
16
  // Shared SQL implementation of ContentRepository. Cloudflare D1 and libSQL
14
17
  // are both SQLite, so every statement, mapper and behavior lives here once;
@@ -215,9 +218,11 @@ export class SqlRepository implements ContentRepository {
215
218
  private driver!: SqlDriver;
216
219
  private ready: Promise<void>;
217
220
  readonly email: EmailStore;
221
+ readonly live: LiveStore;
218
222
 
219
223
  constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
220
224
  this.ready = this.init(driver);
225
+ this.live = new LiveStore(async () => { await this.ready; return this.driver; });
221
226
  this.email = new EmailStore(async () => { await this.ready; return this.driver; });
222
227
  }
223
228
 
@@ -435,6 +440,8 @@ export class SqlRepository implements ContentRepository {
435
440
  }
436
441
  async updatePage(id: string, data: Parameters<ContentRepository['updatePage']>[1]) {
437
442
  await this.ready;
443
+ if (!(await this.getPage(id))) return;
444
+ const writes: SqlStatement[] = [];
438
445
  // a direct live write supersedes any staged edits (applyPageDraft routes
439
446
  // through here too, so applying clears the snapshot in the same UPDATE)
440
447
  const sets: string[] = ['updated_at = ?', 'draft = NULL'];
@@ -457,12 +464,13 @@ export class SqlRepository implements ContentRepository {
457
464
  data.locale !== undefined
458
465
  ? data.locale
459
466
  : String((await this.first('SELECT locale FROM pages WHERE id = ?', [id]))?.locale ?? '');
460
- await this.driver.run('UPDATE pages SET is_home = 0 WHERE locale = ?', [locale]);
467
+ writes.push({ sql: 'UPDATE pages SET is_home = 0 WHERE locale = ? AND id != ? AND is_home = 1', args: [locale, id] });
461
468
  }
462
469
  sets.push('is_home = ?'); args.push(data.isHome ? 1 : 0);
463
470
  }
464
471
  args.push(id);
465
- await this.driver.run(`UPDATE pages SET ${sets.join(', ')} WHERE id = ?`, args);
472
+ writes.push({ sql: `UPDATE pages SET ${sets.join(', ')} WHERE id = ?`, args });
473
+ await this.driver.batch(writes);
466
474
  await this.syncPageIndex(id);
467
475
  }
468
476
  // stage edits on a live page: only the `draft` JSON changes — live columns,
@@ -577,8 +585,7 @@ export class SqlRepository implements ContentRepository {
577
585
  if (data.featured !== undefined) { sets.push('featured = ?'); args.push(data.featured ? 1 : 0); }
578
586
  if (data.template !== undefined) { sets.push('template = ?'); args.push(data.template); }
579
587
  // authorIds is the source of truth; author_id mirrors the first entry.
580
- // The join rows are written AFTER the main UPDATE succeeds, so a failed
581
- // update (e.g. duplicate slug) can't leave the two out of sync.
588
+ // Main row, join rows and webhook notifications share one transaction.
582
589
  let authorJoin: string[] | null = null;
583
590
  if (data.authorIds !== undefined) {
584
591
  authorJoin = data.authorIds;
@@ -596,16 +603,28 @@ export class SqlRepository implements ContentRepository {
596
603
  }
597
604
  }
598
605
  args.push(id);
599
- await this.driver.run(`UPDATE posts SET ${sets.join(', ')} WHERE id = ?`, args);
606
+ const writes: SqlStatement[] = [{ sql: `UPDATE posts SET ${sets.join(', ')} WHERE id = ?`, args }];
600
607
  if (authorJoin !== null) {
601
- await this.driver.batch([
608
+ writes.push(
602
609
  { sql: 'DELETE FROM post_authors WHERE post_id = ?', args: [id] },
603
610
  ...authorJoin.map((aid, i) => ({
604
611
  sql: 'INSERT INTO post_authors (post_id, author_id, ord) VALUES (?, ?, ?)',
605
612
  args: [id, aid, i],
606
613
  })),
607
- ]);
614
+ );
615
+ // The main-row trigger detects primary-author changes. Additional
616
+ // authors live in a join table, so emit once for an authors-only edit.
617
+ const authorsOnly = current.status === 'published'
618
+ && (authorJoin[0] ?? null) === current.authorId
619
+ && JSON.stringify(authorJoin) !== JSON.stringify(current.authorIds)
620
+ && Object.entries(data).every(([key, value]) => key === 'authorIds' || value === undefined
621
+ || JSON.stringify(value) === JSON.stringify(current[key as keyof Post]));
622
+ if (authorsOnly) {
623
+ const post = { id, slug: current.slug, title: current.title, status: current.status, collection: current.collection };
624
+ writes.push(webhookStatement('post.updated', { post, previous: post }));
625
+ }
608
626
  }
627
+ await this.driver.batch(writes);
609
628
  await this.syncPostIndex(id);
610
629
  }
611
630
  // stage edits on a live post: only the `draft` JSON changes — live columns,
@@ -1192,6 +1211,15 @@ export class SqlRepository implements ContentRepository {
1192
1211
  );
1193
1212
  return rows.map(rowToDelivery);
1194
1213
  }
1214
+ async claimDelivery(id: string, now: number) {
1215
+ await this.ready;
1216
+ // A lease outlasts the HTTP timeout. Competing request/cron flushes must
1217
+ // not send the same pending delivery at the same time.
1218
+ const r = await this.first(`UPDATE webhook_deliveries SET next_at = ?
1219
+ WHERE id = ? AND status = 'pending' AND (next_at IS NULL OR next_at <= ?)
1220
+ RETURNING *`, [now + 30_000, id, now]);
1221
+ return r ? rowToDelivery(r) : null;
1222
+ }
1195
1223
  async recordDelivery(id: string, data: Parameters<ContentRepository['recordDelivery']>[1]) {
1196
1224
  await this.ready;
1197
1225
  const r = await this.first('SELECT webhook_id FROM webhook_deliveries WHERE id = ?', [id]);
@@ -1546,6 +1574,9 @@ export class SqlRepository implements ContentRepository {
1546
1574
  async restoreAll(content: Parameters<ContentRepository['restoreAll']>[0]) {
1547
1575
  await this.ready;
1548
1576
  const stmts: SqlStatement[] = [
1577
+ // Suppression and re-enabling share the restore transaction. A failed
1578
+ // restore cannot leave notifications disabled or emit phantom deletes.
1579
+ { sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' },
1549
1580
  ...['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'].map((t) => ({
1550
1581
  sql: `DELETE FROM ${t}`,
1551
1582
  })),
@@ -1614,8 +1645,14 @@ export class SqlRepository implements ContentRepository {
1614
1645
  args: [r.id, r.fromPath, r.toPath, r.code, r.createdAt],
1615
1646
  })),
1616
1647
  ];
1648
+ const settingsWrite = this.settingsStatement({ ...content.settings, live: { ...LiveConfig.parse(content.settings.live ?? {}), mode: 'off', manual: { watchUrl: '', title: 'We’re live', until: 0 } } });
1649
+ 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' });
1650
+ if (settingsWrite) stmts.push(settingsWrite);
1651
+ stmts.push(
1652
+ { sql: 'UPDATE webhook_control SET suppressed=0 WHERE id=1' },
1653
+ webhookStatement('site.restored', { counts: { posts: content.posts.length, pages: content.pages.length, media: content.media.length } }),
1654
+ );
1617
1655
  await this.driver.batch(stmts);
1618
- await this.updateSettings(content.settings);
1619
1656
  await this.reindexSearch();
1620
1657
  }
1621
1658
 
@@ -1666,6 +1703,7 @@ export class SqlRepository implements ContentRepository {
1666
1703
  headCode: r?.head_code ?? '', footerCode: r?.footer_code ?? '',
1667
1704
  timezone: r?.timezone ?? '', locale: r?.locale ?? '',
1668
1705
  ai: JSON.parse(String(r?.ai ?? '{}')),
1706
+ live: JSON.parse(String(r?.live ?? '{}')),
1669
1707
  agent: JSON.parse(String(r?.agent ?? '{}')),
1670
1708
  draftsApiKey: r?.drafts_api_key ?? '',
1671
1709
  draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
@@ -1674,18 +1712,22 @@ export class SqlRepository implements ContentRepository {
1674
1712
  }
1675
1713
  async updateSettings(data: Partial<Settings>) {
1676
1714
  await this.ready;
1715
+ const statement = this.settingsStatement(data);
1716
+ if (statement) await this.driver.run(statement.sql, statement.args);
1717
+ }
1718
+ private settingsStatement(data: Partial<Settings>): SqlStatement | undefined {
1677
1719
  const columns: Record<string, string> = {
1678
1720
  siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
1679
1721
  activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
1680
1722
  announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
1681
- draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent',
1723
+ draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live',
1682
1724
  };
1683
- const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent']);
1725
+ const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live']);
1684
1726
  const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
1685
1727
  if (!entries.length) return;
1686
1728
  const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','draftDesign'].includes(k));
1687
- await this.driver.run(`UPDATE settings SET ${entries.map(([k]) => `${columns[k]} = ?`).join(', ')}${design ? ', design_revision = design_revision + 1' : ''} WHERE id = 1`,
1688
- entries.map(([k,v]) => jsonKeys.has(k) && v !== null ? JSON.stringify(v) : v));
1729
+ return { sql: `UPDATE settings SET ${entries.map(([k]) => `${columns[k]} = ?`).join(', ')}${design ? ', design_revision = design_revision + 1' : ''} WHERE id = 1`,
1730
+ args: entries.map(([k,v]) => jsonKeys.has(k) && v !== null ? JSON.stringify(v) : v) };
1689
1731
  }
1690
1732
  private async designWrite(expected: number, actor: string, action: string, statements: SqlStatement[]) {
1691
1733
  try {
@@ -1739,6 +1781,10 @@ export class SqlRepository implements ContentRepository {
1739
1781
  if (nav) statements.push({ sql: 'DELETE FROM nav_items' }, ...nav.map((n,i) => ({
1740
1782
  sql: 'INSERT INTO nav_items (id,label,url,area,ord) VALUES (?,?,?,?,?)', args: [n.id.startsWith('tmp-') ? crypto.randomUUID() : n.id,n.label,n.url,n.area,i],
1741
1783
  })));
1784
+ statements.push(webhookStatement('design.published', { design: {
1785
+ revision: current.designRevision + 1, activeTheme: design.activeTheme,
1786
+ navigationChanged: nav !== null, sectionPageIds: Object.keys(design.sectionPages),
1787
+ } }));
1742
1788
  await this.designWrite(opts.expectedRevision ?? current.designRevision, opts.author ?? 'admin', 'publish', statements);
1743
1789
  }
1744
1790
  async discardDesignDraft() { await this.setDesignDraft(null); }
@@ -1,3 +1,4 @@
1
+ import type { LiveStore } from '../live/store';
1
2
  import type { BookingRequest } from '../backup/content';
2
3
  import type { EmailStore } from '../email/store';
3
4
  export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
@@ -42,6 +43,7 @@ export type EventUpdate = Partial<
42
43
  // this — never a concrete adapter.
43
44
  export interface ContentRepository {
44
45
  readonly email: EmailStore;
46
+ readonly live: LiveStore;
45
47
  // Bounded public catalog; fetch limit + 1 IDs without loading every body.
46
48
  listPublicContent(options: { page: number; limit: number; kind?: 'post' | 'page'; collection?: string; locale?: string; collections: string[]; defaultLocale: string }): Promise<{ id: string; kind: 'post' | 'page' }[]>;
47
49
  exportBookingState(): Promise<{ bookings: Booking[]; bookingRequests: BookingRequest[] }>;
@@ -172,6 +174,7 @@ export interface ContentRepository {
172
174
  createDeliveries(rows: Omit<WebhookDelivery, 'attempts' | 'responseCode' | 'deliveredAt'>[]): Promise<void>;
173
175
  listDueDeliveries(now: number, limit: number): Promise<WebhookDelivery[]>;
174
176
  listDeliveries(webhookId: string, limit: number): Promise<WebhookDelivery[]>;
177
+ claimDelivery(id: string, now: number): Promise<WebhookDelivery | null>;
175
178
  recordDelivery(id: string, data: Pick<WebhookDelivery, 'status' | 'attempts' | 'nextAt' | 'responseCode' | 'deliveredAt'>): Promise<void>;
176
179
 
177
180
  // media
@@ -1,3 +1,4 @@
1
+ import { syncDueLive } from './live/service';
1
2
  import type { ContentRepository } from './repo/types';
2
3
  import { processDueDeliveries } from './webhooks';
3
4
  import { dispatchEmail } from './email/service';
@@ -13,6 +14,7 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
13
14
  webhooks: () => processDueDeliveries(repo),
14
15
  reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
15
16
  email: () => dispatchEmail(repo, locals, 10),
17
+ live: () => syncDueLive(repo, locals),
16
18
  };
17
19
  const results: Record<string, { ok: boolean; count?: number }> = {};
18
20
  for (const [name, run] of Object.entries(jobs)) {
package/src/lib/schema.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { LiveConfig } from './live/types';
1
2
  import { SectionPages } from '../theme-sdk/contract.mjs';
2
3
  import { AiConfig, AgentConfig } from './ai-schema';
3
4
  import { z } from 'zod';
@@ -8,6 +9,7 @@ import { z } from 'zod';
8
9
  // component in src/components/widgets/ + an editor section in the admin.
9
10
 
10
11
  export const widgetConfigs = {
12
+ live: z.object({ display: z.enum(['card','player']).default('card') }),
11
13
  hero: z.object({
12
14
  heading: z.string().default(''),
13
15
  sub: z.string().default(''),
@@ -195,7 +197,7 @@ export const authorSlug = (u: Pick<User, 'name' | 'slug'>) => u.slug || slugify(
195
197
  // `<svg…>` markup (brand icons), or the keyword 'favicon'. `meta` is per-kind
196
198
  // extras: { color, textColor } button colors, { size } spacer height, vcard
197
199
  // fields, etc.
198
- export const LinkKind = z.enum(['link', 'email', 'phone', 'heading', 'text', 'spacer', 'vcard', 'html']);
200
+ export const LinkKind = z.enum(['link', 'email', 'phone', 'heading', 'text', 'spacer', 'vcard', 'html', 'live']);
199
201
  export type LinkKind = z.infer<typeof LinkKind>;
200
202
 
201
203
  export const Link = z.object({
@@ -509,6 +511,9 @@ export const ApiKey = z.object({
509
511
  export type ApiKey = z.infer<typeof ApiKey>;
510
512
 
511
513
  export const WEBHOOK_EVENTS = [
514
+ 'post.published', 'post.updated', 'post.unpublished', 'post.deleted',
515
+ 'page.published', 'page.updated', 'page.unpublished', 'page.deleted',
516
+ 'design.published', 'media.created', 'media.updated', 'media.deleted', 'site.restored',
512
517
  'event.published', 'event.updated', 'event.unpublished', 'event.deleted',
513
518
  'booking.created', 'booking.confirmed', 'booking.declined', 'booking.cancelled', 'booking.rescheduled',
514
519
  ] as const;
@@ -554,6 +559,7 @@ export function normalizeRedirectPath(input: string): string {
554
559
  }
555
560
 
556
561
  export const Settings = z.object({
562
+ live: LiveConfig.default({}),
557
563
  siteTitle: z.string().default('My Site'),
558
564
  tagline: z.string().default(''),
559
565
  // site-wide footer, markdown allowed; empty falls back to "title — tagline"
@@ -138,11 +138,20 @@ export function paletteVars(p: Palette, t: ThemeTokens, dark: boolean): string[]
138
138
  `--btn:${t.button === 'accent' ? 'var(--accent)' : 'var(--ink)'}`, `--btn-ink:${btnInk}`,
139
139
  `--code-bg:${dark ? '#0d0e11' : '#1b1c21'}`,
140
140
  `--success:${dark ? '#5cc98e' : '#127442'}`, `--danger:${dark ? '#f07b7b' : '#b42f2f'}`, `--warning:${dark ? '#e3b456' : '#8a5f0a'}`,
141
+ ...liveVars(dark ? t.dark?.live || (t.live ? ensureContrast(t.live, p.bg, 3, '#ffffff') : '#ff5a4e') : t.live || '#d42f25', p.bg),
141
142
  `--sh-1:${s1}`, `--sh-2:${s2}`, `--sh-3:${s3}`,
142
143
  `color-scheme:${dark ? 'dark' : 'light'}`,
143
144
  ];
144
145
  }
145
146
 
147
+ // The live signal color stays visible on the canvas (3:1, it's a marker, not
148
+ // body text) and gets whichever text color reads on it.
149
+ function liveVars(live: string, bg: string): string[] {
150
+ const c = ensureContrast(live, bg, 3);
151
+ const on = contrast('#ffffff', c) >= 4.5 || contrast('#ffffff', c) >= contrast('#111214', c) ? '#ffffff' : '#111214';
152
+ return [`--live:${c}`, `--live-on:${on}`];
153
+ }
154
+
146
155
  // Scheme-independent variables: type, shape, rhythm, layout widths.
147
156
  export function staticVars(t: ThemeTokens): string[] {
148
157
  const [r1, r2, r3] = RADII[t.radius];
@@ -0,0 +1,68 @@
1
+ import type { SqlStatement } from './repo/sql';
2
+ import type { WebhookEventType } from './schema';
3
+
4
+ // Shared by triggers and transactional design/restore writes. SQL expressions
5
+ // passed here are authored in this module, never supplied by a request.
6
+ const now = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
7
+ function enqueue(event: string, data: string) {
8
+ return `INSERT INTO webhook_deliveries (id,webhook_id,event,payload,status,attempts,next_at,created_at)
9
+ SELECT lower(hex(randomblob(16))),h.id,${event},
10
+ json_object('type',${event},'createdAt',strftime('%Y-%m-%dT%H:%M:%fZ','now'),'data',json(${data})),
11
+ 'pending',0,${now},${now}
12
+ FROM webhooks h WHERE h.active=1 AND EXISTS (
13
+ SELECT 1 FROM json_each(h.events) WHERE value=${event} OR value='*'
14
+ ) AND (SELECT suppressed FROM webhook_control WHERE id=1)=0;`;
15
+ }
16
+
17
+ export function webhookStatement(event: WebhookEventType, data: Record<string, unknown>): SqlStatement {
18
+ return {
19
+ sql: enqueue(`'${event}'`, '?'),
20
+ args: [JSON.stringify(data)],
21
+ };
22
+ }
23
+
24
+ function trigger(name: string, action: string, table: string, when: string, event: string, data: string) {
25
+ return `CREATE TRIGGER ${name} AFTER ${action} ON ${table} WHEN ${when}
26
+ BEGIN ${enqueue(event, data)} END`;
27
+ }
28
+
29
+ function content(kind: 'post' | 'page') {
30
+ const table = `${kind}s`;
31
+ const fields = kind === 'post'
32
+ ? ['collection','slug','title','status','body','body_json','body_html','excerpt','tags','cover','cover_alt','seo_title','seo_desc','featured','author_id','template','published_at']
33
+ : ['slug','title','status','is_home','locale','blocks','body_json','body_html','seo_title','seo_desc','template'];
34
+ // Metadata only: draft bodies, unpublished edits, credentials and user
35
+ // details never enter a publication webhook. Receivers fetch public content.
36
+ const snapshot = (row: string) => `json_object('id',${row}.id,'slug',${row}.slug,'title',${row}.title,'status',${row}.status,
37
+ ${kind === 'post' ? `'collection',${row}.collection` : `'locale',${row}.locale,'isHome',json(CASE WHEN ${row}.is_home THEN 'true' ELSE 'false' END)`})`;
38
+ const changed = fields.map(f => `OLD.${f} IS NOT NEW.${f}`).join(' OR ');
39
+ const event = `CASE WHEN OLD.status!='published' THEN '${kind}.published'
40
+ WHEN NEW.status!='published' THEN '${kind}.unpublished' ELSE '${kind}.updated' END`;
41
+ return [
42
+ trigger(`webhook_${kind}_insert`, 'INSERT', table, "NEW.status='published'", `'${kind}.published'`, `json_object('${kind}',${snapshot('NEW')})`),
43
+ trigger(`webhook_${kind}_update`, 'UPDATE', table,
44
+ `(OLD.status='published' OR NEW.status='published') AND (${changed})`, event,
45
+ `json_object('${kind}',json(CASE WHEN NEW.status='published' THEN ${snapshot('NEW')}
46
+ ELSE json_set(${snapshot('OLD')},'$.status',NEW.status) END),
47
+ 'previous',json(CASE WHEN OLD.status='published' THEN ${snapshot('OLD')} ELSE 'null' END))`),
48
+ trigger(`webhook_${kind}_delete`, 'DELETE', table, "OLD.status='published'", `'${kind}.deleted'`, `json_object('${kind}',${snapshot('OLD')})`),
49
+ ];
50
+ }
51
+
52
+ const media = (row: string) => `json_object('media',json_object('id',${row}.id,'key',${row}.key,'filename',${row}.filename,
53
+ 'mime',${row}.mime,'width',${row}.width,'height',${row}.height,'size',${row}.size,'alt',${row}.alt))`;
54
+
55
+ export const WEBHOOK_MIGRATION = { version: 33, statements: [
56
+ `CREATE TABLE webhook_control (id INTEGER PRIMARY KEY CHECK(id=1), suppressed INTEGER NOT NULL DEFAULT 0)`,
57
+ `INSERT INTO webhook_control (id,suppressed) VALUES (1,0)`,
58
+ // Populate the envelope from the inserted delivery's stable ID. Retries
59
+ // send these exact stored bytes, including for SQL-generated notifications.
60
+ `CREATE TRIGGER webhook_delivery_envelope AFTER INSERT ON webhook_deliveries
61
+ WHEN json_extract(NEW.payload,'$.id') IS NULL BEGIN
62
+ UPDATE webhook_deliveries SET payload=json_set(payload,'$.id',NEW.id) WHERE id=NEW.id;
63
+ END`,
64
+ ...content('post'), ...content('page'),
65
+ trigger('webhook_media_create','INSERT','media','1',"'media.created'",media('NEW')),
66
+ trigger('webhook_media_update','UPDATE','media','OLD.alt IS NOT NEW.alt',"'media.updated'",media('NEW')),
67
+ trigger('webhook_media_delete','DELETE','media','1',"'media.deleted'",media('OLD')),
68
+ ] };
@@ -50,6 +50,7 @@ export interface EmitOptions {
50
50
  fetcher?: Fetcher;
51
51
  // Workers: ctx.waitUntil, so the response isn't held for delivery
52
52
  waitUntil?: (p: Promise<unknown>) => void;
53
+ limit?: number;
53
54
  }
54
55
 
55
56
  export async function emitWebhook(
@@ -76,8 +77,11 @@ export async function emitWebhook(
76
77
  return rows;
77
78
  }
78
79
 
79
- export async function attemptDelivery(repo: ContentRepository, d: WebhookDelivery, hook: Webhook, opts: EmitOptions = {}): Promise<void> {
80
+ export async function attemptDelivery(repo: ContentRepository, d: WebhookDelivery, hook: Webhook, opts: EmitOptions = {}): Promise<boolean> {
80
81
  const now = opts.now ?? Date.now();
82
+ const claimed = await repo.claimDelivery(d.id, now);
83
+ if (!claimed) return false;
84
+ d = claimed;
81
85
  const fetcher: Fetcher = opts.fetcher ?? ((url, init) => fetch(url, init));
82
86
  const ts = Math.floor(now / 1000);
83
87
  let code: number | null = null;
@@ -111,24 +115,28 @@ export async function attemptDelivery(repo: ContentRepository, d: WebhookDeliver
111
115
  responseCode: code,
112
116
  deliveredAt: ok ? now : null,
113
117
  });
118
+ return true;
114
119
  }
115
120
 
116
121
  // retry sweep: due pending deliveries, oldest first, bounded per run
117
122
  export async function processDueDeliveries(repo: ContentRepository, opts: EmitOptions = {}): Promise<number> {
118
123
  const now = opts.now ?? Date.now();
119
- const due = await repo.listDueDeliveries(now, 25);
124
+ const due = await repo.listDueDeliveries(now, opts.limit ?? 25);
120
125
  if (!due.length) return 0;
121
126
  const hooks = new Map((await repo.listWebhooks()).map((h) => [h.id, h]));
122
127
  let n = 0;
123
- for (const d of due) {
128
+ const deliver = async (d: WebhookDelivery) => {
124
129
  const hook = hooks.get(d.webhookId);
125
130
  if (!hook || !hook.active) {
126
131
  await repo.recordDelivery(d.id, { status: 'failed', attempts: d.attempts, nextAt: null, responseCode: null, deliveredAt: null });
127
- continue;
132
+ return;
128
133
  }
129
- await attemptDelivery(repo, d, hook, { ...opts, now });
130
- n++;
131
- }
134
+ // Each claim gets a fresh clock, even after earlier batches time out.
135
+ if (await attemptDelivery(repo, d, hook, opts)) n++;
136
+ };
137
+ // Bound network concurrency, while avoiding a string of eight-second
138
+ // endpoint timeouts holding up a request or exhausting waitUntil time.
139
+ for (let i = 0; i < due.length; i += 5) await Promise.all(due.slice(i, i + 5).map(deliver));
132
140
  return n;
133
141
  }
134
142
 
@@ -13,6 +13,7 @@ export interface WidgetMeta {
13
13
  }
14
14
 
15
15
  export const widgetMeta: Record<WidgetType, WidgetMeta> = {
16
+ live: { label: 'Live video', description: 'Your shared live broadcast (set up in Integrations)', group: 'Content', icon: 'M3 5h12v14H3zM15 10l6-4v12l-6-4' },
16
17
  hero: {
17
18
  label: 'Hero',
18
19
  description: 'Headline, subtitle + CTA',
package/src/middleware.ts CHANGED
@@ -85,7 +85,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
85
85
  const emails = dispatchEmail(repo, context.locals, 2).catch(() => 0);
86
86
  const wait = waitUntilFrom(context.locals);
87
87
  if (wait) wait(emails); else await emails;
88
- const retries = processDueDeliveries(repo, { now }).catch(() => {});
88
+ const retries = processDueDeliveries(repo).catch(() => {});
89
89
  if (wait) wait(retries); else await retries;
90
90
  // booking reminders (Node has no cron): site URL, else this request's origin
91
91
  const reminders = repo.getSettings()
@@ -99,6 +99,15 @@ export const onRequest = defineMiddleware(async (context, next) => {
99
99
 
100
100
  const response = await next();
101
101
 
102
+ // Database writes enqueue publication/media notifications atomically.
103
+ // Flush after mutations (including Astro actions), once their transaction
104
+ // has committed. The scheduler remains the durable fallback on failure.
105
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(context.request.method)) {
106
+ const deliveries = getRepo(context.locals).then(repo => processDueDeliveries(repo, { limit: 5 })).catch(() => 0);
107
+ const wait = waitUntilFrom(context.locals);
108
+ if (wait) wait(deliveries); else await deliveries;
109
+ }
110
+
102
111
  // a preview render must never be cached where a visitor could get it
103
112
  if (hasPreviewCookie(context.cookies)) {
104
113
  try {
@@ -21,6 +21,12 @@ if (!page || (page.status !== 'published' && !isAdmin)) {
21
21
  const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
22
22
  // ?designPreview=1 (admin-only) renders the staged Design-screen changes
23
23
  const settings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
24
+ // Live state must not survive in a cached page after its snapshot expires.
25
+ if (settings.live.mode !== 'off' || page?.blocks.some(block => block.type === 'live') ||
26
+ (page && settings.sectionPages?.[page.id]?.sections.some(section => section.type === 'live'))) {
27
+ Astro.response.headers.set('Cache-Control', 'no-store');
28
+ }
29
+
24
30
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
31
  const PageTemplate = (page.template && settings.sectionPages?.[page.id]?.mode !== 'content' ? resolveDesign(settings).theme.pageTemplates[page.template] : null) as any;
26
32
  ---