@caelo-cms/shared 0.11.5 → 0.11.7

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 (72) hide show
  1. package/dist/ai-tools.d.ts +206 -2
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +23 -9
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/change-journal.d.ts +71 -0
  6. package/dist/change-journal.d.ts.map +1 -0
  7. package/dist/change-journal.js +252 -0
  8. package/dist/change-journal.js.map +1 -0
  9. package/dist/content.d.ts +214 -0
  10. package/dist/content.d.ts.map +1 -1
  11. package/dist/content.js +29 -11
  12. package/dist/content.js.map +1 -1
  13. package/dist/index.d.ts +3 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/lists/fields.d.ts +5 -0
  18. package/dist/lists/fields.d.ts.map +1 -1
  19. package/dist/lists/fields.js +6 -1
  20. package/dist/lists/fields.js.map +1 -1
  21. package/dist/lists/list-type.d.ts.map +1 -1
  22. package/dist/lists/list-type.js +6 -3
  23. package/dist/lists/list-type.js.map +1 -1
  24. package/dist/lists/page-type.d.ts +1 -0
  25. package/dist/lists/page-type.d.ts.map +1 -1
  26. package/dist/lists/page-type.js +7 -0
  27. package/dist/lists/page-type.js.map +1 -1
  28. package/dist/live-events.d.ts +8 -4
  29. package/dist/live-events.d.ts.map +1 -1
  30. package/dist/live-events.js +18 -3
  31. package/dist/live-events.js.map +1 -1
  32. package/dist/media.d.ts +19 -0
  33. package/dist/media.d.ts.map +1 -1
  34. package/dist/media.js +16 -0
  35. package/dist/media.js.map +1 -1
  36. package/dist/phase-timer.d.ts +43 -2
  37. package/dist/phase-timer.d.ts.map +1 -1
  38. package/dist/phase-timer.js +170 -4
  39. package/dist/phase-timer.js.map +1 -1
  40. package/dist/pool-budget.d.ts +152 -0
  41. package/dist/pool-budget.d.ts.map +1 -0
  42. package/dist/pool-budget.js +151 -0
  43. package/dist/pool-budget.js.map +1 -0
  44. package/dist/stage-status.d.ts +89 -0
  45. package/dist/stage-status.d.ts.map +1 -0
  46. package/dist/stage-status.js +101 -0
  47. package/dist/stage-status.js.map +1 -0
  48. package/dist/themes-errors.js +1 -1
  49. package/dist/themes-errors.js.map +1 -1
  50. package/dist/version.d.ts +2 -2
  51. package/dist/version.js +1 -1
  52. package/package.json +1 -1
  53. package/src/ai-tools.ts +23 -8
  54. package/src/change-journal.ts +325 -0
  55. package/src/content.ts +32 -11
  56. package/src/index.ts +3 -0
  57. package/src/lists/fields.ts +7 -1
  58. package/src/lists/list-fixtures.test.ts +1 -0
  59. package/src/lists/list-type.ts +6 -2
  60. package/src/lists/lists.test.ts +1 -0
  61. package/src/lists/page-type.ts +7 -0
  62. package/src/live-events.ts +18 -3
  63. package/src/media.test.ts +31 -0
  64. package/src/media.ts +17 -0
  65. package/src/phase-timer.test.ts +37 -0
  66. package/src/phase-timer.ts +182 -4
  67. package/src/pool-budget.test.ts +111 -0
  68. package/src/pool-budget.ts +234 -0
  69. package/src/stage-status.test.ts +75 -0
  70. package/src/stage-status.ts +112 -0
  71. package/src/themes-errors.ts +1 -1
  72. package/src/version.ts +1 -1
