@acms-subir/core 0.3.45 → 0.3.46

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.46
4
+
5
+ ### Patch Changes
6
+
7
+ - de6694e: Event webhooks, the "When an event changes" push and slug redirects now follow every change to a published event, however it's made. Before, only the event editor, the Schedule and the list's bulk actions ran them. A scheduled event that went live sent no `event.published`, a saved place's new address sent no `event.updated` and no push, and an environment sync sent no event webhooks. Now database triggers record each write to a published event in the same transaction, and the side effects run right after the request (or from the scheduler, for scheduled publishing).
8
+
9
+ - **Scheduled publishing** behaves like pressing Publish: `event.published`, with the same payload.
10
+ - **Agent tools and the REST API**: unchanged on the surface. Edits to a live event still wait as a staged draft, and `publish_event` (or `"publish": true`) and DELETE send their webhooks.
11
+ - **A saved place's new name or address**: `event.updated`, and the "When an event changes" push for the new place (people with an RSVP already got the email).
12
+ - **Environment sync**: sends the event webhooks but never member pushes for events. An event a run publishes counts as already announced (no "new event" push), and its date, time or place changes send no change push. Posts a run publishes keep their "new post" push.
13
+ - **Restores** still send only `site.restored`. Changes still waiting from before the restore are dropped.
14
+ - **Drafts** (new events, ICS/CSV imports, Duplicate, "Make an editable copy", members' requests) send nothing until they're published.
15
+ - `event.updated` now means something public changed: times, place, cancelled dates, text, translations, calendars, rooms, RSVP settings. Saving only hosts or a member's request, or publishing an event that's already published, no longer sends it. Each write sends its own webhook, so moving a series that has cancelled dates sends two `event.updated`, the second with the moved cancelled dates (before, the one webhook had the old ones).
16
+
17
+ Payloads are unchanged. Migration 65 adds the `event_changes` table (with its triggers on `events`) and `push_control`, and rebuilds the events' first-publish push triggers. Other features can keep their own queues from their own triggers on `events`. The repository has new `claimEventChanges` and `finishEventChanges`.
18
+
3
19
  ## 0.3.45
4
20
 
5
21
  ### Patch Changes
@@ -84,24 +84,24 @@ function trigger(name, action, table, when, event, data) {
84
84
  function content(kind) {
85
85
  const table = `${kind}s`;
86
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,
87
+ const snapshot2 = (row) => `json_object('id',${row}.id,'slug',${row}.slug,'title',${row}.title,'status',${row}.status,
88
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 ");
89
+ const changed2 = fields.map((f) => `OLD.${f} IS NOT NEW.${f}`).join(" OR ");
90
90
  const event = `CASE WHEN OLD.status!='published' THEN '${kind}.published'
91
91
  WHEN NEW.status!='published' THEN '${kind}.unpublished' ELSE '${kind}.updated' END`;
92
92
  return [
93
- trigger(`webhook_${kind}_insert`, "INSERT", table, "NEW.status='published'", `'${kind}.published'`, `json_object('${kind}',${snapshot("NEW")})`),
93
+ trigger(`webhook_${kind}_insert`, "INSERT", table, "NEW.status='published'", `'${kind}.published'`, `json_object('${kind}',${snapshot2("NEW")})`),
94
94
  trigger(
95
95
  `webhook_${kind}_update`,
96
96
  "UPDATE",
97
97
  table,
98
- `(OLD.status='published' OR NEW.status='published') AND (${changed})`,
98
+ `(OLD.status='published' OR NEW.status='published') AND (${changed2})`,
99
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))`
100
+ `json_object('${kind}',json(CASE WHEN NEW.status='published' THEN ${snapshot2("NEW")}
101
+ ELSE json_set(${snapshot2("OLD")},'$.status',NEW.status) END),
102
+ 'previous',json(CASE WHEN OLD.status='published' THEN ${snapshot2("OLD")} ELSE 'null' END))`
103
103
  ),
104
- trigger(`webhook_${kind}_delete`, "DELETE", table, "OLD.status='published'", `'${kind}.deleted'`, `json_object('${kind}',${snapshot("OLD")})`)
104
+ trigger(`webhook_${kind}_delete`, "DELETE", table, "OLD.status='published'", `'${kind}.deleted'`, `json_object('${kind}',${snapshot2("OLD")})`)
105
105
  ];
106
106
  }
