@acms-subir/core 0.3.15 → 0.3.17

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.
@@ -0,0 +1,994 @@
1
+ import { z } from "zod";
2
+ import type { ContentRepository } from "../repo/types";
3
+ import type { SqlStatement } from "../repo/sql";
4
+ import type { MediaStore } from "../media/store";
5
+ import { ApiError } from "../api";
6
+ import { CONTENT } from "../content";
7
+ import { CALENDAR } from "../calendar/config";
8
+ import { eventInstants, normalizeEventTimes } from "../calendar/events";
9
+ import { htmlToPlaintext } from "../plaintext";
10
+ import { validateDesignValues } from "../design-service";
11
+ import {
12
+ canonical,
13
+ differences,
14
+ hash,
15
+ reference,
16
+ transform,
17
+ PROTOCOL,
18
+ } from "./protocol.mjs";
19
+ import {
20
+ column,
21
+ draftKinds,
22
+ encode,
23
+ fields,
24
+ parseData,
25
+ schemas,
26
+ settingsKinds,
27
+ tables,
28
+ type Data,
29
+ type Kind,
30
+ } from "./catalog";
31
+ import { objectBytes, type Entity, type Snapshot } from "./store";
32
+
33
+ const KindSchema = z.enum(Object.keys(schemas) as [Kind, ...Kind[]]);
34
+ const Choice = z
35
+ .object({
36
+ key: z.string().min(1).max(300),
37
+ kind: KindSchema,
38
+ aliases: z.array(z.string().max(300)).max(20),
39
+ data: z.record(z.unknown()),
40
+ targetId: z.string().max(100).nullable(),
41
+ expected: z.string().nullable(),
42
+ action: z.enum(["create", "update", "skip"]),
43
+ sourceHash: z.string().regex(/^[a-f0-9]{64}$/),
44
+ })
45
+ .strict();
46
+ export const PlanInput = z
47
+ .object({
48
+ sourceSite: z.string().min(1).max(100),
49
+ sourceUrl: z.string().max(2000),
50
+ destinationSite: z.string().max(100),
51
+ revision: z.number().int().nonnegative(),
52
+ mode: z.enum(["draft", "live"]).default("draft"),
53
+ choices: z.array(Choice).min(1).max(200),
54
+ })
55
+ .strict();
56
+ export type Operation = {
57
+ key: string;
58
+ kind: Kind;
59
+ targetId: string;
60
+ aliases: string[];
61
+ data: Data | null;
62
+ before: Entity | null;
63
+ action: "create" | "update" | "skip" | "delete";
64
+ sourceHash: string;
65
+ ref: string;
66
+ restore?: Entity;
67
+ };
68
+
69
+ function requireRevision(snapshot: Snapshot, site: string, revision: number) {
70
+ if (snapshot.siteId !== site || snapshot.revision !== revision)
71
+ throw new ApiError(
72
+ 409,
73
+ "Destination changed or is a different installation. Refresh status and plan again.",
74
+ );
75
+ }
76
+ function refMap(snapshot: Snapshot, operations: Operation[]) {
77
+ const map = new Map<string, string>();
78
+ for (const entity of snapshot.entities)
79
+ for (const alias of entity.aliases) map.set(reference(alias), entity.id);
80
+ for (const op of operations)
81
+ for (const alias of [op.key, ...op.aliases])
82
+ map.set(reference(alias), op.targetId);
83
+ return map;
84
+ }
85
+ function resolved(data: Data, map: Map<string, string>): Data {
86
+ const convert = (s: string): string => {
87
+ if (map.has(s)) return map.get(s)!;
88
+ if (/^\s*[\[{]/.test(s)) {
89
+ try {
90
+ return JSON.stringify(transform(JSON.parse(s), convert));
91
+ } catch (e) {
92
+ if (e instanceof ApiError) throw e;
93
+ }
94
+ }
95
+ if (s.startsWith("acms-sync:"))
96
+ throw new ApiError(
97
+ 409,
98
+ `Missing dependency ${s.slice(10)}. Include it in the selection.`,
99
+ );
100
+ return s;
101
+ };
102
+ return transform(data, convert);
103
+ }
104
+ async function validateCompatibility(
105
+ repo: ContentRepository,
106
+ operations: Operation[],
107
+ snapshot: Snapshot,
108
+ mode: string,
109
+ ) {
110
+ const map = refMap(snapshot, operations);
111
+ const homes = new Map<string, string>();
112
+ const mediaPaths = new Set([
113
+ ...snapshot.entities
114
+ .filter((e) => e.kind === "media")
115
+ .flatMap((e) => e.data.objects.map((o: Data) => `/media/${o.key}`)),
116
+ ...operations
117
+ .filter((o) => o.kind === "media" && o.data)
118
+ .flatMap((o) => o.data!.objects.map((a: Data) => `/media/${a.key}`)),
119
+ ]);
120
+ for (const e of snapshot.entities.filter(
121
+ (e) => e.kind === "page" && e.live.isHome,
122
+ ))
123
+ homes.set(e.live.locale, e.id);
124
+ for (const op of operations) {
125
+ if (op.action === "skip") continue;
126
+ if (op.action === "delete") {
127
+ if (["event", "resource", "booking", "form"].includes(op.kind)) {
128
+ const table = op.kind === "form" ? "form_submissions" : "bookings",
129
+ col =
130
+ op.kind === "form"
131
+ ? "form_id"
132
+ : op.kind === "event"
133
+ ? "event_id"
134
+ : op.kind === "resource"
135
+ ? "resource_id"
136
+ : "link_id";
137
+ if (
138
+ (
139
+ await repo.sync.query(
140
+ `SELECT id FROM ${table} WHERE ${col}=? LIMIT 1`,
141
+ [op.targetId],
142
+ )
143
+ ).length
144
+ )
145
+ throw new ApiError(
146
+ 409,
147
+ "Production activity prevents this rollback deletion.",
148
+ );
149
+ }
150
+ continue;
151
+ }
152
+ if (
153
+ mode === "draft" &&
154
+ !draftKinds.has(op.kind) &&
155
+ !["media", "tag"].includes(op.kind)
156
+ )
157
+ throw new ApiError(
158
+ 409,
159
+ `${op.ref} has no draft mode. Use an explicit live plan.`,
160
+ );
161
+ if (
162
+ mode === "draft" &&
163
+ ["media", "tag"].includes(op.kind) &&
164
+ op.action === "update"
165
+ )
166
+ throw new ApiError(
167
+ 409,
168
+ `${op.ref} changes live library metadata. Use an explicit live plan.`,
169
+ );
170
+ if (op.kind === "author")
171
+ throw new ApiError(
172
+ 409,
173
+ "Bylines must map to existing destination profiles; sync never creates or edits accounts.",
174
+ );
175
+ const data = parseData(op.kind, resolved(op.data!, map));
176
+ const mediaRefs =
177
+ canonical(data).match(/\/media\/sync\/[a-f0-9]{64}\.[a-z0-9]{1,8}/g) ??
178
+ [];
179
+ if (mediaRefs.some((path) => !mediaPaths.has(path)))
180
+ throw new ApiError(
181
+ 409,
182
+ `${op.ref} requires media that is not selected or present on the destination.`,
183
+ );
184
+ if (
185
+ op.kind === "form" &&
186
+ mode === "draft" &&
187
+ op.before?.live.status === "published" &&
188
+ ["name", "kind", "purpose"].some((k) => data[k] !== op.before!.live[k])
189
+ )
190
+ throw new ApiError(
191
+ 409,
192
+ "Only a published form definition can be staged. Rename or change form metadata with an explicit live plan.",
193
+ );
194
+ if (op.kind === "post" && !CONTENT.collections[data.collection])
195
+ throw new ApiError(
196
+ 409,
197
+ `Install collection ${data.collection} on the destination first.`,
198
+ );
199
+ const available = (kind: Kind, id: string) =>
200
+ snapshot.entities.some((e) => e.kind === kind && e.id === id) ||
201
+ operations.some(
202
+ (o) => o.kind === kind && o.targetId === id && o.action !== "delete",
203
+ );
204
+ const requireRef = (kind: Kind, id: string) => {
205
+ if (id && !available(kind, id))
206
+ throw new ApiError(409, `${op.ref} refers to a missing ${kind}.`);
207
+ };
208
+ if (op.kind === "post")
209
+ data.authorIds.forEach((id: string) => requireRef("author", id));
210
+ if (op.kind === "event") {
211
+ data.calendarIds.forEach((id: string) => requireRef("calendar", id));
212
+ if (data.location.placeId) requireRef("place", data.location.placeId);
213
+ }
214
+ if (op.kind === "resource") requireRef("place", data.placeId);
215
+ if (op.kind === "booking") requireRef("resource", data.resourceId);
216
+ if (op.kind === "booking-settings")
217
+ requireRef("calendar", data.defaultCalendarId);
218
+ if (["event", "calendar"].includes(op.kind) && !CALENDAR.enabled)
219
+ throw new ApiError(409, "Enable and deploy the calendar module first.");
220
+ if (
221
+ ["resource", "booking", "booking-settings"].includes(op.kind) &&
222
+ !CALENDAR.bookings
223
+ )
224
+ throw new ApiError(409, "Enable and deploy bookings first.");
225
+ if (
226
+ op.kind === "calendar" &&
227
+ data.kind === "feed" &&
228
+ op.action === "create"
229
+ )
230
+ throw new ApiError(
231
+ 409,
232
+ "Create feed calendars on the destination first; private feed URLs do not transfer.",
233
+ );
234
+ if (op.kind === "page" && data.isHome) {
235
+ const home = homes.get(data.locale);
236
+ if (
237
+ home &&
238
+ home !== op.targetId &&
239
+ !operations.some(
240
+ (o) =>
241
+ o.targetId === home &&
242
+ o.kind === "page" &&
243
+ o.data?.isHome === false,
244
+ )
245
+ )
246
+ throw new ApiError(
247
+ 409,
248
+ "Another page is the home for this locale. Select its change too.",
249
+ );
250
+ homes.set(data.locale, op.targetId);
251
+ }
252
+ if (
253
+ op.kind === "place" &&
254
+ data.isDefault &&
255
+ snapshot.entities.some(
256
+ (e) =>
257
+ e.kind === "place" &&
258
+ e.id !== op.targetId &&
259
+ e.live.isDefault &&
260
+ !operations.some(
261
+ (o) =>
262
+ o.kind === "place" &&
263
+ o.targetId === e.id &&
264
+ o.data?.isDefault === false,
265
+ ),
266
+ )
267
+ )
268
+ throw new ApiError(
269
+ 409,
270
+ "Select the former default place change before assigning another default.",
271
+ );
272
+ if (op.kind === "event") {
273
+ normalizeEventTimes(data as any);
274
+ // Guest notifications, seat accounting and resource conflicts must go
275
+ // through the event editor once an event has production activity.
276
+ const used = await repo.sync.query(
277
+ "SELECT id FROM bookings WHERE event_id=? LIMIT 1",
278
+ [op.targetId],
279
+ );
280
+ if (used.length)
281
+ throw new ApiError(
282
+ 409,
283
+ `${op.ref} has destination reservations. Edit that event on the destination.`,
284
+ );
285
+ if (data.roomHold?.resourceIds?.length)
286
+ throw new ApiError(
287
+ 409,
288
+ "Events with room holds must be configured on the destination after sync.",
289
+ );
290
+ }
291
+ if (
292
+ op.kind === "resource" &&
293
+ op.before &&
294
+ canonical(op.before.data) !== canonical(op.data)
295
+ ) {
296
+ const used = await repo.sync.query(
297
+ "SELECT id FROM bookings WHERE resource_id=? AND status IN ('pending','confirmed') LIMIT 1",
298
+ [op.targetId],
299
+ );
300
+ if (used.length)
301
+ throw new ApiError(
302
+ 409,
303
+ `${op.ref} has active reservations. Edit its configuration on the destination.`,
304
+ );
305
+ }
306
+ if (op.kind === "design" || (op.kind === "page" && data.layout)) {
307
+ const { getTheme, listThemes } = await import("../theme");
308
+ const { sectionIssues, ThemeTokens } = await import(
309
+ "../../theme-sdk/contract.mjs"
310
+ );
311
+ const settings = await repo.getSettings();
312
+ const themeName =
313
+ operations.find((o) => o.kind === "design")?.data?.activeTheme ??
314
+ (mode === "draft" ? settings.draftDesign?.activeTheme : null) ??
315
+ settings.activeTheme;
316
+ if (themeName && !listThemes().some((t) => t.slug === themeName))
317
+ throw new ApiError(
318
+ 409,
319
+ "Install and deploy the selected theme code first.",
320
+ );
321
+ if (op.kind === "design") {
322
+ validateDesignValues(data);
323
+ ThemeTokens.parse({
324
+ ...getTheme(String(themeName)).tokens,
325
+ ...data.designOverrides,
326
+ });
327
+ }
328
+ if (op.kind === "page" && data.layout?.mode === "sections") {
329
+ const issues = sectionIssues(
330
+ data.layout,
331
+ getTheme(String(themeName)).sections,
332
+ );
333
+ if (issues.length)
334
+ throw new ApiError(
335
+ 409,
336
+ `Section compatibility: ${issues.map((i: any) => i.message).join("; ")}`,
337
+ );
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ export async function createPlan(
344
+ repo: ContentRepository,
345
+ media: MediaStore,
346
+ raw: unknown,
347
+ actor: string,
348
+ persist = true,
349
+ ) {
350
+ const input = PlanInput.parse(raw),
351
+ snapshot = await repo.sync.snapshot(media);
352
+ requireRevision(snapshot, input.destinationSite, input.revision);
353
+ if (input.sourceSite === snapshot.siteId)
354
+ throw new ApiError(
355
+ 400,
356
+ "Source and destination are the same installation.",
357
+ );
358
+ const used = new Set<string>();
359
+ const operations: Operation[] = input.choices.map((c) => {
360
+ const before = c.targetId
361
+ ? (snapshot.entities.find(
362
+ (e) => e.kind === c.kind && e.id === c.targetId,
363
+ ) ?? null)
364
+ : null;
365
+ if ((before?.stateHash ?? null) !== c.expected)
366
+ throw new ApiError(
367
+ 409,
368
+ "Selected destination item changed. Refresh the plan.",
369
+ );
370
+ if ((c.action === "create" && before) || (c.action !== "create" && !before))
371
+ throw new ApiError(
372
+ 409,
373
+ "The planned item no longer matches the destination.",
374
+ );
375
+ const targetId =
376
+ c.targetId ?? (settingsKinds.has(c.kind) ? "site" : crypto.randomUUID());
377
+ const target = `${c.kind}:${targetId}`;
378
+ if (used.has(target))
379
+ throw new ApiError(
380
+ 400,
381
+ "Multiple source items target the same destination.",
382
+ );
383
+ used.add(target);
384
+ for (const alias of [c.key, ...c.aliases]) {
385
+ if (!alias.includes(`:${c.kind}:`))
386
+ throw new ApiError(400, "Invalid sync identity.");
387
+ const existing = snapshot.entities.find((e) => e.aliases.includes(alias));
388
+ if (existing && (existing.kind !== c.kind || existing.id !== targetId))
389
+ throw new ApiError(
390
+ 409,
391
+ "Identity is already linked to another destination item.",
392
+ );
393
+ }
394
+ if (
395
+ c.action === "skip" &&
396
+ before &&
397
+ canonical(c.data) !== canonical(before.data)
398
+ )
399
+ throw new ApiError(409, "A skipped item differs from the destination.");
400
+ return {
401
+ ...c,
402
+ targetId,
403
+ before,
404
+ data: c.data,
405
+ ref: before?.ref ?? `${c.kind}:${c.data.slug ?? c.data.name ?? "site"}`,
406
+ };
407
+ });
408
+ await validateCompatibility(repo, operations, snapshot, input.mode);
409
+ const body = {
410
+ protocol: PROTOCOL,
411
+ sourceSite: input.sourceSite,
412
+ sourceUrl: input.sourceUrl,
413
+ destinationSite: snapshot.siteId,
414
+ revision: snapshot.revision,
415
+ mode: input.mode,
416
+ purpose: "transfer",
417
+ operations,
418
+ changes: operations.map((o) => ({
419
+ ref: o.ref,
420
+ action: o.action,
421
+ effect:
422
+ input.mode === "draft" && draftKinds.has(o.kind) ? "draft" : "live",
423
+ fields: differences(o.before?.data, o.data),
424
+ })),
425
+ };
426
+ return persist ? repo.sync.savePlan(body, actor) : body;
427
+ }
428
+
429
+ function upsert(
430
+ kind: Kind,
431
+ id: string,
432
+ data: Data,
433
+ exists: boolean,
434
+ ): SqlStatement {
435
+ const row: Data = Object.fromEntries(
436
+ fields(kind)
437
+ .filter((k) => data[k] !== undefined)
438
+ .map((k) => [column(k), encode(data[k])]),
439
+ );
440
+ if (kind === "post") row.author_id = data.authorIds[0] ?? null;
441
+ const now = Date.now();
442
+ if (
443
+ [
444
+ "page",
445
+ "post",
446
+ "calendar",
447
+ "event",
448
+ "resource",
449
+ "booking",
450
+ "place",
451
+ "form",
452
+ ].includes(kind)
453
+ ) {
454
+ row.updated_at = now;
455
+ if (!exists) row.created_at = now;
456
+ }
457
+ if (["media", "redirect"].includes(kind) && !exists) row.created_at = now;
458
+ if (["calendar", "resource"].includes(kind) && !exists)
459
+ row.feed_token = crypto.randomUUID();
460
+ if (kind === "form") {
461
+ if (!exists) row.revision = 1;
462
+ row.draft = null;
463
+ }
464
+ if (["page", "post", "event"].includes(kind)) row.draft = null;
465
+ if (kind === "event")
466
+ Object.assign(
467
+ row,
468
+ Object.fromEntries(
469
+ Object.entries(eventInstants(data as any, data.timezone || "UTC")).map(
470
+ ([k, v]) => [column(k), v],
471
+ ),
472
+ ),
473
+ );
474
+ const entries = Object.entries(row),
475
+ table = tables[kind]!;
476
+ return exists
477
+ ? {
478
+ sql: `UPDATE ${table} SET ${entries.map(([k]) => `${k}=?`).join(",")}${kind === "form" ? ',revision=revision+1' : ''} WHERE id=?`,
479
+ args: [...entries.map(([, v]) => v), id],
480
+ }
481
+ : {
482
+ sql: `INSERT INTO ${table} (id,${entries.map(([k]) => k).join(",")}) VALUES (${Array(
483
+ entries.length + 1,
484
+ )
485
+ .fill("?")
486
+ .join(",")})`,
487
+ args: [id, ...entries.map(([, v]) => v)],
488
+ };
489
+ }
490
+ function indexStatements(
491
+ kind: Kind,
492
+ id: string,
493
+ data: Data | null,
494
+ ): SqlStatement[] {
495
+ if (!["page", "post", "event"].includes(kind)) return [];
496
+ const statements: SqlStatement[] = [
497
+ {
498
+ sql: "DELETE FROM search_index WHERE kind=? AND ref_id=?",
499
+ args: [kind, id],
500
+ },
501
+ ];
502
+ if (data?.status === "published")
503
+ statements.push({
504
+ sql: "INSERT INTO search_index(kind,ref_id,locale,title,excerpt,plaintext,tags) VALUES(?,?,?,?,?,?,?)",
505
+ args: [
506
+ kind,
507
+ id,
508
+ data.locale ?? "",
509
+ data.title,
510
+ data.excerpt ?? data.seoDesc ?? "",
511
+ htmlToPlaintext(data.bodyHtml ?? "") || data.body || "",
512
+ (data.tags ?? []).filter((t: string) => !t.startsWith("#")).join(" "),
513
+ ],
514
+ });
515
+ return statements;
516
+ }
517
+
518
+ export async function applyPlan(
519
+ repo: ContentRepository,
520
+ media: MediaStore,
521
+ id: string,
522
+ digest: string,
523
+ actor: string,
524
+ allowLive: boolean,
525
+ ) {
526
+ const stored = await repo.sync.plan(id);
527
+ if (!stored || stored.digest !== digest)
528
+ throw new ApiError(404, "Plan not found or digest does not match.");
529
+ const plan = stored.body;
530
+ if (plan.mode === "live" && !allowLive)
531
+ throw new ApiError(403, "Live plans require sync:publish.");
532
+ if (stored.result) return stored.result;
533
+ const snapshot = await repo.sync.snapshot(media);
534
+ requireRevision(snapshot, plan.destinationSite, plan.revision);
535
+ const operations = plan.operations as Operation[];
536
+ for (const operation of operations) {
537
+ const current = snapshot.entities.find(
538
+ (e) => e.kind === operation.kind && e.id === operation.targetId,
539
+ );
540
+ if ((current?.stateHash ?? null) !== (operation.before?.stateHash ?? null))
541
+ throw new ApiError(
542
+ 409,
543
+ `${operation.ref} changed after review. Generate a new plan.`,
544
+ );
545
+ }
546
+ await validateCompatibility(repo, operations, snapshot, plan.mode);
547
+ const map = refMap(snapshot, operations),
548
+ settings = await repo.sync.rawSettings();
549
+ let designDraft = settings.draft_design
550
+ ? JSON.parse(settings.draft_design)
551
+ : null;
552
+ let liveLayouts = JSON.parse(settings.section_pages || "{}");
553
+ let designTouched = false;
554
+ const statements: SqlStatement[] = [];
555
+ const applied: Data[] = [];
556
+ for (const op of operations) {
557
+ const data = op.data ? parseData(op.kind, resolved(op.data, map)) : null;
558
+ if (op.action === "delete") {
559
+ // Rollback only. Never delete media bytes; a later page may reuse them.
560
+ if (op.kind === "author" || settingsKinds.has(op.kind))
561
+ throw new ApiError(400, "Cannot delete that item.");
562
+ statements.push(
563
+ {
564
+ sql: `DELETE FROM ${tables[op.kind]} WHERE id=?`,
565
+ args: [op.targetId],
566
+ },
567
+ ...indexStatements(op.kind, op.targetId, null),
568
+ );
569
+ if (op.kind === "post")
570
+ statements.push({
571
+ sql: "DELETE FROM post_authors WHERE post_id=?",
572
+ args: [op.targetId],
573
+ });
574
+ if (op.kind === "page") {
575
+ delete liveLayouts[op.targetId];
576
+ if (designDraft?.sectionPages)
577
+ delete designDraft.sectionPages[op.targetId];
578
+ designTouched = true;
579
+ }
580
+ statements.push({
581
+ sql: "DELETE FROM sync_aliases WHERE kind=? AND local_id=?",
582
+ args: [op.kind, op.targetId],
583
+ });
584
+ applied.push({
585
+ key: op.key,
586
+ kind: op.kind,
587
+ id: op.targetId,
588
+ action: op.action,
589
+ });
590
+ continue;
591
+ }
592
+ if (op.action !== "skip" && data) {
593
+ if (op.kind === "media")
594
+ for (const object of data.objects) {
595
+ if (
596
+ (await hash(await objectBytes(media, object.key))) !== object.digest
597
+ )
598
+ throw new ApiError(
599
+ 409,
600
+ "Upload and verify every required media object before applying.",
601
+ );
602
+ }
603
+ if (op.restore) {
604
+ const restoreLive = parseData(op.kind, resolved(op.restore.live, map));
605
+ const restoreDraft = op.restore.draft
606
+ ? resolved(op.restore.draft, map)
607
+ : null;
608
+ if (op.kind === "design") {
609
+ statements.push({
610
+ sql: `UPDATE settings SET ${Object.keys(restoreLive)
611
+ .map((k) => `${column(k)}=?`)
612
+ .join(",")} WHERE id=1`,
613
+ args: Object.values(restoreLive).map(encode),
614
+ });
615
+ designDraft = { ...designDraft };
616
+ for (const key of fields("design")) {
617
+ delete designDraft[key];
618
+ if (restoreDraft && key in restoreDraft)
619
+ designDraft[key] = restoreDraft[key];
620
+ }
621
+ designTouched = true;
622
+ } else if (op.kind === "settings")
623
+ statements.push({
624
+ sql: `UPDATE settings SET ${Object.keys(restoreLive)
625
+ .map((k) => `${column(k)}=?`)
626
+ .join(",")} WHERE id=1`,
627
+ args: Object.values(restoreLive).map(encode),
628
+ });
629
+ else if (op.kind === "booking-settings")
630
+ statements.push({
631
+ sql: "UPDATE settings SET calendar=? WHERE id=1",
632
+ args: [JSON.stringify(restoreLive)],
633
+ });
634
+ else {
635
+ statements.push(
636
+ upsert(op.kind, op.targetId, restoreLive, true),
637
+ ...indexStatements(op.kind, op.targetId, restoreLive),
638
+ );
639
+ if (["page", "post", "event", "form"].includes(op.kind))
640
+ statements.push({
641
+ sql: `UPDATE ${tables[op.kind]} SET draft=? WHERE id=?`,
642
+ args: [
643
+ restoreDraft ? JSON.stringify(restoreDraft) : null,
644
+ op.targetId,
645
+ ],
646
+ });
647
+ if (op.kind === "post") {
648
+ statements.push({
649
+ sql: "DELETE FROM post_authors WHERE post_id=?",
650
+ args: [op.targetId],
651
+ });
652
+ restoreLive.authorIds.forEach((aid: string, i: number) =>
653
+ statements.push({
654
+ sql: "INSERT INTO post_authors(post_id,author_id,ord) VALUES(?,?,?)",
655
+ args: [op.targetId, aid, i],
656
+ }),
657
+ );
658
+ }
659
+ if (op.kind === "page") {
660
+ if (restoreLive.layout === null) delete liveLayouts[op.targetId];
661
+ else liveLayouts[op.targetId] = restoreLive.layout;
662
+ if (
663
+ canonical(op.restore.data.layout) !==
664
+ canonical(op.restore.live.layout) ||
665
+ designDraft?.sectionPages
666
+ ) {
667
+ designDraft = {
668
+ ...designDraft,
669
+ sectionPages: { ...(designDraft?.sectionPages ?? liveLayouts) },
670
+ };
671
+ const layout = resolved(
672
+ { layout: op.restore.data.layout },
673
+ map,
674
+ ).layout;
675
+ if (layout === null) delete designDraft.sectionPages[op.targetId];
676
+ else designDraft.sectionPages[op.targetId] = layout;
677
+ }
678
+ designTouched = true;
679
+ }
680
+ }
681
+ } else if (op.kind === "design") {
682
+ if (plan.mode === "draft")
683
+ designDraft = {
684
+ ...designDraft,
685
+ ...data,
686
+ savedAt: Date.now(),
687
+ shareEpoch: crypto.randomUUID(),
688
+ };
689
+ else {
690
+ statements.push({
691
+ sql: `UPDATE settings SET ${Object.keys(data)
692
+ .map((k) => `${column(k)}=?`)
693
+ .join(",")} WHERE id=1`,
694
+ args: Object.values(data).map(encode),
695
+ });
696
+ if (designDraft)
697
+ for (const key of Object.keys(data)) delete designDraft[key];
698
+ }
699
+ designTouched = true;
700
+ } else if (op.kind === "settings")
701
+ statements.push({
702
+ sql: `UPDATE settings SET ${Object.keys(data)
703
+ .map((k) => `${column(k)}=?`)
704
+ .join(",")} WHERE id=1`,
705
+ args: Object.values(data).map(encode),
706
+ });
707
+ else if (op.kind === "booking-settings")
708
+ statements.push({
709
+ sql: "UPDATE settings SET calendar=? WHERE id=1",
710
+ args: [JSON.stringify(data)],
711
+ });
712
+ else {
713
+ const stage =
714
+ plan.mode === "draft" &&
715
+ draftKinds.has(op.kind) &&
716
+ op.before &&
717
+ ["published", "scheduled"].includes(op.before.live.status);
718
+ if (stage) {
719
+ let draft: Data;
720
+ if (op.kind === "form") draft = data.definition;
721
+ else {
722
+ draft = { ...data, savedAt: Date.now() };
723
+ for (const k of ["status", "publishedAt", "scheduledAt", "layout"])
724
+ delete draft[k];
725
+ }
726
+ statements.push({
727
+ sql: `UPDATE ${tables[op.kind]} SET draft=?${op.kind === "form" ? ",revision=revision+1" : ""} WHERE id=?`,
728
+ args: [JSON.stringify(draft), op.targetId],
729
+ });
730
+ } else {
731
+ const writeData = { ...data };
732
+ if (plan.mode === "draft" && draftKinds.has(op.kind)) {
733
+ writeData.status = "draft";
734
+ if ("scheduledAt" in writeData) writeData.scheduledAt = null;
735
+ }
736
+ if (plan.mode === "draft" && op.kind === "page")
737
+ writeData.isHome = op.before?.live.isHome ?? false;
738
+ statements.push(
739
+ upsert(op.kind, op.targetId, writeData, !!op.before),
740
+ ...indexStatements(op.kind, op.targetId, writeData),
741
+ );
742
+ if (
743
+ plan.mode === "draft" &&
744
+ op.kind === "page" &&
745
+ data.isHome !== writeData.isHome
746
+ )
747
+ statements.push({
748
+ sql: "UPDATE pages SET draft=? WHERE id=?",
749
+ args: [
750
+ JSON.stringify({ isHome: data.isHome, savedAt: Date.now() }),
751
+ op.targetId,
752
+ ],
753
+ });
754
+ if (op.kind === "post") {
755
+ statements.push({
756
+ sql: "DELETE FROM post_authors WHERE post_id=?",
757
+ args: [op.targetId],
758
+ });
759
+ data.authorIds.forEach((aid: string, i: number) =>
760
+ statements.push({
761
+ sql: "INSERT INTO post_authors(post_id,author_id,ord) VALUES(?,?,?)",
762
+ args: [op.targetId, aid, i],
763
+ }),
764
+ );
765
+ }
766
+ }
767
+ if (op.kind === "page") {
768
+ if (plan.mode === "draft") {
769
+ designDraft = {
770
+ ...designDraft,
771
+ sectionPages: { ...(designDraft?.sectionPages ?? liveLayouts) },
772
+ savedAt: Date.now(),
773
+ shareEpoch: crypto.randomUUID(),
774
+ };
775
+ if (data.layout === null)
776
+ delete designDraft.sectionPages[op.targetId];
777
+ else designDraft.sectionPages[op.targetId] = data.layout;
778
+ } else {
779
+ if (data.layout === null) delete liveLayouts[op.targetId];
780
+ else liveLayouts[op.targetId] = data.layout;
781
+ if (designDraft?.sectionPages) {
782
+ if (data.layout === null)
783
+ delete designDraft.sectionPages[op.targetId];
784
+ else designDraft.sectionPages[op.targetId] = data.layout;
785
+ }
786
+ }
787
+ designTouched = true;
788
+ }
789
+ }
790
+ }
791
+ for (const alias of [...new Set([op.key, ...op.aliases])])
792
+ statements.push({
793
+ sql: "INSERT INTO sync_aliases(alias,kind,local_id) VALUES(?,?,?) ON CONFLICT(alias) DO UPDATE SET kind=excluded.kind,local_id=excluded.local_id",
794
+ args: [alias, op.kind, op.targetId],
795
+ });
796
+ statements.push({
797
+ sql: "UPDATE sync_aliases SET canonical=1 WHERE alias=? AND NOT EXISTS(SELECT 1 FROM sync_aliases WHERE kind=? AND local_id=? AND canonical=1)",
798
+ args: [op.key, op.kind, op.targetId],
799
+ });
800
+ const predicted = { ...(op.restore?.data ?? op.data) };
801
+ if (
802
+ plan.mode === "draft" &&
803
+ ["page", "post", "event", "form"].includes(op.kind)
804
+ ) {
805
+ predicted.status =
806
+ op.before && ["published", "scheduled"].includes(op.before.live.status)
807
+ ? op.before.live.status
808
+ : "draft";
809
+ if (predicted.status === "draft" && "scheduledAt" in predicted)
810
+ predicted.scheduledAt = null;
811
+ }
812
+ applied.push({
813
+ key: op.key,
814
+ kind: op.kind,
815
+ id: op.targetId,
816
+ action: op.action,
817
+ sourceHash: op.sourceHash,
818
+ destinationHash: await hash(predicted),
819
+ });
820
+ }
821
+ if (designTouched)
822
+ statements.push({
823
+ sql: "UPDATE settings SET section_pages=?,draft_design=?,design_revision=design_revision+1 WHERE id=1",
824
+ args: [
825
+ JSON.stringify(liveLayouts),
826
+ designDraft ? JSON.stringify(designDraft) : null,
827
+ ],
828
+ });
829
+ const result = {
830
+ id,
831
+ actor,
832
+ completedAt: Date.now(),
833
+ mode: plan.mode,
834
+ purpose: plan.purpose,
835
+ items: applied,
836
+ };
837
+ const versionKeys = [
838
+ ...new Set(
839
+ operations.flatMap((op) => [
840
+ `${tables[op.kind] ?? "settings"}:${tables[op.kind] ? op.targetId : "1"}`,
841
+ ...(op.kind === "page" ? ["settings:1"] : []),
842
+ ...(op.kind === "post" ? [`post_authors:${op.targetId}`] : []),
843
+ ]),
844
+ ),
845
+ ];
846
+ await repo.sync.commit(id, plan.revision, statements, result, versionKeys);
847
+ return (await repo.sync.plan(id))!.result;
848
+ }
849
+
850
+ export async function followupPlan(
851
+ repo: ContentRepository,
852
+ media: MediaStore,
853
+ runId: string,
854
+ purpose: "publish" | "rollback",
855
+ actor: string,
856
+ persist = true,
857
+ ) {
858
+ const run = await repo.sync.plan(runId);
859
+ if (!run?.result) throw new ApiError(404, "Completed sync run not found.");
860
+ if (
861
+ purpose === "publish" &&
862
+ (run.body.mode !== "draft" || run.body.purpose === "rollback")
863
+ )
864
+ throw new ApiError(400, "Only a staged transfer can be published.");
865
+ const snapshot = await repo.sync.snapshot(media),
866
+ original = run.body.operations as Operation[];
867
+ const versions = Object.fromEntries(
868
+ (await repo.sync.query("SELECT * FROM sync_versions")).map((r) => [
869
+ String(r.entity),
870
+ Number(r.revision),
871
+ ]),
872
+ );
873
+ // Require the exact after-state saved atomically with the original run.
874
+ // The global revision recorded below also protects the review/apply gap.
875
+ const operations: Operation[] = [];
876
+ for (const old of original.filter((o) => o.action !== "skip")) {
877
+ const current = snapshot.entities.find(
878
+ (e) => e.kind === old.kind && e.id === old.targetId,
879
+ );
880
+ if (!current)
881
+ throw new ApiError(
882
+ 409,
883
+ "A transferred item was removed. Resolve it before publishing or rollback.",
884
+ );
885
+ const guards = [
886
+ `${tables[old.kind] ?? "settings"}:${tables[old.kind] ? old.targetId : "1"}`,
887
+ ];
888
+ if (old.kind === "page") guards.push("settings:1");
889
+ if (old.kind === "post") guards.push(`post_authors:${old.targetId}`);
890
+ if (
891
+ guards.some((g) => (run.result.versions?.[g] ?? 0) !== (versions[g] ?? 0))
892
+ )
893
+ throw new ApiError(
894
+ 409,
895
+ `${current.ref} changed after the transfer. Preserve those edits before continuing.`,
896
+ );
897
+ if (purpose === "publish") {
898
+ if (!draftKinds.has(old.kind)) continue;
899
+ const data = { ...current.data };
900
+ if (["page", "post", "event", "form"].includes(old.kind))
901
+ data.status = "published";
902
+ if (["post", "event"].includes(old.kind)) {
903
+ data.scheduledAt = null;
904
+ data.publishedAt = data.publishedAt ?? Date.now();
905
+ }
906
+ operations.push({ ...old, action: "update", before: current, data });
907
+ } else if (old.before)
908
+ operations.push({
909
+ ...old,
910
+ action: "update",
911
+ before: current,
912
+ data: old.before.data,
913
+ restore: old.before,
914
+ });
915
+ else {
916
+ if (
917
+ snapshot.entities.some(
918
+ (e) =>
919
+ e.key !== current.key &&
920
+ e.dependencies.includes(current.key) &&
921
+ !original.some(
922
+ (o) =>
923
+ o.targetId === e.id &&
924
+ o.kind === e.kind &&
925
+ o.action === "create",
926
+ ),
927
+ )
928
+ )
929
+ throw new ApiError(409, `${current.ref} is now used by another item.`);
930
+ if (["event", "resource", "booking", "form"].includes(old.kind)) {
931
+ const table = old.kind === "form" ? "form_submissions" : "bookings";
932
+ const col =
933
+ old.kind === "form"
934
+ ? "form_id"
935
+ : old.kind === "event"
936
+ ? "event_id"
937
+ : old.kind === "resource"
938
+ ? "resource_id"
939
+ : "link_id";
940
+ if (
941
+ (
942
+ await repo.sync.query(
943
+ `SELECT id FROM ${table} WHERE ${col}=? LIMIT 1`,
944
+ [old.targetId],
945
+ )
946
+ ).length
947
+ )
948
+ throw new ApiError(
949
+ 409,
950
+ "New production activity prevents deleting this item.",
951
+ );
952
+ }
953
+ operations.push({
954
+ ...old,
955
+ action: "delete",
956
+ before: current,
957
+ data: null,
958
+ });
959
+ }
960
+ }
961
+ if (!operations.length) throw new ApiError(400, "No changes to plan.");
962
+ const body = {
963
+ ...run.body,
964
+ revision: snapshot.revision,
965
+ mode: "live",
966
+ purpose,
967
+ parent: runId,
968
+ operations,
969
+ changes: operations.map((o) => ({
970
+ ref: o.ref,
971
+ action: o.action,
972
+ effect: "live",
973
+ fields: o.restore
974
+ ? differences(
975
+ {
976
+ live: o.before?.live,
977
+ draft: o.before?.draft,
978
+ editable: o.before?.data,
979
+ },
980
+ {
981
+ live: o.restore.live,
982
+ draft: o.restore.draft,
983
+ editable: o.restore.data,
984
+ },
985
+ )
986
+ : differences(
987
+ purpose === "publish" ? o.before?.live : o.before?.data,
988
+ o.data,
989
+ ),
990
+ })),
991
+ };
992
+ await validateCompatibility(repo, operations, snapshot, "live");
993
+ return persist ? repo.sync.savePlan(body, actor) : body;
994
+ }