@@ -0,0 +1,325 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Issue #1267 — the change journal's registry: which `cms_admin` tables
5
+ * write journal rows, under which kind, and why every other table does not.
6
+ * The contract is `docs/dev/state-and-changes.md`.
7
+ *
8
+ * The journal (`entity_changes`, migration 0430) is written by generic row
9
+ * triggers that migrations attach with `caelo_journal_attach(...)`. This
10
+ * file is the readable copy of that set, and the CI test
11
+ * `change-journal-registry.integration.test.ts` holds both in step: every
12
+ * `cms_admin` table must be either journaled here with the exact trigger
13
+ * arguments the database carries, or exempt here with a written reason. A
14
+ * new table that is neither fails CI, so no table can silently fall
15
+ * outside the journal.
16
+ *
17
+ * Plugin storage tables (`plugin_<slug>.*`) are not listed: the host
18
+ * attaches the trigger when it provisions a plugin's schema, under the kind
19
+ * `plugin_storage` keyed on the plugin's id (never on its reusable slug), so
20
+ * a new plugin or domain needs no journal migration.
21
+ */
22
+
23
+ /** Every kind a journal row can carry. A consumer names the kinds it reads. */
24
+ export const JOURNAL_KINDS = [
25
+ "page",
26
+ "page_terms",
27
+ "page_seo",
28
+ "page_layout",
29
+ "page_module_content",
30
+ "module",
31
+ "template",
32
+ "template_blocks",
33
+ "layout",
34
+ "layout_blocks",
35
+ "layout_module",
36
+ "content_instance",
37
+ "list",
38
+ "list_type",
39
+ "page_type",
40
+ "theme",
41
+ "media",
42
+ "media_variant",
43
+ "media_crop",
44
+ "font",
45
+ "redirect",
46
+ "domain",
47
+ "site_defaults",
48
+ "site_settings",
49
+ "site_memory",
50
+ "skill",
51
+ "plugin",
52
+ "plugin_installation",
53
+ "plugin_grant",
54
+ "plugin_file",
55
+ "plugin_row",
56
+ "plugin_storage",
57
+ "snapshot",
58
+ ] as const;
59
+
60
+ /** One kind of the journal (`entity_changes.kind`). */
61
+ export type JournalKind = (typeof JOURNAL_KINDS)[number];
62
+
63
+ const KIND_SET: ReadonlySet<string> = new Set(JOURNAL_KINDS);
64
+
65
+ /** Is `value` a journal kind? */
66
+ export function isJournalKind(value: string): value is JournalKind {
67
+ return KIND_SET.has(value);
68
+ }
69
+
70
+ /**
71
+ * How a journaled table's rows map to journal rows (the trigger's
72
+ * arguments, `caelo_journal_attach` in migration 0430):
73
+ *
74
+ * - `row` — a live entity table. The journal row carries the entity id
75
+ * (`key`), the row's own branch when the table has one (`branch`), and
76
+ * the chat from the writing transaction's attribution settings.
77
+ * - `snapshot` — an entity snapshot table. Branch, chat and snapshot id
78
+ * come from the snapshot's `site_snapshots` header, and an inserted
79
+ * snapshot records the snapshot it replaces on the same line
80
+ * (`prev_snapshot_id`), so a consumer can diff the two states.
81
+ * - `header` — `site_snapshots` itself: Stage (`staged_at`), undo
82
+ * (`undone_at`) and lock takeover (`chat_branch_id`) are UPDATEs of it.
83
+ */
84
+ export type JournalMode = "row" | "snapshot" | "header";
85
+
86
+ /** One journaled table, exactly as its trigger is attached. */
87
+ export interface JournaledTable {
88
+ readonly kind: JournalKind;
89
+ readonly mode: JournalMode;
90
+ /** The column that holds the entity id (`entity_changes.row_id`). */
91
+ readonly key: string;
92
+ /** The primary-key columns an UPDATE's old and new rows are paired on. */
93
+ readonly pair: readonly string[];
94
+ /** A column naming the entity this row belongs to (`entity_changes.parent_id`). */
95
+ readonly parent?: string;
96
+ /** The row's own branch column (`row` mode only). */
97
+ readonly branch?: string;
98
+ /** The column holding the owning plugin's id. */
99
+ readonly plugin?: string;
100
+ }
101
+
102
+ const row = (
103
+ kind: JournalKind,
104
+ key: string,
105
+ pair: readonly string[],
106
+ extra: Partial<Pick<JournaledTable, "parent" | "branch" | "plugin">> = {},
107
+ ): JournaledTable => ({ kind, mode: "row", key, pair, ...extra });
108
+
109
+ const snapshot = (
110
+ kind: JournalKind,
111
+ key: string,
112
+ extra: Partial<Pick<JournaledTable, "parent" | "plugin">> = {},
113
+ ): JournaledTable => ({ kind, mode: "snapshot", key, pair: ["id"], ...extra });
114
+
115
+ /**
116
+ * The journaled `cms_admin` tables. Everything an editor, a render, a
117
+ * build or a plugin derives from: content and its snapshots, media,
118
+ * settings that render into pages, redirects, plugins and their grants.
119
+ */
120
+ export const JOURNALED_TABLES: Readonly<Record<string, JournaledTable>> = {
121
+ pages: row("page", "id", ["id"], { branch: "chat_branch_id" }),
122
+ page_terms: row("page_terms", "page_id", ["page_id", "list_id", "item_id"]),
123
+ pages_seo: row("page_seo", "page_id", ["page_id"]),
124
+ page_modules: row("page_layout", "page_id", ["page_id", "block_name", "position"]),
125
+ page_module_content: row("page_module_content", "id", ["id"], { parent: "page_id" }),
126
+ modules: row("module", "id", ["id"], { branch: "chat_branch_id" }),
127
+ templates: row("template", "id", ["id"], { branch: "chat_branch_id" }),
128
+ template_blocks: row("template_blocks", "template_id", ["template_id", "name"]),
129
+ layouts: row("layout", "id", ["id"], { branch: "chat_branch_id" }),
130
+ layout_blocks: row("layout_blocks", "layout_id", ["layout_id", "name"]),
131
+ layout_modules: row("layout_module", "layout_id", ["layout_id", "block_name", "position"]),
132
+ content_instances: row("content_instance", "id", ["id"], {
133
+ branch: "chat_branch_id",
134
+ parent: "module_id",
135
+ }),
136
+ lists: row("list", "id", ["id"], { branch: "chat_branch_id" }),
137
+ list_types: row("list_type", "id", ["id"], { branch: "chat_branch_id" }),
138
+ page_types: row("page_type", "id", ["id"], { branch: "chat_branch_id" }),
139
+ themes: row("theme", "id", ["id"]),
140
+ media_assets: row("media", "id", ["id"]),
141
+ media_variants: row("media_variant", "asset_id", ["asset_id", "variant"]),
142
+ media_crops: row("media_crop", "id", ["id"], { parent: "asset_id" }),
143
+ font_assets: row("font", "id", ["id"]),
144
+ redirects: row("redirect", "id", ["id"]),
145
+ domains: row("domain", "id", ["id"]),
146
+ site_defaults: row("site_defaults", "id", ["id"]),
147
+ site_settings: row("site_settings", "id", ["id"]),
148
+ site_ai_memory: row("site_memory", "id", ["id"]),
149
+ skills: row("skill", "id", ["id"]),
150
+ plugins: row("plugin", "id", ["id"]),
151
+ plugin_installation_versions: row("plugin_installation", "id", ["id"], { parent: "plugin_id" }),
152
+ plugin_capability_grants: row("plugin_grant", "id", ["id"], { parent: "plugin_id" }),
153
+ plugin_private_files: row("plugin_file", "id", ["plugin_id", "id"], { plugin: "plugin_id" }),
154
+
155
+ site_snapshots: { kind: "snapshot", mode: "header", key: "id", pair: ["id"] },
156
+ page_snapshots: snapshot("page", "page_id"),
157
+ page_layout_snapshots: snapshot("page_layout", "page_id"),
158
+ page_module_content_snapshots: snapshot("page_module_content", "page_module_content_id", {
159
+ parent: "page_id",
160
+ }),
161
+ module_snapshots: snapshot("module", "module_id"),
162
+ template_snapshots: snapshot("template", "template_id"),
163
+ layout_module_snapshots: snapshot("layout_module", "layout_id"),
164
+ content_instance_snapshots: snapshot("content_instance", "content_instance_id"),
165
+ list_snapshots: snapshot("list", "list_id"),
166
+ list_type_snapshots: snapshot("list_type", "list_type_id"),
167
+ page_type_snapshots: snapshot("page_type", "page_type_id"),
168
+ theme_snapshots: snapshot("theme", "theme_id"),
169
+ plugin_row_snapshots: snapshot("plugin_row", "row_id", {
170
+ parent: "table_name",
171
+ plugin: "plugin_id",
172
+ }),
173
+ };
174
+
175
+ const QUEUE = "a proposal queue; its changes reach editors as the op-classified `pending` topic";
176
+ const LOG = "an append-only log or history; nothing is derived from it";
177
+ const LEASE = "a lease or lock that coordinates processes; nothing is derived from it";
178
+ const SECURITY = "identity and permission rows, read per request by the auth layer";
179
+ const RUNTIME = "runtime bookkeeping of a process (health, progress, cache)";
180
+ const RETIRED = "retired by #1267; nothing reads it any more, migration 0442 drops it";
181
+ const RETIRED_SIGNAL =
182
+ "retired by #1267; nothing reads it, its writer triggers stay one release for the previous image (roll, rollback), migration 0442 drops both";
183
+
184
+ /**
185
+ * Every `cms_admin` table that writes no journal rows, with the reason.
186
+ * Adding a table here is a decision a reviewer reads, not a default.
187
+ */
188
+ export const JOURNAL_EXEMPT_TABLES: Readonly<Record<string, string>> = {
189
+ __drizzle_migrations: "the migration runner's own bookkeeping",
190
+ entity_changes: "the journal itself",
191
+ entity_change_horizons: "the journal's garbage-collection horizons",
192
+ change_consumers: "the journal's consumer cursors",
193
+ domain_events: RETIRED,
194
+ font_usage_changes: RETIRED_SIGNAL,
195
+ site_view_generation: RETIRED_SIGNAL,
196
+ plugin_event_cursors: RETIRED,
197
+
198
+ actors: SECURITY,
199
+ users: SECURITY,
200
+ roles: SECURITY,
201
+ permissions: SECURITY,
202
+ role_permissions: SECURITY,
203
+ user_roles: SECURITY,
204
+ sessions: SECURITY,
205
+ password_reset_tokens: SECURITY,
206
+ owner_bootstrap_tokens: SECURITY,
207
+ mcp_tokens: SECURITY,
208
+ mcp_remote_connections: SECURITY,
209
+ mcp_remote_access_blocks: SECURITY,
210
+ mcp_transport_sessions: SECURITY,
211
+ operator_oauth_client: SECURITY,
212
+ credential_handoffs:
213
+ "one-time credentials an approval left; delivered as the `credentials` topic",
214
+ user_preferences: "one person's editor preferences, read only by that person's own requests",
215
+ skill_pin_defaults: "one person's skill pins, read only by that person's own chats",
216
+
217
+ audit_events: LOG,
218
+ ai_calls: LOG,
219
+ ai_bug_reports: LOG,
220
+ ai_moduleize_attempts: LOG,
221
+ page_edit_log: LOG,
222
+ import_run_events: LOG,
223
+ platform_update_events: LOG,
224
+ plugin_errors: LOG,
225
+ redirect_syncs: LOG,
226
+ operator_access_sync_runs: LOG,
227
+ link_migration_runs: LOG,
228
+ comment_archive:
229
+ "visitor comments copied from cms_public: untrusted visitor data, nothing is derived from it",
230
+
231
+ chat_sessions:
232
+ "conversation metadata; what a chat changed is journaled through its snapshots, its run state reaches editors as the `agents` topic",
233
+ chat_messages: "conversation history (the provider's message log)",
234
+ chat_tool_results: "conversation history (tool results)",
235
+ chat_agent_runs: "running agents; reach editors as the op-classified `agents` topic",
236
+ chat_turn_approvals: "paused approval cards; reach editors as the `pending` topic",
237
+ chat_turn_leases: LEASE,
238
+ chat_entity_locks:
239
+ "cross-branch entity locks; a takeover re-points the snapshots, which are journaled",
240
+ chat_lock_takeovers: LOG,
241
+ chat_branch_publish_marks:
242
+ "which branch states reached main; the Stage that writes it also updates the journaled site_snapshots",
243
+ draft_entity_observations: "which draft entity states a chat has read (conflict detection)",
244
+ subagent_runs: "subagent bookkeeping of a chat turn",
245
+ mcp_approval_handoffs: "approval hand-offs to an MCP client",
246
+ tool_approval_actions: "approval decisions of chat tool calls",
247
+ plugin_tool_approval_bindings: "the binding of an approved plugin tool call to its arguments",
248
+ site_draft: "the shared draft branch's registry row; branch changes are journaled as snapshots",
249
+ genesis_drafts: "design drafts before a site exists; no page renders from them",
250
+ image_requests: "image generation requests; the resulting media asset is journaled",
251
+ media_alt_proposals:
252
+ "alt-text suggestions awaiting review; the accepted alt is journaled on media_assets",
253
+ entity_leases: LEASE,
254
+ deploy_leases: LEASE,
255
+ plugin_activation_leases: LEASE,
256
+ rate_limit_buckets: "visitor and login rate-limit counters",
257
+ rate_limit_profiles: "gateway rate-limit profiles, published to the gateway's projection",
258
+ plugin_rate_limit_overrides:
259
+ "gateway rate-limit overrides, published to the gateway's projection",
260
+ plugin_rate_limit_proposals: QUEUE,
261
+
262
+ ai_providers: "Owner AI configuration; read per call, nothing is derived from it",
263
+ ai_budgets: "Owner AI budgets; read per call, nothing is derived from it",
264
+ ai_pricing: "Owner AI pricing; read per call, nothing is derived from it",
265
+ email_config: "Owner email configuration; read per send",
266
+ telemetry_settings: "the install's telemetry consent",
267
+ staging_access:
268
+ "staging access settings, synced to the hosting provider on save (docs/dev/redirects-and-settings-sync.md)",
269
+ provisioning_outputs: "what the provisioner recorded about the install",
270
+ release_check_cache: RUNTIME,
271
+ deploy_targets: "deploy target configuration; read by the deploy ops",
272
+ deploy_runs: "deploy runs; reach editors as the op-classified `deploys` topic",
273
+ redirect_sync_state: RUNTIME,
274
+ redirect_changes:
275
+ "which transactions changed redirects, folded by the redirect sync; the journaled redirects table is the source",
276
+ static_bakes: "derived build output of plugin-baked regions",
277
+ content_instance_refs:
278
+ "derived (synchronous, #1287): the ids and media slugs each content instance's values reference, maintained by triggers on the journaled content_instances; rebuilt by content_instances.rebuild_refs",
279
+ plugin_data_build_state: RUNTIME,
280
+ plugin_data_stale_pages: "derived: which pages' baked plugin regions are stale",
281
+ plugin_runtime: RUNTIME,
282
+ plugin_schema_migrations: "plugin DDL bookkeeping",
283
+ plugin_schema_provisions: "plugin DDL bookkeeping",
284
+ plugin_private_file_chunks:
285
+ "the bytes of plugin_private_files rows, journaled through the file row",
286
+ public_projection_outbox: "the outbox of the cms_public projection, drained by its publisher",
287
+ platform_update_agent: RUNTIME,
288
+ platform_update_notifications: RUNTIME,
289
+ platform_update_policy: "the Owner's update policy, read by the update scheduler",
290
+ platform_update_requests: "update requests, run by the upgrader job",
291
+ platform_update_scheduler: RUNTIME,
292
+
293
+ import_runs: "site-import runs; reach editors as the op-classified `crawl` topic",
294
+ import_pages: "crawled pages of a site import, before they become pages",
295
+ import_run_links: "links found by a site import",
296
+ quality_audit_runs: "quality audits; reach editors as the op-classified `quality` topic",
297
+ quality_audit_pages: "quality audit results per page",
298
+ quality_baselines: "quality baselines per page, written by the audit worker",
299
+ quality_acceptances: "accepted quality findings; reach editors as the `quality` topic",
300
+ site_memory_proposals: QUEUE,
301
+ skill_proposals: QUEUE,
302
+
303
+ ai_providers_pending_actions: QUEUE,
304
+ deploy_pending_actions: QUEUE,
305
+ domain_pending_actions: QUEUE,
306
+ email_config_pending_actions: QUEUE,
307
+ layout_pending_actions: QUEUE,
308
+ mcp_token_pending_actions: QUEUE,
309
+ owner_settings_pending_actions: QUEUE,
310
+ plugin_pending_actions: QUEUE,
311
+ quality_pending_actions: QUEUE,
312
+ role_pending_actions: QUEUE,
313
+ site_defaults_pending_actions: QUEUE,
314
+ snapshot_revert_pending_actions: QUEUE,
315
+ template_pending_actions: QUEUE,
316
+ theme_pending_actions: QUEUE,
317
+ url_migration_pending_actions: QUEUE,
318
+ user_pending_actions: QUEUE,
319
+ };
320
+
321
+ /** NOTIFY channel the journal trigger wakes consumers on (payload empty). */
322
+ export const JOURNAL_CHANNEL = "caelo_entity_changes";
323
+
324
+ /** NOTIFY channel carrying operation-classified live topics across instances. */
325
+ export const LIVE_TOPICS_CHANNEL = "caelo_live_topics";
package/src/content.ts CHANGED
@@ -15,6 +15,7 @@ import { hasEscapedLineBreaks } from "./escaped-line-breaks.js";
15
15
  import { DEFAULT_LIST_TYPES, SITE_TEXTS_LIST_TYPE } from "./lists/default-types.js";