107
107
  var media = (row) => `json_object('media',json_object('id',${row}.id,'key',${row}.key,'filename',${row}.filename,
@@ -218,6 +218,104 @@ var ROOM_HOLD_MIGRATION = { version: 41, statements: [
218
218
  `ALTER TABLE events ADD COLUMN room_hold TEXT NOT NULL DEFAULT '{}'`
219
219
  ] };
220
220
 
221
+ // src/lib/calendar/change-migration.ts
222
+ var now4 = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
223
+ var EVENT_CHANGE_SNAPSHOT = [
224
+ "id",
225
+ "slug",
226
+ "title",
227
+ "excerpt",
228
+ "cover",
229
+ "cover_alt",
230
+ "status",
231
+ "featured",
232
+ "tags",
233
+ "starts",
234
+ "ends",
235
+ "all_day",
236
+ "timezone",
237
+ "rrule",
238
+ "exdates",
239
+ "cancelled",
240
+ "cancelled_dates",
241
+ "location",
242
+ "cta",
243
+ "calendar_ids",
244
+ "fields",
245
+ "rsvp",
246
+ "room_hold",
247
+ "start_at",
248
+ "end_at",
249
+ "published_at",
250
+ "scheduled_at",
251
+ "created_at",
252
+ "updated_at"
253
+ ];
254
+ var EVENT_CHANGE_COLUMNS = [
255
+ "slug",
256
+ "title",
257
+ "body_json",
258
+ "body_html",
259
+ "excerpt",
260
+ "cover",
261
+ "cover_alt",
262
+ "seo_title",
263
+ "seo_desc",
264
+ "status",
265
+ "featured",
266
+ "tags",
267
+ "template",
268
+ "starts",
269
+ "ends",
270
+ "all_day",
271
+ "timezone",
272
+ "rrule",
273
+ "exdates",
274
+ "cancelled",
275
+ "cancelled_dates",
276
+ "location",
277
+ "cta",
278
+ "calendar_ids",
279
+ "fields",
280
+ "rsvp",
281
+ "translations",
282
+ "room_hold",
283
+ "start_at",
284
+ "end_at"
285
+ ];
286
+ var snapshot = (row) => `json_object(${EVENT_CHANGE_SNAPSHOT.map((c) => `'${c}',${row}.${c}`).join(",")})`;
287
+ var live = "(SELECT suppressed FROM webhook_control WHERE id=1)=0";
288
+ var quiet = "COALESCE((SELECT quiet FROM push_control WHERE id=1),0)";
289
+ var record = (id, before, after) => `INSERT INTO event_changes (event_id,old_row,new_row,quiet,created_at)
290
+ VALUES (${id},${before},${after},${quiet},${now4});`;
291
+ var changed = EVENT_CHANGE_COLUMNS.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
292
+ var skipped = `CASE WHEN (SELECT suppressed FROM webhook_control WHERE id=1)=1 OR ${quiet}=1 THEN 'skipped' ELSE 'pending' END`;
293
+ var ledger = `INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
294
+ SELECT 'event:'||NEW.id,'event',NEW.id,NULL,${skipped},NULL,${now4},${now4}
295
+ WHERE NOT EXISTS (SELECT 1 FROM push_queue WHERE key='event:'||NEW.id);`;
296
+ var EVENT_CHANGES_MIGRATION = { version: 65, statements: [
297
+ `CREATE TABLE event_changes (
298
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL, old_row TEXT, new_row TEXT,
299
+ quiet INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0, claimed_until INTEGER NOT NULL DEFAULT 0,
300
+ created_at INTEGER NOT NULL
301
+ )`,
302
+ `CREATE INDEX event_changes_event ON event_changes (event_id, claimed_until)`,
303
+ `CREATE TABLE push_control (id INTEGER PRIMARY KEY CHECK(id=1), quiet INTEGER NOT NULL DEFAULT 0)`,
304
+ `INSERT INTO push_control (id,quiet) VALUES (1,0)`,
305
+ `CREATE TRIGGER event_changes_insert AFTER INSERT ON events WHEN NEW.status='published' AND ${live}
306
+ BEGIN ${record("NEW.id", "NULL", snapshot("NEW"))} END`,
307
+ `CREATE TRIGGER event_changes_update AFTER UPDATE ON events
308
+ WHEN (OLD.status='published' OR NEW.status='published') AND (${changed}) AND ${live}
309
+ BEGIN ${record("NEW.id", snapshot("OLD"), snapshot("NEW"))} END`,
310
+ `CREATE TRIGGER event_changes_delete AFTER DELETE ON events WHEN OLD.status='published' AND ${live}
311
+ BEGIN ${record("OLD.id", snapshot("OLD"), "NULL")} END`,
312
+ `DROP TRIGGER push_event_insert`,
313
+ `DROP TRIGGER push_event_publish`,
314
+ `CREATE TRIGGER push_event_insert AFTER INSERT ON events WHEN NEW.status='published' BEGIN ${ledger} END`,
315
+ `CREATE TRIGGER push_event_publish AFTER UPDATE OF status ON events
316
+ WHEN NEW.status='published' AND OLD.status IS NOT 'published' BEGIN ${ledger} END`
317
+ ] };
318
+
221
319
  // src/lib/maps/migration.ts
222
320
  var PLACES_MIGRATION = { version: 43, statements: [
223
321
  `CREATE TABLE IF NOT EXISTS places (
@@ -468,8 +566,8 @@ var CONTENT_VERSION_COLUMNS = {
468
566
  };
469
567
  var BUMP_CONTENT_VERSION = "UPDATE settings SET content_version = content_version + 1 WHERE id = 1;";
470
568
  function contentVersionTriggers(table, columns) {
471
- const changed = columns.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
472
- const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(", ")} ON ${table} WHEN ${changed} BEGIN ${BUMP_CONTENT_VERSION} END`;
569
+ const changed2 = columns.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
570
+ const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(", ")} ON ${table} WHEN ${changed2} BEGIN ${BUMP_CONTENT_VERSION} END`;
473
571
  if (table === "settings") return [update];
474
572
  return [
475
573
  `CREATE TRIGGER content_version_${table}_insert AFTER INSERT ON ${table} BEGIN ${BUMP_CONTENT_VERSION} END`,
@@ -1146,7 +1244,10 @@ var MIGRATIONS = [
1146
1244
  `ALTER TABLE resources ADD COLUMN requestable INTEGER NOT NULL DEFAULT 0`,
1147
1245
  `DROP TRIGGER content_version_resources_update`,
1148
1246
  contentVersionTriggers("resources", [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events"])[2]
1149
- ] }
1247
+ ] },
1248
+ // every change to a published event, from any write path, queued for its
1249
+ // side effects (webhooks, "event changed" pushes, slug redirects)
1250
+ EVENT_CHANGES_MIGRATION
1150
1251
  ];
1151
1252
  var CURRENT_CONTENT_VERSION_COLUMNS = {
1152
1253
  ...CONTENT_VERSION_COLUMNS,
@@ -1175,7 +1276,7 @@ async function pendingMigrations(db) {
1175
1276
  return { applied, pending: MIGRATIONS.filter((m) => m.version > applied) };
1176
1277
  }
1177
1278
  async function runMigrations(db, opts = {}) {
1178
- const now4 = opts.now ?? Date.now;
1279
+ const now5 = opts.now ?? Date.now;
1179
1280
  const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1180
1281
  const first = await pendingMigrations(db);
1181
1282
  if (!first.pending.length) return { from: first.applied, to: first.applied, applied: [] };
@@ -1183,10 +1284,10 @@ async function runMigrations(db, opts = {}) {
1183
1284
  if (refused) return { from: first.applied, to: first.applied, applied: [], deferred: refused.version };
1184
1285
  await db.run(MIGRATION_LOCK_TABLE);
1185
1286
  const owner = crypto.randomUUID();
1186
- const deadline = now4() + (opts.waitMs ?? 6e4);
1287
+ const deadline = now5() + (opts.waitMs ?? 6e4);
1187
1288
  let waited = false;
1188
1289
  for (; ; ) {
1189
- const t = now4();
1290
+ const t = now5();
1190
1291
  await db.run(
1191
1292
  `INSERT INTO migration_lock (id, owner, expires_at) VALUES (1, ?, ?)
1192
1293
  ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at WHERE migration_lock.expires_at < ?`,
@@ -1204,13 +1305,13 @@ async function runMigrations(db, opts = {}) {
1204
1305
  try {
1205
1306
  const { applied, pending } = await pendingMigrations(db);
1206
1307
  if (applied === 1 && !await appliedVersion(db)) {
1207
- await db.run("INSERT INTO migrations (version, applied_at) VALUES (1, ?)", [now4()]);
1308
+ await db.run("INSERT INTO migrations (version, applied_at) VALUES (1, ?)", [now5()]);
1208
1309
  }
1209
1310
  for (const m of pending) {
1210
1311
  opts.log?.(`Applying migration ${m.version}`);
1211
1312
  await db.batch([
1212
1313
  ...m.statements.map((sql) => ({ sql })),
1213
- { sql: "INSERT INTO migrations (version, applied_at) VALUES (?, ?)", args: [m.version, now4()] }
1314
+ { sql: "INSERT INTO migrations (version, applied_at) VALUES (?, ?)", args: [m.version, now5()] }
1214
1315
  ]);
1215
1316
  }
1216
1317
  return { from: applied, to: pending.length ? pending[pending.length - 1].version : applied, applied: pending.map((m) => m.version) };
@@ -221,6 +221,12 @@ This publishes only the draft-capable items in that run. It rejects later edits
221
221
  to those items instead of implicitly publishing somebody else's changes.
222
222
  Design/layout changes conservatively share a settings guard.
223
223
 
224
+ What a run publishes or changes sends the destination's webhooks, as an edit
225
+ there would (`post.*`, `page.*`, `event.*`). It never sends app push
226
+ notifications for events: an event a run publishes counts as already
227
+ announced, and its date, time or place changes send no "When an event changes"
228
+ notice. Posts a run publishes still get the "new post" notice.
229
+
224
230
  ## History and rollback
225
231
 
226
232
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.45",
3
+ "version": "0.3.46",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -32,7 +32,6 @@ import { collectViewEvents } from '../lib/calendar/data';
32
32
  import { loadBoard } from '../lib/calendar/board';
33
33
  import { cancelOccurrence, duplicateEvent, moveOccurrence, moveSeries, quickCreate, restoreOccurrence, undoMove } from '../lib/calendar/schedule';
34
34
  import { waitUntilFrom } from '../lib/webhooks';
35
- import { eventChanged, eventDeleted } from '../lib/calendar/hooks';
36
35
  import { siteOrigin } from '../lib/seo';
37
36
  import type { CalEvent } from '../lib/schema';
38
37
  import { CalEvent as CalEventSchema } from '../lib/schema';
@@ -40,7 +39,7 @@ import { diffDesign, revertDesignChange, summarizeChanges } from '../lib/designD
40
39
  import { shareEpoch } from '../lib/drafts';
41
40
  import { createDesignToken } from '../lib/preview';
42
41
  import { getTheme } from '../lib/theme';
43
- import { savePost, applyPostUpdate, savePage, applyPageUpdate, saveEvent, applyEventUpdate, type SaveHooks } from '../lib/content-service';
42
+ import { savePost, applyPostUpdate, savePage, applyPageUpdate, saveEvent, applyEventUpdate, flushEventHooks, type SaveHooks } from '../lib/content-service';
44
43
  import { resolveMapLink, searchPlaces } from '../lib/maps/service';
45
44
  import { savePlace } from '../lib/maps/places';
46
45
 
@@ -1027,9 +1026,8 @@ export const server = {
1027
1026
  handler: async ({ id }, ctx) => {
1028
1027
  await requireCan(ctx, 'content.delete');
1029
1028
  const repo = await getRepo(ctx.locals);
1030
- const before = await repo.getEvent(id);
1031
1029
  await repo.deleteEvent(id);
1032
- await eventDeleted(repo, before, { waitUntil: waitUntilFrom(ctx.locals) });
1030
+ await flushEventHooks(repo, hooksFor(ctx));
1033
1031
  return { ok: true };
1034
1032
  },
1035
1033
  }),
@@ -11,7 +11,7 @@ import { CALENDAR } from './config';
11
11
  import { EventToolInputs } from './agent-schema';
12
12
  import { featuredOccurrence, formatOccurrence, isValidTimeZone, normalizeEventTimes } from './events';
13
13
  import { mergeEventDraft } from '../drafts';
14
- import { eventChanged } from './hooks';
14
+ import { flushEventChanges } from './hooks';
15
15
  import { eventToJson } from './api';
16
16
  import { siteLocale } from '../dates';
17
17
  import { EventLocation } from '../schema';
@@ -169,9 +169,8 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
169
169
  if (!before) throw new ApiError(404, 'Event not found.');
170
170
  if (before.draft) await repo.applyEventDraft(before.id);
171
171
  await repo.updateEvent(before.id, { status: 'published', scheduledAt: null });
172
- const after = (await repo.getEvent(before.id))!;
173
- await eventChanged(repo, before, after, { origin: ctx.origin, waitUntil: ctx.waitUntil, applied: Boolean(before.draft) });
174
- return { ...summary(after) };
172
+ await flushEventChanges(repo, { origin: ctx.origin, waitUntil: ctx.waitUntil });
173
+ return { ...summary((await repo.getEvent(before.id))!) };
175
174
  }
176
175
  }
177
176
  }
@@ -0,0 +1,65 @@
1
+ // v65: every write that touches a published event (editor, Schedule, agent,
2
+ // REST, scheduled publishing, place refresh, environment sync, any raw SQL)
3
+ // leaves a row in event_changes, written by triggers in the same transaction:
4
+ // the event as it was and as written. lib/calendar/hooks.ts drains it into the
5
+ // side effects (webhook, "event changed" push, slug redirect), so no caller
6
+ // has to remember to. Its own table, not a shared "last change" column, so
7
+ // other features can keep their own queues from their own triggers on events.
8
+ //
9
+ // Only changes a visitor can see are recorded (CHANGED): hosts, members'
10
+ // requests, staged drafts and bookkeeping never are. Restores run
11
+ // with webhook_control.suppressed=1 and record nothing. Environment sync sets
12
+ // push_control.quiet=1 for its transaction: its changes still send webhooks,
13
+ // but never member pushes (its first publishes are recorded as skipped, like
14
+ // restores). Posts keep their v37 triggers.
15
+ //
16
+ // Frozen with v65: a later events column joins by a new migration that
17
+ // recreates these triggers (the test that sorts every column fails until then).
18
+ const now = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
19
+
20
+ /** the columns copied into each snapshot (what the webhook payload and push rules read) */
21
+ export const EVENT_CHANGE_SNAPSHOT = ['id', 'slug', 'title', 'excerpt', 'cover', 'cover_alt', 'status', 'featured', 'tags',
22
+ 'starts', 'ends', 'all_day', 'timezone', 'rrule', 'exdates', 'cancelled', 'cancelled_dates', 'location', 'cta', 'calendar_ids',
23
+ 'fields', 'rsvp', 'room_hold', 'start_at', 'end_at', 'published_at', 'scheduled_at', 'created_at', 'updated_at'];
24
+ /** the columns whose change counts (published_at and scheduled_at follow status; room_hold names the rooms public data shows) */
25
+ export const EVENT_CHANGE_COLUMNS = ['slug', 'title', 'body_json', 'body_html', 'excerpt', 'cover', 'cover_alt', 'seo_title', 'seo_desc',
26
+ 'status', 'featured', 'tags', 'template', 'starts', 'ends', 'all_day', 'timezone', 'rrule', 'exdates', 'cancelled', 'cancelled_dates',
27
+ 'location', 'cta', 'calendar_ids', 'fields', 'rsvp', 'translations', 'room_hold', 'start_at', 'end_at'];
28
+ /** columns no change is recorded for */
29
+ export const EVENT_CHANGE_IGNORED = ['id', 'draft', 'host_ids', 'request', 'source_uid', 'published_at', 'scheduled_at', 'created_at', 'updated_at'];
30
+
31
+ const snapshot = (row: 'OLD' | 'NEW') => `json_object(${EVENT_CHANGE_SNAPSHOT.map((c) => `'${c}',${row}.${c}`).join(',')})`;
32
+ const live = '(SELECT suppressed FROM webhook_control WHERE id=1)=0';
33
+ const quiet = 'COALESCE((SELECT quiet FROM push_control WHERE id=1),0)';
34
+ const record = (id: string, before: string, after: string) => `INSERT INTO event_changes (event_id,old_row,new_row,quiet,created_at)
35
+ VALUES (${id},${before},${after},${quiet},${now});`;
36
+ const changed = EVENT_CHANGE_COLUMNS.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(' OR ');
37
+
38
+ // v37's first-publish ledger rows, now also skipped while push_control.quiet is set
39
+ const skipped = `CASE WHEN (SELECT suppressed FROM webhook_control WHERE id=1)=1 OR ${quiet}=1 THEN 'skipped' ELSE 'pending' END`;
40
+ const ledger = `INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
41
+ SELECT 'event:'||NEW.id,'event',NEW.id,NULL,${skipped},NULL,${now},${now}
42
+ WHERE NOT EXISTS (SELECT 1 FROM push_queue WHERE key='event:'||NEW.id);`;
43
+
44
+ export const EVENT_CHANGES_MIGRATION = { version: 65, statements: [
45
+ `CREATE TABLE event_changes (
46
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL, old_row TEXT, new_row TEXT,
47
+ quiet INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0, claimed_until INTEGER NOT NULL DEFAULT 0,
48
+ created_at INTEGER NOT NULL
49
+ )`,
50
+ `CREATE INDEX event_changes_event ON event_changes (event_id, claimed_until)`,
51
+ `CREATE TABLE push_control (id INTEGER PRIMARY KEY CHECK(id=1), quiet INTEGER NOT NULL DEFAULT 0)`,
52
+ `INSERT INTO push_control (id,quiet) VALUES (1,0)`,
53
+ `CREATE TRIGGER event_changes_insert AFTER INSERT ON events WHEN NEW.status='published' AND ${live}
54
+ BEGIN ${record('NEW.id', 'NULL', snapshot('NEW'))} END`,
55
+ `CREATE TRIGGER event_changes_update AFTER UPDATE ON events
56
+ WHEN (OLD.status='published' OR NEW.status='published') AND (${changed}) AND ${live}
57
+ BEGIN ${record('NEW.id', snapshot('OLD'), snapshot('NEW'))} END`,
58
+ `CREATE TRIGGER event_changes_delete AFTER DELETE ON events WHEN OLD.status='published' AND ${live}
59
+ BEGIN ${record('OLD.id', snapshot('OLD'), 'NULL')} END`,
60
+ `DROP TRIGGER push_event_insert`,
61
+ `DROP TRIGGER push_event_publish`,
62
+ `CREATE TRIGGER push_event_insert AFTER INSERT ON events WHEN NEW.status='published' BEGIN ${ledger} END`,
63
+ `CREATE TRIGGER push_event_publish AFTER UPDATE OF status ON events
64
+ WHEN NEW.status='published' AND OLD.status IS NOT 'published' BEGIN ${ledger} END`,
65
+ ] };
@@ -1,42 +1,87 @@
1
- // Side effects when an event changes live: a 301 when a published event's
2
- // slug moves, a queued "event changed" app notice when its schedule or place
3
- // moves, and the matching webhook (event.published / event.updated /
4
- // event.unpublished). Shared by the editor actions, the list's bulk actions
5
- // and the agent tools so every path behaves the same.
6
- import type { ContentRepository } from '../repo/types';
7
- import type { CalEvent } from '../schema';
1
+ // Side effects when a published event changes: a 301 when its slug moves, a
2
+ // queued "event changed" app notice when its schedule or place moves, and the
3
+ // matching webhook (event.published / event.updated / event.unpublished /
4
+ // event.deleted). Every write path records its changes in event_changes
5
+ // (triggers, ./change-migration.ts) in the same transaction, so this is the
6
+ // one place they become side effects, whoever made them. Editor paths flush
7
+ // at once (the redirect works on the very next request); the flush after
8
+ // every mutating request and the scheduler catch the rest, raw SQL included
9
+ // (scheduled publishing, environment sync, place refreshes).
10
+ import type { ContentRepository, EventChange } from '../repo/types';
11
+ import type { Calendar, Resource, Settings } from '../schema';
8
12
  import { emitWebhook } from '../webhooks';
9
- import { queueEventChange } from '../push/service';
13
+ import { queueEventChange, siteOriginOf } from '../push/service';
10
14
  import { eventToJson } from './api';
11
15
  import { eventUrl } from './config';
12
16
 
13
- export async function eventChanged(
14
- repo: ContentRepository,
15
- before: CalEvent,
16
- after: CalEvent,
17
- opts: { origin: string; waitUntil?: (p: Promise<unknown>) => void; applied?: boolean; onRedirect?: () => void }
18
- ): Promise<void> {
19
- if (before.status === 'published' && after.status === 'published' && before.slug !== after.slug) {
17
+ export interface FlushOptions {
18
+ /** this request's origin, for webhook links while the site URL isn't set */
19
+ origin?: string;
20
+ waitUntil?: (p: Promise<unknown>) => void;
21
+ /** called when a slug redirect was added (the middleware drops its cached rules) */
22
+ onRedirect?: () => void;
23
+ now?: number;
24
+ }
25
+
26
+ const BATCH = 50;
27
+ const ROUNDS = 20;
28
+ /** how long a claim holds its changes; one that failed is retried after it */
29
+ const LEASE = 5 * 60_000;
30
+ /** a change that failed this many times is dropped */
31
+ const MAX_ATTEMPTS = 3;
32
+
33
+ interface Site { origin: string; settings: Settings; calendars: Map<string, Calendar>; resources: Map<string, Resource> }
34
+
35
+ // One write, as webhooks for posts report them: several writes in one save
36
+ // send one webhook each. The push queue keeps what people last knew, so
37
+ // several changes close together still send one notice.
38
+ async function apply(repo: ContentRepository, change: EventChange, now: number, site: () => Promise<Site>, opts: FlushOptions) {
39
+ const { before, after } = change;
40
+ const was = before?.status === 'published';
41
+ if (was && after?.status === 'published' && before.slug !== after.slug) {
20
42
  await repo.createRedirect({ fromPath: eventUrl(before.slug), toPath: eventUrl(after.slug) }).catch(() => {});
21
43
  opts.onRedirect?.();
22
44
  }
23
- // the scheduler sends it once the edits settle, if the rule still applies
24
- await queueEventChange(repo, before, after).catch((e) => console.error('acms push: event change not queued', e));
25
- let type: 'event.published' | 'event.updated' | 'event.unpublished' | null = null;
26
- if (before.status !== 'published' && after.status === 'published') type = 'event.published';
27
- else if (before.status === 'published' && after.status !== 'published') type = 'event.unpublished';
28
- else if (after.status === 'published' && (opts.applied || before.updatedAt !== after.updatedAt)) type = 'event.updated';
45
+ // the scheduler sends it once the edits settle, if the rule still applies;
46
+ // a bulk copy (quiet) sends webhooks and redirects, never pushes
47
+ if (before && after && !change.quiet) await queueEventChange(repo, before, after, now).catch((e) => console.error('acms push: event change not queued', e));
48
+ if (!after) {
49
+ if (was) await emitWebhook(repo, 'event.deleted', { event: { id: before.id, slug: before.slug, title: before.title } }, { waitUntil: opts.waitUntil }).catch(() => {});
50
+ return;
51
+ }
52
+ const type = was ? (after.status === 'published' ? 'event.updated' : 'event.unpublished') : after.status === 'published' ? 'event.published' : null;
29
53
  if (!type) return;
30
- const [settings, calendars, rooms] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listResources()]);
31
- const data = eventToJson(after, opts.origin, new Map(calendars.map((c) => [c.id, c])), settings.timezone, { resources: new Map(rooms.map((r) => [r.id, r])) });
32
- await emitWebhook(repo, type, { event: data }, { waitUntil: opts.waitUntil }).catch(() => {});
54
+ const { origin, settings, calendars, resources } = await site();
55
+ await emitWebhook(repo, type, { event: eventToJson(after, origin, calendars, settings.timezone, { resources }) }, { waitUntil: opts.waitUntil }).catch(() => {});
33
56
  }
34
57
 
35
- export async function eventDeleted(
36
- repo: ContentRepository,
37
- before: CalEvent | null,
38
- opts: { waitUntil?: (p: Promise<unknown>) => void }
39
- ): Promise<void> {
40
- if (before?.status !== 'published') return;
41
- await emitWebhook(repo, 'event.deleted', { event: { id: before.id, slug: before.slug, title: before.title } }, opts).catch(() => {});
58
+ /** Runs the side effects of every recorded change to a published event, oldest first. Returns how many changes it handled. */
59
+ export async function flushEventChanges(repo: ContentRepository, opts: FlushOptions = {}): Promise<number> {
60
+ let site: Promise<Site> | null = null;
61
+ const load = () => (site ??= Promise.all([repo.getSettings(), repo.listCalendars(), repo.listResources()]).then(([settings, calendars, rooms]) => ({
62
+ origin: siteOriginOf(settings, opts.origin), settings, calendars: new Map(calendars.map((c) => [c.id, c])), resources: new Map(rooms.map((r) => [r.id, r])) })));
63
+ let handled = 0;
64
+ for (let round = 0; round < ROUNDS; round++) {
65
+ const now = opts.now ?? Date.now();
66
+ const claimed = await repo.claimEventChanges(now, BATCH, LEASE);
67
+ if (!claimed.length) break;
68
+ const done: number[] = [];
69
+ const failed = new Set<string>();
70
+ for (const change of claimed) {
71
+ // a later change to an event whose earlier one failed waits with it, in order
72
+ if (failed.has(change.eventId)) continue;
73
+ try {
74
+ // a snapshot that no longer parses has nothing to report
75
+ if (change.before || change.after) await apply(repo, change, now, load, opts);
76
+ } catch (e) {
77
+ console.error('acms calendar: event change side effects failed', e);
78
+ if (change.attempts < MAX_ATTEMPTS) { failed.add(change.eventId); continue; }
79
+ }
80
+ done.push(change.seq);
81
+ }
82
+ await repo.finishEventChanges(done);
83
+ handled += done.length;
84
+ if (claimed.length < BATCH) break;
85
+ }
86
+ return handled;
42
87
  }
@@ -2,10 +2,9 @@ import type { ContentRepository } from '../repo/types';
2
2
  import type { CalEvent, EventRequest } from '../schema';
3
3
  import { EventLocation } from '../schema';
4
4
  import { ApiError } from '../api';
5
- import { applyEventUpdate, saveEvent, type SaveHooks } from '../content-service';
5
+ import { applyEventUpdate, flushEventHooks, saveEvent, type SaveHooks } from '../content-service';
6
6
  import { CALENDAR } from './config';
7
7
  import { cancelOccurrence, restoreOccurrence, uniqueSlug } from './schedule';
8
- import { eventDeleted } from './hooks';
9
8
  import { waitUntilFrom } from '../webhooks';
10
9
  import { kickEmail } from '../email/service';
11
10
 
@@ -191,7 +190,7 @@ export async function withdrawEventRequest(repo: ContentRepository, who: Actor |
191
190
  // only an event the member's own request made is deleted; a draft staff wrote stays
192
191
  if (e.request.kind === 'new' && e.request.created && !live(e) && !e.publishedAt) {
193
192
  await repo.deleteEvent(id);
194
- await eventDeleted(repo, e, { waitUntil: waitUntilFrom(hooks.locals) });
193
+ await flushEventHooks(repo, hooks);
195
194
  return { deleted: true };
196
195
  }
197
196
  if (e.request.kind === 'changes') await repo.discardEventDraft(id);
@@ -10,10 +10,9 @@ import type { CalEvent } from '../schema';
10
10
  import { EventLocation, slugify } from '../schema';
11
11
  import { defaultPlaceLocation } from '../maps/places';
12
12
  import { ApiError } from '../api';
13
- import { applyEventUpdate, eventSideEffects, saveEvent, type SaveHooks } from '../content-service';
13
+ import { applyEventUpdate, eventSideEffects, flushEventHooks, saveEvent, type SaveHooks } from '../content-service';
14
14
  import { addWallDays, findOccurrence, formatWall, parseWall } from './events';
15
15
  import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
16
- import { eventDeleted } from './hooks';
17
16
  import { waitUntilFrom } from '../webhooks';
18
17
  import { kickEmail } from '../email/service';
19
18
 
@@ -114,6 +113,7 @@ export async function moveSeries(repo: ContentRepository, id: string, days: numb
114
113
  // cancelled dates move with the series, like skipped ones
115
114
  if (e.rrule && e.cancelledDates.length) {
116
115
  await repo.setEventCancelledDates(id, e.cancelledDates.map((x) => shift(x, days)));
116
+ await flushEventHooks(repo, hooks);
117
117
  event = (await repo.getEvent(id))!;
118
118
  }
119
119
  return { event, undo: { kind: 'series', id, days: -days } };
@@ -177,7 +177,7 @@ export async function undoMove(repo: ContentRepository, undo: MoveUndo, hooks: S
177
177
  await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
178
178
  }
179
179
  await repo.deleteEvent(copy.id);
180
- await eventDeleted(repo, copy, { waitUntil: waitUntilFrom(hooks.locals) });
180
+ await flushEventHooks(repo, hooks);
181
181
  }
182
182
  }
183
183
 
@@ -202,7 +202,7 @@ async function setOccurrenceCancelled(repo: ContentRepository, id: string, key:
202
202
  await repo.setEventCancelledDates(id, cancel ? [...e.cancelledDates, key].sort() : e.cancelledDates.filter((x) => x !== key));
203
203
  if (cancel && await repo.cancelOccurrenceRsvps(id, o.start)) await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
204
204
  const after = (await repo.getEvent(id))!;
205
- await eventSideEffects(repo, hooks, e, after);
205
+ await eventSideEffects(repo, hooks, after);
206
206
  return after;
207
207
  }
208
208
 
@@ -7,7 +7,7 @@ import { deniedMessage } from './permissions/catalog';
7
7
  import { CONTENT, collectionUrl } from './content';
8
8
  import { CALENDAR } from './calendar/config';
9
9
  import { formatWall, isValidTimeZone, normalizeEventTimes, parseWall } from './calendar/events';
10
- import { eventChanged } from './calendar/hooks';
10
+ import { flushEventChanges } from './calendar/hooks';
11
11
  import { offerEventWaitlist } from './booking/service';
12
12
  import { normalizePageLocale } from './i18n';
13
13
  import { siteOrigin } from './seo';
@@ -219,14 +219,18 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
219
219
  return out;
220
220
  }
221
221
 
222
- export async function eventSideEffects(repo: ContentRepository, hooks: SaveHooks, before: CalEvent, after: CalEvent, applied = false) {
223
- const settings = await repo.getSettings();
224
- const origin = siteOrigin(settings, hooks.url);
225
- await eventChanged(repo, before, after, {
226
- origin, waitUntil: waitUntilFrom(hooks.locals), applied, onRedirect: hooks.onRedirect,
227
- });
222
+ /** the side effects of event writes made so far (calendar/hooks.ts), at once rather than after the response */
223
+ export function flushEventHooks(repo: ContentRepository, hooks: SaveHooks): Promise<number> {
224
+ return flushEventChanges(repo, { origin: hooks.url.origin, waitUntil: waitUntilFrom(hooks.locals), onRedirect: hooks.onRedirect });
225
+ }
226
+
227
+ export async function eventSideEffects(repo: ContentRepository, hooks: SaveHooks, after: CalEvent) {
228
+ await flushEventHooks(repo, hooks);
228
229
  // seats an edit freed (a higher capacity) go to those in line now, not at the next sweep
229
- if (after.rsvp.waitlist) await offerEventWaitlist(repo, after.id, { origin, locals: hooks.locals, waitUntil: waitUntilFrom(hooks.locals) });
230
+ if (after.rsvp.waitlist) {
231
+ const origin = siteOrigin(await repo.getSettings(), hooks.url);
232
+ await offerEventWaitlist(repo, after.id, { origin, locals: hooks.locals, waitUntil: waitUntilFrom(hooks.locals) });
233
+ }
230
234
  }
231
235
 
232
236
  export async function saveEvent(repo: ContentRepository, id: string, data: EventSave, hooks: SaveHooks): Promise<CalEvent> {
@@ -249,17 +253,16 @@ export async function saveEvent(repo: ContentRepository, id: string, data: Event
249
253
  await repo.updateEvent(id, clean);
250
254
  if (clean.tags !== undefined) await repo.ensureTags(clean.tags);
251
255
  const event = (await repo.getEvent(id))!;
252
- await eventSideEffects(repo, hooks, before, event);
256
+ await eventSideEffects(repo, hooks, event);
253
257
  return event;
254
258
  }
255
259
 
256
260
  export async function applyEventUpdate(repo: ContentRepository, id: string, hooks: SaveHooks): Promise<CalEvent> {
257
261
  assertCanPublish(hooks);
258
- const before = await repo.getEvent(id);
259
- if (!before) throw new ApiError(404, 'Event not found.');
262
+ if (!(await repo.getEvent(id))) throw new ApiError(404, 'Event not found.');
260
263
  await repo.applyEventDraft(id);
261
264
  const event = (await repo.getEvent(id))!;
262
265
  if (event.tags.length) await repo.ensureTags(event.tags);
263
- await eventSideEffects(repo, hooks, before, event, true);
266
+ await eventSideEffects(repo, hooks, event);
264
267
  return event;
265
268
  }
@@ -356,7 +356,7 @@ export function eventChange(was: EventSnapshot, e: CalEvent, settings: Pick<Sett
356
356
  return starts.length ? { ...out, at: Math.min(...starts) } : null;
357
357
  }
358
358
 
359
- /** Called by eventChanged on every path: queues (or postpones) a change notice when a published event's schedule or place differs. */
359
+ /** Called for every recorded change to a published event (calendar/hooks.ts): queues (or postpones) a change notice when its schedule or place differs. */
360
360
  export async function queueEventChange(repo: ContentRepository, before: CalEvent, after: CalEvent, now = Date.now()): Promise<boolean> {
361
361
  // publishing is the "new event" notice; unpublishing says nothing
362
362
  if (!PUSH.enabled || !CALENDAR.enabled || before.status !== 'published' || after.status !== 'published') return false;
@@ -5,6 +5,7 @@ import { WEBHOOK_MIGRATION } from '../webhook-sql';
5
5
  import { PUSH_MIGRATION } from '../push/migration';
6
6
  import { EDITOR_MIGRATION, EDITOR_GRACE_MIGRATION } from '../editor/migration';
7
7
  import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/migration';
8
+ import { EVENT_CHANGES_MIGRATION } from '../calendar/change-migration';
8
9
  import { PLACES_MIGRATION } from '../maps/migration';
9
10
  import { ROLES_MIGRATION } from '../permissions/migration';
10
11
  import { SYNC_MIGRATION } from '../sync/migration';
@@ -720,6 +721,9 @@ export const MIGRATIONS: Migration[] = [
720
721
  `DROP TRIGGER content_version_resources_update`,
721
722
  contentVersionTriggers('resources', [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events'])[2],
722
723
  ] },
724
+ // every change to a published event, from any write path, queued for its
725
+ // side effects (webhooks, "event changed" pushes, slug redirects)
726
+ EVENT_CHANGES_MIGRATION,
723
727
  ];
724
728
 
725
729
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
@@ -1030,6 +1030,33 @@ export class SqlRepository implements ContentRepository {
1030
1030
  if (patch.live) await this.syncEventIndex(id);
1031
1031
  }
1032
1032
 
1033
+ async claimEventChanges(now: number, limit: number, leaseMs: number) {
1034
+ await this.ready;
1035
+ const rows = await this.driver.all(
1036
+ `UPDATE event_changes SET claimed_until=?, attempts=attempts+1 WHERE seq IN (
1037
+ SELECT seq FROM event_changes WHERE claimed_until<=?
1038
+ AND event_id NOT IN (SELECT event_id FROM event_changes WHERE claimed_until>?)
1039
+ ORDER BY seq LIMIT ?) RETURNING *`,
1040
+ [now + leaseMs, now, now, limit]
1041
+ );
1042
+ return rows.map((r) => {
1043
+ // a snapshot that no longer parses drops the whole change (never a fake insert or delete)
1044
+ let before: CalEvent | null = null, after: CalEvent | null = null;
1045
+ try {
1046
+ before = r.old_row ? rowToEvent(JSON.parse(String(r.old_row))) : null;
1047
+ after = r.new_row ? rowToEvent(JSON.parse(String(r.new_row))) : null;
1048
+ } catch { before = after = null; }
1049
+ return { seq: Number(r.seq), eventId: String(r.event_id), before, after, quiet: Boolean(r.quiet), attempts: Number(r.attempts), createdAt: Number(r.created_at) };
1050
+ }).sort((a, b) => a.seq - b.seq);
1051
+ }
1052
+ async finishEventChanges(seqs: number[]) {
1053
+ await this.ready;
1054
+ for (let i = 0; i < seqs.length; i += 200) {
1055
+ const chunk = seqs.slice(i, i + 200);
1056
+ await this.driver.run(`DELETE FROM event_changes WHERE seq IN (${chunk.map(() => '?').join(', ')})`, chunk);
1057
+ }
1058
+ }
1059
+
1033
1060
  // ---- places ----
1034
1061
  private placeInsert(p: Place): SqlStatement {
1035
1062
  return {
@@ -1866,6 +1893,8 @@ export class SqlRepository implements ContentRepository {
1866
1893
  // Suppression and re-enabling share the restore transaction. A failed
1867
1894
  // restore cannot leave notifications disabled or emit phantom deletes.
1868
1895
  { sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' },
1896
+ // changes made before the restore describe content it replaces
1897
+ { sql: 'DELETE FROM event_changes' },
1869
1898
  { sql: "UPDATE auth_invitations SET state='revoked' WHERE state<>'accepted'" },
1870
1899
  ...['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) => ({
1871
1900
  sql: `DELETE FROM ${t}`,
@@ -54,6 +54,21 @@ export type EventUpdate = Partial<
54
54
  >
55
55
  >;
56
56
 
57
+ /** One recorded change to a published event (the event_changes outbox, written by triggers). */
58
+ export interface EventChange {
59
+ seq: number;
60
+ eventId: string;
61
+ /** the event as it was: null when it was inserted */
62
+ before: CalEvent | null;
63
+ /** the event as written: null when it was deleted */
64
+ after: CalEvent | null;
65
+ /** written by a bulk copy (environment sync): no member pushes */
66
+ quiet: boolean;
67
+ /** times it was claimed, this one included */
68
+ attempts: number;
69
+ createdAt: number;
70
+ }
71
+
57
72
  // The single contract both backends implement. The app only ever imports
58
73
  // this — never a concrete adapter.
59
74
  export interface ContentRepository {
@@ -168,6 +183,12 @@ export interface ContentRepository {
168
183
  // draft itself — alone. notify = RSVP guests of a published event get an
169
184
  // "event updated" email (the address changed).
170
185
  setEventLocation(id: string, patch: { live?: EventLocation; draft?: EventLocation; notify?: boolean }): Promise<void>;
186
+ // changes to published events waiting for their side effects
187
+ // (lib/calendar/hooks.ts), oldest first. A claim holds the rows for
188
+ // `leaseMs` and skips events another claim still holds, so two runners
189
+ // never handle one event's changes out of order. finish removes them.
190
+ claimEventChanges(now: number, limit: number, leaseMs: number): Promise<EventChange[]>;
191
+ finishEventChanges(seqs: number[]): Promise<void>;
171
192
 
172
193
  // saved places. Only one can be the default (saving one as default clears
173
194
  // the others). Deleting a place unlinks its rooms; events keep their copy.
@@ -1,6 +1,7 @@
1
1
  import { syncDueLive } from './live/service';
2
2
  import type { ContentRepository } from './repo/types';
3
3
  import { processDueDeliveries } from './webhooks';
4
+ import { flushEventChanges } from './calendar/hooks';
4
5
  import { dispatchEmail } from './email/service';
5
6
  import { decideOverdue, sendDueReminders, sweepWaitlist } from './booking/service';
6
7
  import { runPushJobs } from './push/service';
@@ -17,6 +18,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
17
18
  const settings = await repo.getSettings();
18
19
  const jobs = {
19
20
  publish: () => repo.publishDuePosts(startedAt),
21
+ // changes to published events (scheduled publishing above included): webhooks, change pushes, redirects
22
+ events: () => flushEventChanges(repo),
20
23
  webhooks: () => processDueDeliveries(repo),
21
24
  reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
22
25
  // pending bookings past their approval deadline (Calendar settings → Bookings)
@@ -327,7 +327,11 @@ export class SyncStore {
327
327
  sql: "INSERT INTO revision_checks(expected,actual) SELECT 1,CASE WHEN result IS NULL THEN 1 ELSE 0 END FROM sync_plans WHERE id=?",
328
328
  args: [planId],
329
329
  },
330
+ // a transfer is a bulk copy: its event changes send webhooks but
331
+ // never member pushes (the flag shares this transaction)
332
+ { sql: "UPDATE push_control SET quiet=1 WHERE id=1" },
330
333
  ...statements,
334
+ { sql: "UPDATE push_control SET quiet=0 WHERE id=1" },
331
335
  {
332
336
  sql: "UPDATE sync_plans SET result=json_set(?,'$.versions',json((SELECT json_group_object(entity,revision) FROM sync_versions WHERE entity IN (SELECT value FROM json_each(?))))) WHERE id=?",
333
337
  args: [JSON.stringify(result), JSON.stringify(versionKeys), planId],
package/src/middleware.ts CHANGED
@@ -7,6 +7,7 @@ import { AUTH } from './lib/auth/config';
7
7
  import { getRepo } from './lib/repo';
8
8
  import { CONTENT, legacyBases, localizesRedirectsTo } from './lib/content';
9
9
  import { processDueDeliveries, waitUntilFrom } from './lib/webhooks';
10
+ import { flushEventChanges } from './lib/calendar/hooks';
10
11
  import { dispatchEmail } from './lib/email/service';
11
12
  import { decideOverdue, sendDueReminders } from './lib/booking/service';
12
13
  import { PUSH } from './lib/push/config';
@@ -151,10 +152,14 @@ async function handle(context: APIContext, next: MiddlewareNext): Promise<Respon
151
152
  try {
152
153
  const repo = await getRepo(context.locals);
153
154
  await repo.publishDuePosts(now);
155
+ const wait = waitUntilFrom(context.locals);
156
+ // scheduled events that just went live get their webhooks (calendar/hooks.ts)
157
+ const changes = flushEventChanges(repo, { origin: context.url.origin, waitUntil: wait, onRedirect: invalidateRedirectCache })
158
+ .catch((e) => console.error('acms calendar: event changes not flushed', e));
159
+ if (wait) wait(changes); else await changes;
154
160
  // webhook retries ride the same once-a-minute sweep (Node has no
155
161
  // cron) — in the background, never holding up this request
156
162
  const emails = dispatchEmail(repo, context.locals, 2).catch(() => 0);
157
- const wait = waitUntilFrom(context.locals);
158
163
  if (wait) wait(emails); else await emails;
159
164
  const retries = processDueDeliveries(repo).catch(() => {});
160
165
  if (wait) wait(retries); else await retries;
@@ -186,8 +191,14 @@ async function handle(context: APIContext, next: MiddlewareNext): Promise<Respon
186
191
  // Flush after mutations (including Astro actions), once their transaction
187
192
  // has committed. The scheduler remains the durable fallback on failure.
188
193
  if (!['GET', 'HEAD', 'OPTIONS'].includes(context.request.method)) {
189
- const deliveries = getRepo(context.locals).then(repo => processDueDeliveries(repo, { limit: 5 })).catch(() => 0);
190
194
  const wait = waitUntilFrom(context.locals);
195
+ // whatever this request changed on published events, by any path: its
196
+ // webhooks, "event changed" pushes and slug redirects (calendar/hooks.ts)
197
+ const changes = getRepo(context.locals)
198
+ .then(repo => flushEventChanges(repo, { origin: context.url.origin, waitUntil: wait, onRedirect: invalidateRedirectCache }))
199
+ .catch((e) => console.error('acms calendar: event changes not flushed', e));
200
+ if (wait) wait(changes); else await changes;
201
+ const deliveries = getRepo(context.locals).then(repo => processDueDeliveries(repo, { limit: 5 })).catch(() => 0);
191
202
  if (wait) wait(deliveries); else await deliveries;
192
203
  // first-publish push notices: background only, never delaying the response
193
204
  if (PUSH.enabled) {
@@ -14,7 +14,7 @@ import { describeRepeat } from '../../../../lib/calendar/rrule';
14
14
  import { expandVEvents, loadFeed, tzParts } from '../../../../lib/calendar/ics';
15
15
  import { normalizeCalendarSrc } from '../../../../lib/calendar/src';
16
16
  import { eventsFromCsv, eventsFromIcs, eventsFromVEvents } from '../../../../lib/calendar/import';
17
- import { eventChanged, eventDeleted } from '../../../../lib/calendar/hooks';
17
+ import { flushEventChanges } from '../../../../lib/calendar/hooks';
18
18
  import { siteOrigin } from '../../../../lib/seo';
19
19
  import { siteLocale } from '../../../../lib/dates';
20
20
  import { waitUntilFrom } from '../../../../lib/webhooks';
@@ -69,7 +69,6 @@ async function saveImported(list: ReturnType<typeof eventsFromIcs>['events'], ca
69
69
  if (Astro.request.method === 'POST') {
70
70
  const form = await Astro.request.formData();
71
71
  const op = String(form.get('op'));
72
- const hookOpts = { origin: siteOrigin(settings, Astro.url), waitUntil: waitUntilFrom(Astro.locals), onRedirect: invalidateRedirectCache };
73
72
  // create, copy and import make drafts; bulk status changes that touch live events need Publish
74
73
  let needs: Permission = 'content.edit';
75
74
  // answering members' event requests needs Publish (approving puts them live)
@@ -126,16 +125,12 @@ if (Astro.request.method === 'POST') {
126
125
  if (op === 'bulk') {
127
126
  const to = String(form.get('to'));
128
127
  for (const id of form.getAll('ids').map(String).filter(Boolean)) {
129
- const before = await repo.getEvent(id);
130
- if (!before) continue;
131
- if (to === 'delete') {
132
- await repo.deleteEvent(id);
133
- await eventDeleted(repo, before, hookOpts);
134
- } else if (to === 'published' || to === 'draft' || to === 'archived') {
128
+ if (to === 'delete') await repo.deleteEvent(id);
129
+ else if (to === 'published' || to === 'draft' || to === 'archived') {
135
130
  await repo.updateEvent(id, { status: to as Status, ...(to === 'published' ? { scheduledAt: null } : {}) });
136
- await eventChanged(repo, before, (await repo.getEvent(id))!, hookOpts);
137
131
  }
138
132
  }
133
+ await flushEventChanges(repo, { origin: siteOrigin(settings, Astro.url), waitUntil: waitUntilFrom(Astro.locals), onRedirect: invalidateRedirectCache });
139
134
  return back();
140
135
  }
141
136
 
@@ -59,6 +59,8 @@ Add endpoints in Team → API & webhooks. Events: event.published, event.updated
59
59
  Each POST has headers X-Acms-Event, X-Acms-Delivery, X-Acms-Timestamp and
60
60
  X-Acms-Signature: sha256=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>.
61
61
  Body: { "id", "type", "createdAt", "data": { ... } }. Failed deliveries retry after 1, 10 and 60 minutes.
62
+ event.* fire however an event changes: the editor, Schedule, this API, agent tools, scheduled
63
+ publishing, a saved place's new address and environment sync. Restores send site.restored instead.
62
64
 
63
65
  ## MCP / agent tools
64
66
 
@@ -7,7 +7,7 @@ import { CALENDAR } from '../../../../lib/calendar/config';
7
7
  import { eventIsPublic } from '../../../../lib/calendar/events';
8
8
  import { eventToJson } from '../../../../lib/calendar/api';
9
9
  import { runAgentTool } from '../../../../lib/agent';
10
- import { eventDeleted } from '../../../../lib/calendar/hooks';
10
+ import { flushEventChanges } from '../../../../lib/calendar/hooks';
11
11
  import { mergeEventDraft } from '../../../../lib/drafts';
12
12
  import { waitUntilFrom } from '../../../../lib/webhooks';
13
13
  import { fail, preflight, withCors } from '../../../../lib/calendar/http';
@@ -50,14 +50,14 @@ export const PATCH: APIRoute = async ({ request, locals, url, params }) => {
50
50
  } catch (err) { return fail(err); }
51
51
  };
52
52
 
53
- export const DELETE: APIRoute = async ({ request, locals, params }) => {
53
+ export const DELETE: APIRoute = async ({ request, locals, url, params }) => {
54
54
  try {
55
55
  const repo = await getRepo(locals);
56
56
  await requireScope(request, repo, 'events:write');
57
57
  const e = await find(repo, params.id!);
58
58
  if (!e) throw new ApiError(404, 'Event not found.');
59
59
  await repo.deleteEvent(e.id);
60
- await eventDeleted(repo, e, { waitUntil: waitUntilFrom(locals) });
60
+ await flushEventChanges(repo, { origin: url.origin, waitUntil: waitUntilFrom(locals) });
61
61
  return withCors(json({ deleted: e.id }));
62
62
  } catch (err) { return fail(err); }
63
63
  };
@@ -24,6 +24,11 @@ Restoring design history only stages a draft; publishing it emits this event.
24
24
  media.created/updated/deleted: a media record is added, its alt text changes, or it is removed.
25
25
  site.restored: a content backup is restored; resync instead of expecting individual record events.
26
26
  announcement.published: an announcement goes up (acms({ announcements }); scheduled ones when their time comes).
27
+ event.published/updated/unpublished/deleted: a calendar event goes public, changes, stops being
28
+ public or is deleted, by any path: the editor, Schedule, list actions, agent tools, the REST API,
29
+ scheduled publishing, a saved place's new address and environment sync. event.updated means
30
+ something public changed (times, place, rooms, cancelled dates, text, translations); hosts,
31
+ members' requests and staged edits do not emit it. A restore sends site.restored instead.
27
32
  Existing event.* and booking.* payloads are described at /api/calendar/docs.
28
33
 
29
34
  ## Payloads