16
16
  import { listItemInputSchema } from "./lists/items.js";
17
17
  import { LIST_MAX_ITEMS, sourceDefSchema } from "./lists/list-type.js";
18
+ import { pageTypeDefSchema } from "./lists/page-type.js";
18
19
  import { listPaginationSchema } from "./lists/pagination.js";
19
20
  import { LIST_REF_RE } from "./lists/refs.js";
20
21
 
@@ -185,7 +186,7 @@ const noCdata = (value: string): boolean => !value.includes(CDATA_OPEN);
185
186
  const cdataMessage = (field: "css" | "js"): string =>
186
187
  `${field} contains an XML CDATA marker (${CDATA_OPEN}) — module ${field} is stored and served ` +
187
188
  `verbatim, so CDATA wrappers reach the browser unparsed and break rendering. ` +
188
- `Remove the wrapper and resend plain ${field === "css" ? "CSS" : "JavaScript"}.`;
189
+ `Remove the wrapper: pass plain ${field === "css" ? "CSS" : "JavaScript"}.`;
189
190
 
190
191
  const moduleCss = z
191
192
  .string()
@@ -624,17 +625,37 @@ export const pageTypeSlugSchema = z
624
625
  "page type slug: lowercase letters, digits, hyphens",
625
626
  );
626
627
 
628
+ /**
629
+ * #1276 — slugs a new page type is deliberately distinct from: the
630
+ * duplicate guard refuses a new type whose slug normalises to an existing
631
+ * one (`blogs` next to `blog`) unless the caller names it here.
632
+ */
633
+ export const pageTypeDistinctFromSchema = z.array(pageTypeSlugSchema).max(16);
634
+
635
+ /**
636
+ * #1276 — a page type created together with its first template
637
+ * (`templates.create` `newPageType`): the definition without `templates`
638
+ * and `defaultTemplate`, which are the new template.
639
+ */
640
+ export const newPageTypeSchema = pageTypeDefSchema
641
+ .omit({ templates: true, defaultTemplate: true })
642
+ .extend({ distinctFrom: pageTypeDistinctFromSchema.optional() })
643
+ .strict();
644
+ export type NewPageTypeInput = z.infer<typeof newPageTypeSchema>;
645
+
627
646
  export const templateCreateSchema = z
628
647
  .object({
629
648
  slug: slugSchema,
630
649
  displayName: displayNameSchema,
631
650
  /**
632
- * Epic #1107 — the page type (slug) this template serves; the template
633
- * is appended to that type's `templates`. Omit for the site's default
634
- * page type (`site_defaults.default_page_type_id`, stored data — not a
635
- * render-time fallback).
651
+ * Epic #1107 / #1276 — the page type this template serves. Exactly one
652
+ * of `pageType` (an existing type's slug: the template is appended to
653
+ * its `templates`) and `newPageType` (a new type created in the same
654
+ * transaction with this template as its `defaultTemplate`) is
655
+ * required; `templates.create` refuses neither or both.
636
656
  */
637
657
  pageType: pageTypeSlugSchema.optional(),
658
+ newPageType: newPageTypeSchema.optional(),
638
659
  html: z
639
660
  .string()
640
661
  .max(TEMPLATE_HTML_MAX, `html exceeds ${TEMPLATE_HTML_MAX} bytes`)
@@ -736,9 +757,9 @@ export const pageCreateSchema = z
736
757
  */
737
758
  shortTitle: pageShortTitleSchema.optional(),
738
759
  /**
739
- * P6.7.6 — optional. When omitted, the handler resolves to
740
- * `site_defaults.default_template_id` at create time. Stored data,
741
- * not a render-time fallback (CLAUDE.md §2 no-fallbacks).
760
+ * Optional: one of the page type's templates; omitted = the type's
761
+ * `defaultTemplate`. Without `pageType` it must belong to exactly one
762
+ * page type, which the page then gets (#1276).
742
763
  */
743
764
  templateId: z.string().uuid().optional(),
744
765
  /**
@@ -756,9 +777,9 @@ export const pageCreateSchema = z
756
777
  */
757
778
  parentPageId: z.string().uuid().nullable().optional(),
758
779
  /**
759
- * Epic #1107 — what the page IS (page type slug). Omit to take the type
760
- * whose `templates` contain the template; with neither `pageType` nor
761
- * `templateId`, the site's default page type and its default template.
780
+ * Epic #1107 — what the page IS (page type slug). Required unless
781
+ * `templateId` belongs to exactly one page type; there is no stored
782
+ * default type (#1276), so a create with neither is refused.
762
783
  */
763
784
  pageType: pageTypeSlugSchema.optional(),
764
785
  /** Property values, validated against the page type. */
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from "./base-css.js";
9
9
  export * from "./bug-intake.js";
10
10
  export * from "./build-page.js";
11
11
  export * from "./cap-failures.js";
12
+ export * from "./change-journal.js";
12
13
  export * from "./cloud-shell-install.js";
13
14
  export * from "./content.js";
14
15
  export * from "./context.js";
@@ -55,6 +56,7 @@ export * from "./page-log.js";
55
56
  export * from "./phase-timer.js";
56
57
  export * from "./platform-updates.js";
57
58
  export * from "./plugin-preview.js";
59
+ export * from "./pool-budget.js";
58
60
  export * from "./preview-compose.js";
59
61
  export * from "./preview-scanner.js";
60
62
  export * from "./proposal-status.js";
@@ -77,6 +79,7 @@ export * from "./seo.js";
77
79
  export * from "./skills.js";
78
80
  export * from "./snapshots.js";
79
81
  export * from "./source-text-transport.js";
82
+ export * from "./stage-status.js";
80
83
  export * from "./staging-gate.js";
81
84
  export * from "./static-cache-policy.js";
82
85
  export * from "./static-security-headers.js";
@@ -56,6 +56,12 @@ export const LIST_RESERVED_NAMES: ReadonlySet<string> = new Set([
56
56
  /** Name prefixes reserved as a family (see {@link LIST_RESERVED_NAMES}). */
57
57
  export const LIST_RESERVED_PREFIXES = ["is_", "has_", "count_"] as const;
58
58
 
59
+ /**
60
+ * The reserved names as one clause for an error (#1277: the tool
61
+ * description no longer lists them, so the refusal does).
62
+ */
63
+ export const LIST_RESERVED_NAMES_CLAUSE = `reserved: ${[...LIST_RESERVED_NAMES].join(", ")}, ${LIST_RESERVED_PREFIXES.map((p) => `${p}*`).join(", ")}`;
64
+
59
65
  /** True when `name` is taken by the engine (see {@link LIST_RESERVED_NAMES}). */
60
66
  export function isReservedListName(name: string): boolean {
61
67
  return LIST_RESERVED_NAMES.has(name) || LIST_RESERVED_PREFIXES.some((p) => name.startsWith(p));
@@ -256,7 +262,7 @@ export function checkFieldDef(field: FieldDef, path: string): ListIssue[] {
256
262
  if (isReservedListName(field.name)) {
257
263
  issues.push({
258
264
  path,
259
- message: `field name "${field.name}" is reserved by the template engine — pick another name.`,
265
+ message: `field name "${field.name}" is reserved by the template engine (${LIST_RESERVED_NAMES_CLAUSE}) — pick another name.`,
260
266
  });
261
267
  }
262
268
  if (field.default !== undefined) {
@@ -262,6 +262,7 @@ export function fixtureData(
262
262
  slug: "news",
263
263
  name: "News",
264
264
  purpose: "News posts.",
265
+ collection: true,
265
266
  templates: [id(801)],
266
267
  defaultTemplate: id(801),
267
268
  properties: [
@@ -17,6 +17,7 @@ import {
17
17
  fieldDefSchema,
18
18
  fieldExtSchema,
19
19
  isReservedListName,
20
+ LIST_RESERVED_NAMES_CLAUSE,
20
21
  type ListIssue,
21
22
  listNameSchema,
22
23
  } from "./fields.js";
@@ -180,7 +181,10 @@ export function checkListTypeDef(def: ListTypeDef): ListIssue[] {
180
181
  for (const [kind, node] of Object.entries(def.nodes)) {
181
182
  const at = `nodes.${kind}`;
182
183
  if (isReservedListName(kind)) {
183
- issues.push({ path: at, message: `node kind "${kind}" is a reserved name — rename it.` });
184
+ issues.push({
185
+ path: at,
186
+ message: `node kind "${kind}" is a reserved name (${LIST_RESERVED_NAMES_CLAUSE}) — rename it.`,
187
+ });
184
188
  }
185
189
  const seen = new Set<string>();
186
190
  node.fields.forEach((f, i) => {
@@ -193,7 +197,7 @@ export function checkListTypeDef(def: ListTypeDef): ListIssue[] {
193
197
  if (isReservedListName(node.children.name)) {
194
198
  issues.push({
195
199
  path: `${at}.children.name`,
196
- message: `children name "${node.children.name}" is reserved — pick another loop name.`,
200
+ message: `children name "${node.children.name}" is reserved (${LIST_RESERVED_NAMES_CLAUSE}) — pick another loop name.`,
197
201
  });
198
202
  }
199
203
  if (seen.has(node.children.name)) {
@@ -295,6 +295,7 @@ describe("page types and addresses", () => {
295
295
  slug: "news",
296
296
  name: "News",
297
297
  purpose: "Dated announcements",
298
+ collection: true,
298
299
  templates: [PAGE],
299
300
  defaultTemplate: LIST,
300
301
  properties: [{ name: "teaser", label: "T", type: "module" }],
@@ -17,6 +17,13 @@ export const pageTypeDefSchema = z
17
17
  slug: z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/),
18
18
  name: z.string().min(1).max(200),
19
19
  purpose: z.string().min(1).max(1000),
20
+ /**
21
+ * A collection of many similar pages meant to be listed or queried
22
+ * (blog, news, products, jobs, docs) — true; standalone pages (home,
23
+ * about / team / contact, legal, landing, utility) — false. The
24
+ * manual-page-listing check looks only at collection types (0450).
25
+ */
26
+ collection: z.boolean(),
20
27
  /** Template ids a page of this type may bind to. */
21
28
  templates: z.array(z.string().uuid()).min(1).max(64),
22
29
  defaultTemplate: z.string().uuid(),
@@ -11,9 +11,13 @@
11
11
  * it is safe to hand to every signed-in tab. The read that follows goes
12
12
  * through the endpoint's own permission checks.
13
13
  *
14
- * Topics come from the names of operations that committed (the Query API's
15
- * `observeCommittedOperations`). Only WRITE operations are listed: a read
16
- * classified as a change would make every refresh announce another change.
14
+ * Operational topics come from the names of operations that committed (the
15
+ * Query API's `observeCommittedOperations`). Only WRITE operations are
16
+ * listed: a read classified as a change would make every refresh announce
17
+ * another change. Entity-derived topics (`draft`) come from the change
18
+ * journal instead (issue #1267): they fire exactly when a committed write
19
+ * changed a journaled row, on whichever instance it committed, and never
20
+ * for a write that changed nothing.
17
21
  */
18
22
 
19
23
  /** One surface an editor tab may need to re-read. */
@@ -30,6 +34,12 @@ export const LIVE_TOPICS = [
30
34
  "crawl",
31
35
  /** Deploy runs (Stage, Publish live, rollback). */
32
36
  "deploys",
37
+ /**
38
+ * Site content changed: a journaled row committed (a chat's or an MCP
39
+ * agent's write, a Stage, an undo, a settings change). From the change
40
+ * journal, never from an op name (#1267).
41
+ */
42
+ "draft",
33
43
  ] as const;
34
44
 
35
45
  export type LiveTopic = (typeof LIVE_TOPICS)[number];
@@ -70,6 +80,11 @@ const EXACT: Readonly<Record<string, readonly LiveTopic[]>> = {
70
80
  // An external agent's tool call: first sight of it lists it as running,
71
81
  // and it may have queued a card.
72
82
  "chat.touch_external_agent": ["agents", "pending"],
83
+ // The same presence row, written by Power-MCP's bookkeeping around each
84
+ // tool call (#1253): both mark the agent present (as the touch did), and
85
+ // the finish follows a card the tool may have queued.
86
+ "mcp.begin_tool_call": ["agents"],
87
+ "mcp.finish_tool_call": ["agents", "pending"],
73
88
  "chat.record_turn_approvals": ["pending"],
74
89
  "chat.decide_turn_approval": ["pending"],
75
90
  "chat.supersede_turn_approvals": ["pending"],
package/src/media.test.ts CHANGED
@@ -103,12 +103,43 @@ describe("media schemas", () => {
103
103
  height: 1080,
104
104
  sizeBytes: 1024,
105
105
  storageKey: `${"a".repeat(64)}/orig.jpg`,
106
+ digest: {
107
+ contentSha256: "b".repeat(64),
108
+ size: 1024,
109
+ gzipSha256: "c".repeat(64),
110
+ gzipSize: 1000,
111
+ profile: "gzip9-0123456789abcdef",
112
+ },
106
113
  },
107
114
  ],
108
115
  });
109
116
  expect(r.success).toBe(true);
110
117
  });
111
118
 
119
+ it("mediaUploadInputSchema requires each variant's published digests (#1271)", () => {
120
+ const r = mediaUploadInputSchema.safeParse({
121
+ sha256: "a".repeat(64),
122
+ originalName: "hero.jpg",
123
+ mime: "image/jpeg",
124
+ sizeBytes: 1024,
125
+ width: 1920,
126
+ height: 1080,
127
+ alt: "",
128
+ storageKey: `${"a".repeat(64)}/orig.jpg`,
129
+ variants: [
130
+ {
131
+ variant: "orig",
132
+ format: "jpeg",
133
+ width: 1920,
134
+ height: 1080,
135
+ sizeBytes: 1024,
136
+ storageKey: `${"a".repeat(64)}/orig.jpg`,
137
+ },
138
+ ],
139
+ });
140
+ expect(r.success).toBe(false);
141
+ });
142
+
112
143
  it("mediaUploadInputSchema rejects non-hex sha256", () => {
113
144
  const r = mediaUploadInputSchema.safeParse({
114
145
  sha256: "not-a-sha",
package/src/media.ts CHANGED
@@ -295,6 +295,21 @@ const mediaRefPattern =
295
295
 
296
296
  const sha256Schema = z.string().regex(/^[0-9a-f]{64}$/, "must be hex sha256");
297
297
 
298
+ /**
299
+ * #1271 — the published digests of one stored media file (the static
300
+ * generator's `published-digest.ts`), stored with its variant row so no
301
+ * build reads the file to name or upload it.
302
+ */
303
+ export const publishedDigestSchema = z
304
+ .object({
305
+ contentSha256: sha256Schema,
306
+ size: z.number().int().nonnegative(),
307
+ gzipSha256: sha256Schema,
308
+ gzipSize: z.number().int().nonnegative(),
309
+ profile: z.string().min(1).max(64),
310
+ })
311
+ .strict();
312
+
298
313
  /**
299
314
  * #531 — `library` assets are listed and placeable on pages; `reference`
300
315
  * assets exist only to guide image generation/editing: hidden from the
@@ -345,6 +360,8 @@ export const mediaUploadInputSchema = z
345
360
  height: z.number().int().positive().nullable(),
346
361
  sizeBytes: z.number().int().positive(),
347
362
  storageKey: z.string().min(1),
363
+ /** #1271 — taken from the bytes the writer stored (runMediaPipeline). */
364
+ digest: publishedDigestSchema,
348
365
  }),
349
366
  )
350
367
  .min(1),