@elytracms/studio 0.0.81 → 0.0.83

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 (137) hide show
  1. package/convex/redirect-revalidate.test.ts +130 -0
  2. package/convex/revalidate.ts +9 -7
  3. package/convex/triggers.ts +33 -3
  4. package/dist/components/ai/ai-chat-panel.js +26 -4
  5. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  6. package/dist/components/ai/proposal-card.d.ts +27 -0
  7. package/dist/components/ai/proposal-card.js +31 -3
  8. package/dist/components/ai/proposal-card.js.map +1 -1
  9. package/dist/components/ai/proposal-issues.d.ts +55 -0
  10. package/dist/components/ai/proposal-issues.js +69 -0
  11. package/dist/components/ai/proposal-issues.js.map +1 -0
  12. package/dist/components/ai/translate-document-dialog.js +38 -3
  13. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  14. package/dist/components/app-views/studio-config.d.ts +1 -1
  15. package/dist/components/app-views/studio-config.js +1 -1
  16. package/dist/components/app-views/studio-config.js.map +1 -1
  17. package/dist/components/builder/canvas-render-context.js +3 -1
  18. package/dist/components/builder/canvas-render-context.js.map +1 -1
  19. package/dist/components/builder/clipboard-actions.js +5 -3
  20. package/dist/components/builder/clipboard-actions.js.map +1 -1
  21. package/dist/components/cms/bulk-actions.js +8 -5
  22. package/dist/components/cms/bulk-actions.js.map +1 -1
  23. package/dist/components/cms/cell-edit.d.ts +18 -3
  24. package/dist/components/cms/cell-edit.js +27 -9
  25. package/dist/components/cms/cell-edit.js.map +1 -1
  26. package/dist/components/cms/cms-store.d.ts +14 -1
  27. package/dist/components/cms/cms-store.js +93 -24
  28. package/dist/components/cms/cms-store.js.map +1 -1
  29. package/dist/components/cms/collection-table.js +1 -1
  30. package/dist/components/cms/collection-table.js.map +1 -1
  31. package/dist/components/cms/detail-editor.js +7 -12
  32. package/dist/components/cms/detail-editor.js.map +1 -1
  33. package/dist/components/cms/document-editor-context.d.ts +12 -1
  34. package/dist/components/cms/document-editor-context.js.map +1 -1
  35. package/dist/components/cms/field-control.js +20 -7
  36. package/dist/components/cms/field-control.js.map +1 -1
  37. package/dist/components/cms/gate-validation.d.ts +36 -0
  38. package/dist/components/cms/gate-validation.js +17 -0
  39. package/dist/components/cms/gate-validation.js.map +1 -0
  40. package/dist/components/cms/inline-field-errors.d.ts +8 -0
  41. package/dist/components/cms/inline-field-errors.js +52 -0
  42. package/dist/components/cms/inline-field-errors.js.map +1 -0
  43. package/dist/components/cms/issues-pane.js +8 -4
  44. package/dist/components/cms/issues-pane.js.map +1 -1
  45. package/dist/components/cms/slug-collision-report.d.ts +41 -0
  46. package/dist/components/cms/slug-collision-report.js +52 -0
  47. package/dist/components/cms/slug-collision-report.js.map +1 -0
  48. package/dist/components/deployment-setup-gate.d.ts +24 -0
  49. package/dist/components/deployment-setup-gate.js +29 -4
  50. package/dist/components/deployment-setup-gate.js.map +1 -1
  51. package/dist/components/issue-severity.d.ts +73 -0
  52. package/dist/components/issue-severity.js +56 -0
  53. package/dist/components/issue-severity.js.map +1 -0
  54. package/dist/components/nav-issues.d.ts +68 -0
  55. package/dist/components/nav-issues.js +78 -0
  56. package/dist/components/nav-issues.js.map +1 -0
  57. package/dist/components/operation-error.js +3 -3
  58. package/dist/components/operation-error.js.map +1 -1
  59. package/dist/components/section-nav.js +5 -38
  60. package/dist/components/section-nav.js.map +1 -1
  61. package/dist/lib/ai/apply-content.js +6 -7
  62. package/dist/lib/ai/apply-content.js.map +1 -1
  63. package/dist/lib/ai/chat-context.d.ts +32 -26
  64. package/dist/lib/ai/chat-context.js +81 -9
  65. package/dist/lib/ai/chat-context.js.map +1 -1
  66. package/dist/lib/ai/constraints.d.ts +42 -0
  67. package/dist/lib/ai/constraints.js +259 -0
  68. package/dist/lib/ai/constraints.js.map +1 -0
  69. package/dist/lib/ai/index.d.ts +2 -0
  70. package/dist/lib/ai/index.js +2 -0
  71. package/dist/lib/ai/index.js.map +1 -1
  72. package/dist/lib/ai/proposal-validation.d.ts +26 -0
  73. package/dist/lib/ai/proposal-validation.js +17 -0
  74. package/dist/lib/ai/proposal-validation.js.map +1 -0
  75. package/dist/lib/ai/shape.js +60 -13
  76. package/dist/lib/ai/shape.js.map +1 -1
  77. package/dist/lib/ai/tools.d.ts +8 -4
  78. package/dist/lib/ai/tools.js +43 -14
  79. package/dist/lib/ai/tools.js.map +1 -1
  80. package/dist/lib/ai/translate-document.d.ts +35 -1
  81. package/dist/lib/ai/translate-document.js +16 -1
  82. package/dist/lib/ai/translate-document.js.map +1 -1
  83. package/dist/lib/ai/types.d.ts +11 -6
  84. package/dist/lib/ai/types.js.map +1 -1
  85. package/dist/lib/ai/update-fields.d.ts +8 -6
  86. package/dist/lib/ai/update-fields.js +9 -22
  87. package/dist/lib/ai/update-fields.js.map +1 -1
  88. package/dist/lib/clipboard/paste-issues.d.ts +5 -0
  89. package/dist/lib/clipboard/paste-issues.js +16 -6
  90. package/dist/lib/clipboard/paste-issues.js.map +1 -1
  91. package/dist/lib/cms/document-gate.d.ts +37 -0
  92. package/dist/lib/cms/document-gate.js +40 -0
  93. package/dist/lib/cms/document-gate.js.map +1 -0
  94. package/dist/lib/cms-views/view-model.d.ts +5 -0
  95. package/dist/lib/cms-views/view-model.js +10 -4
  96. package/dist/lib/cms-views/view-model.js.map +1 -1
  97. package/dist/lib/data/collection-content.js +1 -3
  98. package/dist/lib/data/collection-content.js.map +1 -1
  99. package/dist/lib/i18n/i18n.d.ts +51 -16
  100. package/dist/lib/i18n/i18n.js +119 -43
  101. package/dist/lib/i18n/i18n.js.map +1 -1
  102. package/dist/lib/i18n/index.d.ts +8 -1
  103. package/dist/lib/i18n/index.js +1 -1
  104. package/dist/lib/i18n/index.js.map +1 -1
  105. package/dist/lib/i18n/messages.de.js +68 -0
  106. package/dist/lib/i18n/messages.de.js.map +1 -1
  107. package/dist/lib/i18n/messages.en.d.ts +59 -0
  108. package/dist/lib/i18n/messages.en.js +80 -0
  109. package/dist/lib/i18n/messages.en.js.map +1 -1
  110. package/dist/lib/i18n/types.d.ts +15 -9
  111. package/dist/lib/i18n/types.js.map +1 -1
  112. package/dist/lib/operations/studio-operations.js +19 -2
  113. package/dist/lib/operations/studio-operations.js.map +1 -1
  114. package/dist/lib/persistence/deployment-readiness.js +10 -0
  115. package/dist/lib/persistence/deployment-readiness.js.map +1 -1
  116. package/dist/lib/persona/labels.d.ts +44 -9
  117. package/dist/lib/persona/labels.js +143 -60
  118. package/dist/lib/persona/labels.js.map +1 -1
  119. package/dist/lib/webhooks/dispatcher.d.ts +5 -2
  120. package/dist/lib/webhooks/dispatcher.js.map +1 -1
  121. package/dist/lib/webhooks/tags.js +150 -17
  122. package/dist/lib/webhooks/tags.js.map +1 -1
  123. package/dist/lib/workspace/config-issues.d.ts +3 -1
  124. package/dist/lib/workspace/config-issues.js +3 -1
  125. package/dist/lib/workspace/config-issues.js.map +1 -1
  126. package/dist/lib/workspace/index.d.ts +15 -8
  127. package/dist/lib/workspace/index.js +17 -18
  128. package/dist/lib/workspace/index.js.map +1 -1
  129. package/dist/lib/workspace/workspace-source.js +27 -0
  130. package/dist/lib/workspace/workspace-source.js.map +1 -1
  131. package/dist/previews/manifest-format.d.ts +13 -6
  132. package/dist/previews/manifest-format.js +9 -2
  133. package/dist/previews/manifest-format.js.map +1 -1
  134. package/dist/router/root.d.ts +36 -1
  135. package/dist/router/root.js +54 -7
  136. package/dist/router/root.js.map +1 -1
  137. package/package.json +5 -2
@@ -1 +1 @@
1
- {"version":3,"file":"labels.js","sourceRoot":"","sources":["../../../src/lib/persona/labels.ts"],"names":[],"mappings":"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","sourcesContent":["import type { IssueCode, ConditionOperator } from '@elytracms/core/project-graph'\nimport type { CmsIssueCode, CmsValidationIssue } from '@elytracms/core/cms-core'\nimport { resolveLabel } from '@elytracms/core/cms-core'\nimport type { OperationErrorCode } from '@elytracms/core/operations'\nimport type { CustomizationIssue } from '@elytracms/core/studio-core'\nimport type { ComponentManifest } from '@elytracms/core/component-registry'\nimport { localizedLabel, translate } from '@elytracms/studio/lib/i18n'\nimport { humanizeName } from '../utils'\n\n/**\n * Persona-aware labels (EC-192 item 2, Milestone B promise).\n *\n * A non-technical editor must never see a manifest/binding/path-token/validation\n * identifier on — or one click off — the editor path. The engine speaks in stable\n * machine codes (`issueCodeSchema`, `cmsIssueCodeSchema`, `operationErrorCodeSchema`)\n * and machine ids (`base.Heading`, `r-blog-slug-en`); this module is the\n * single translation layer the editor-facing surfaces route through.\n *\n * The maps are exhaustive `Record<…Code, string>` over the Zod enums, so adding a\n * code in `@elytracms/core/project-graph`, `@elytracms/core/cms-core`, or `@elytracms/core/operations`\n * fails typecheck here until it is given an editor-facing label — the persona\n * boundary cannot silently regress. Engineers keep the raw code: every surface that\n * shows a label also carries the code in a `title=`/`data-*` attribute (inspectable\n * via hover/devtools, never rendered as visible copy).\n */\n\nconst GRAPH_ISSUE_LABELS: Record<IssueCode, string> = {\n 'invalid-component-name': 'Invalid component',\n 'unknown-component': 'Unknown component',\n 'duplicate-node-id': 'Duplicate item',\n 'unknown-prop': 'Unknown setting',\n 'invalid-prop': 'Invalid setting',\n 'binding-not-allowed': 'Data not allowed here',\n 'unresolved-binding': 'Missing data',\n 'missing-required-slot': 'Missing content',\n 'unknown-slot': 'Unknown content area',\n 'forbidden-component': 'Not allowed here',\n 'server-only-component': 'Not available in the editor',\n 'invalid-container-direction': 'Invalid layout',\n 'invalid-container-alignment': 'Invalid alignment',\n 'invalid-container-gap': 'Invalid spacing',\n 'invalid-container-columns': 'Invalid columns',\n 'invalid-container-responsive': 'Invalid responsive setting',\n}\n\n/**\n * EC-443: the CMS issue titles live in the EC-241 message catalog\n * (`cms.issue.<code>`, EN source + shipped de), so the Issues pane / for-review\n * lane localize with the rest of the chrome. This map stays the exhaustive\n * `Record` over the Zod enum — a new code still fails typecheck here until it\n * gets an editor-facing catalog key (the persona boundary cannot silently\n * regress); {@link cmsIssueLabel} resolves it through `translate`.\n */\nconst CMS_ISSUE_LABEL_KEYS: Record<CmsIssueCode, string> = {\n 'duplicate-collection': 'cms.issue.duplicate-collection',\n 'duplicate-field': 'cms.issue.duplicate-field',\n 'unknown-collection': 'cms.issue.unknown-collection',\n 'invalid-collection-config': 'cms.issue.invalid-collection-config',\n 'invalid-field-config': 'cms.issue.invalid-field-config',\n 'reserved-field-name': 'cms.issue.reserved-field-name',\n 'missing-required-field': 'cms.issue.missing-required-field',\n 'invalid-field-value': 'cms.issue.invalid-field-value',\n 'unknown-relation-target': 'cms.issue.unknown-relation-target',\n 'unpublished-relation-target': 'cms.issue.unpublished-relation-target',\n 'cardinality-violation': 'cms.issue.cardinality-violation',\n 'unknown-asset': 'cms.issue.unknown-asset',\n 'duplicate-document': 'cms.issue.duplicate-document',\n 'unknown-locale': 'cms.issue.unknown-locale',\n 'missing-localized-value': 'cms.issue.missing-localized-value',\n 'route-conflict': 'cms.issue.route-conflict',\n 'redirect-loop': 'cms.issue.redirect-loop',\n 'unknown-route-target': 'cms.issue.unknown-route-target',\n 'hierarchy-cycle': 'cms.issue.hierarchy-cycle',\n 'document-nesting-depth': 'cms.issue.document-nesting-depth',\n 'singleton-document-drift': 'cms.issue.singleton-document-drift',\n 'duplicate-alias': 'cms.issue.duplicate-alias',\n 'unknown-alias': 'cms.issue.unknown-alias',\n 'unfilled-slot': 'cms.issue.unfilled-slot',\n 'custom-validation': 'cms.issue.custom-validation',\n 'unknown-version': 'cms.issue.unknown-version',\n}\n\nconst OPERATION_ERROR_LABELS: Record<OperationErrorCode, string> = {\n validation: \"Couldn't save\",\n 'not-found': 'Not found',\n conflict: 'Already exists',\n 'backend-unavailable': 'Connection problem',\n unauthorized: 'Not allowed',\n unknown: 'Something went wrong',\n}\n\nconst EXPORT_TARGET_LABELS: Record<string, string> = {\n nextjs: 'Next.js',\n}\n\n/**\n * Plain-language labels for condition operators. Exhaustive over\n * `conditionOperatorSchema` — a new operator fails typecheck here until it has an\n * editor-facing label. The raw operator (`eq`, `gte`, …) is a machine token an\n * editor must never read in the inspector.\n */\nconst CONDITION_OPERATOR_LABELS: Record<ConditionOperator, string> = {\n truthy: 'Is set',\n exists: 'Exists',\n eq: 'Equals',\n neq: 'Does not equal',\n gt: 'Greater than',\n gte: 'At least',\n lt: 'Less than',\n lte: 'At most',\n}\n\n/** Plain-language title for a graph validation issue code. */\nexport function graphIssueLabel(code: IssueCode): string {\n return GRAPH_ISSUE_LABELS[code]\n}\n\n/**\n * Plain-language title for a CMS validation issue code — localized against the\n * active studio-UI locale (EC-443). A runtime-unknown code (impossible typewise;\n * a cast could smuggle one) degrades to its humanized form, never the raw enum.\n */\nexport function cmsIssueLabel(code: CmsIssueCode): string {\n const key = CMS_ISSUE_LABEL_KEYS[code] as string | undefined\n return key !== undefined ? translate(key) : humanizeName(code)\n}\n\n/**\n * The localized text of a {@link CustomizationIssue} (EC-478) — the ONE place the\n * studio turns a config-level finding into rendered prose.\n *\n * The producing layer (`studio-core/customization.ts`, `app-views/default-modules.tsx`,\n * `builder/reuse.ts`) cannot own the wording: the EC-236 seam lives in `packages/core`,\n * a leaf of the DAG that may not import this catalog. So it emits a\n * `CustomizationMessageRef` — a stable key plus its interpolation values, all of\n * which are IDENTIFIERS (entry ids, extension points, component ids) and therefore the\n * same in every language — and this resolves it against the active UI locale. Same\n * shape as {@link cmsIssueLabel}, one layer down: the engine speaks codes and keys, the\n * studio speaks the editor's language.\n *\n * `messageRef.reason` fills the `{reason}` slot of `invalid-override`, whose detail is\n * itself prose (a `validate` rejection) and so carries its own key. A ref-less issue —\n * a third-party producer of this published type — degrades to its English\n * `issue.message`, which is honest: a visible English sentence beats a missing key.\n */\nexport function customizationIssueMessage(issue: CustomizationIssue): string {\n const ref = issue.messageRef\n if (!ref) return issue.message\n const vars = { ...ref.vars }\n if (ref.reason) vars.reason = translate(ref.reason.key, ref.reason.vars)\n return translate(ref.key, vars)\n}\n\n/**\n * The localized BODY of a {@link CmsValidationIssue} (EC-487) — the ONE place the\n * studio turns a CMS validation finding into rendered prose, exactly as\n * {@link customizationIssueMessage} does one layer up.\n *\n * EC-443 localized the issue TITLES ({@link cmsIssueLabel}); the bodies stayed English\n * because their producers live in `packages/core`, below this catalog in the DAG. So\n * the producer emits a `CmsMessageRef` — a stable key plus its interpolation values,\n * all of which are IDENTIFIERS (field/collection/route ids, locales, patterns, counts)\n * and therefore the same in every language — and this resolves it against the active\n * UI locale.\n *\n * `messageRef.details` fill placeholders whose text is itself prose, keyed by\n * placeholder name. A placeholder with no entry keeps whatever the producer put in\n * `vars` — the honest outcome for prose the engine did not author (a config-as-code\n * `validate` message, a composition finding), which then stays in its original\n * language rather than vanishing. A ref-less issue — a third-party producer of this\n * published type — degrades to its English `issue.message`: a visible English sentence\n * beats a missing key.\n */\nexport function cmsValidationIssueMessage(issue: CmsValidationIssue): string {\n const ref = issue.messageRef\n if (!ref) return issue.message\n const vars: Record<string, string | number> = { ...ref.vars }\n for (const [placeholder, nested] of Object.entries(ref.details ?? {})) {\n vars[placeholder] = translate(nested.key, nested.vars)\n }\n return translate(ref.key, vars)\n}\n\n/** Plain-language title for an operation failure code. */\nexport function operationErrorLabel(code: OperationErrorCode): string {\n return OPERATION_ERROR_LABELS[code] ?? OPERATION_ERROR_LABELS.unknown\n}\n\n/** Friendly name for a project export target (`nextjs` → `Next.js`). */\nexport function exportTargetLabel(target: string): string {\n return EXPORT_TARGET_LABELS[target] ?? humanizeName(target)\n}\n\n/** Plain-language label for a condition operator (`gte` → `At least`). */\nexport function conditionOperatorLabel(operator: ConditionOperator): string {\n return CONDITION_OPERATOR_LABELS[operator]\n}\n\n/** The leaf of a component id, namespace stripped (`base.Heading` → `Heading`). */\nexport function componentLeafId(componentId: string): string {\n const dot = componentId.lastIndexOf('.')\n return dot === -1 ? componentId : componentId.slice(dot + 1)\n}\n\n/**\n * Strip the `base.`/`project.` namespace prefix from a component id for compact,\n * editor-facing labels (`base.Heading` → `Heading`). Unlike {@link componentLeafId}\n * it removes only those two known prefixes, so a dotted custom id keeps its shape.\n */\nexport function shortId(componentId: string): string {\n return componentId.replace('base.', '').replace('project.', '')\n}\n\n/**\n * The human display name of a component TYPE for editor-facing surfaces: the\n * manifest's authored `title` (EC-407: resolved through {@link resolveLabel}, so a\n * `{ en, de }` localized title renders in the active studio-UI locale) when present,\n * else the humanized leaf of its id (`base.Heading` → `Heading`,\n * `project.MarketingHero` → `Marketing hero`). Never the raw namespaced id.\n *\n * `locale` is optional: omit it (the render-site default) to resolve against the\n * active studio-UI locale non-reactively; pass it explicitly for pure/unit callers.\n */\nexport function componentDisplayName(\n componentId: string,\n manifest?: ComponentManifest | undefined,\n locale?: string,\n): string {\n const resolved =\n locale === undefined ? localizedLabel(manifest?.title) : (resolveLabel(manifest?.title, locale) ?? '')\n return resolved || humanizeName(componentLeafId(componentId))\n}\n"]}
1
+ {"version":3,"file":"labels.js","sourceRoot":"","sources":["../../../src/lib/persona/labels.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CmsIssueCode, CmsIssueSeverity, CmsValidationIssue } from '@elytracms/core/cms-core'\nimport { resolveLabel } from '@elytracms/core/cms-core'\nimport type { OperationError, OperationErrorCode, OperationIssue } from '@elytracms/core/operations'\nimport type { BuildFragmentError, FragmentParseError } from '@elytracms/core/clipboard'\nimport type { ContentQueryError } from '@elytracms/core/content'\nimport type { CustomizationIssue } from '@elytracms/core/studio-core'\nimport type { ComponentManifest } from '@elytracms/core/component-registry'\nimport { localizedLabel, translate } from '@elytracms/studio/lib/i18n'\nimport { humanizeName } from '../utils'\n\n/**\n * Persona-aware labels (EC-192 item 2, Milestone B promise).\n *\n * A non-technical editor must never see a manifest/binding/path-token/validation\n * identifier on — or one click off — the editor path. The engine speaks in stable\n * machine codes (`cmsIssueCodeSchema`, `operationErrorCodeSchema`) and machine ids\n * (`base.Heading`, `r-blog-slug-en`); this module is the single translation layer the\n * editor-facing surfaces route through.\n *\n * The maps are exhaustive `Record<…Code, string>` over the Zod enums, so adding a\n * code in `@elytracms/core/cms-core` or `@elytracms/core/operations`\n * fails typecheck here until it is given an editor-facing label — the persona\n * boundary cannot silently regress. A table is only worth that guard while something\n * renders it: EC-509 deleted two that nothing did (see below). Engineers keep the raw\n * code: every surface that\n * shows a label also carries the code in a `title=`/`data-*` attribute (inspectable\n * via hover/devtools, never rendered as visible copy).\n */\n\n/**\n * EC-509 — two label tables were DELETED here rather than localized, because measuring\n * first showed nothing rendered them.\n *\n * `GRAPH_ISSUE_LABELS`/`graphIssueLabel` (16 entries, project-graph `IssueCode`) and\n * `CONDITION_OPERATOR_LABELS`/`conditionOperatorLabel` (8, `ConditionOperator`) had\n * **zero call sites** outside this file and its own test — measured by grep over\n * `packages/` + `apps/`, with `cmsIssueLabel` (4 sites) and `operationErrorLabel` (1)\n * as the known-positive control that the search finds a render site when one exists.\n *\n * The graph table was unreachable structurally, not just unused: the ONE live path from\n * graph validation into the studio is `cms-composition-validators.ts`, which maps a\n * graph issue to `CompositionIssue` — a type with `message`/`severity`/`nodePath` and\n * **no `code` field at all** (`cms-core/validate-document.ts:809`). The code an\n * exhaustiveness guard exists to force a label for cannot reach an editor surface.\n * `ConditionOperator` is live in the runtime renderer's condition gate\n * (`runtime-renderer/render.tsx:48`) but has no studio surface: v1 canvas is static\n * props, editor bindings parked, so no inspector ever shows an operator to name.\n *\n * **This overturns an EC-210 decision** (2026-06-17), which kept `graphIssueLabel` when\n * the validation panel was removed, on the rationale that it was \"the EC-192 boundary's\n * exhaustiveness guard, not panel code\". That was right then and its premise is false\n * now — the guard guards a boundary with no traffic, so it only forced editor copy for\n * strings no editor can see. Localizing them would have added 24 keys to the same\n * effect. If a surface ever renders these codes it needs labels written for it, and\n * will write them localized from the start.\n *\n * Neither symbol is referenced by either consumer repo (measured read-only) and neither\n * appears in docs.\n */\n\n/**\n * EC-443: the CMS issue titles live in the EC-241 message catalog\n * (`cms.issue.<code>`, EN source + shipped de), so the Issues pane / for-review\n * lane localize with the rest of the chrome. This map stays the exhaustive\n * `Record` over the Zod enum — a new code still fails typecheck here until it\n * gets an editor-facing catalog key (the persona boundary cannot silently\n * regress); {@link cmsIssueLabel} resolves it through `translate`.\n */\nconst CMS_ISSUE_LABEL_KEYS: Record<CmsIssueCode, string> = {\n 'duplicate-collection': 'cms.issue.duplicate-collection',\n 'duplicate-field': 'cms.issue.duplicate-field',\n 'unknown-collection': 'cms.issue.unknown-collection',\n 'invalid-collection-config': 'cms.issue.invalid-collection-config',\n 'invalid-field-config': 'cms.issue.invalid-field-config',\n 'reserved-field-name': 'cms.issue.reserved-field-name',\n 'missing-required-field': 'cms.issue.missing-required-field',\n 'invalid-field-value': 'cms.issue.invalid-field-value',\n 'unknown-relation-target': 'cms.issue.unknown-relation-target',\n 'unpublished-relation-target': 'cms.issue.unpublished-relation-target',\n 'cardinality-violation': 'cms.issue.cardinality-violation',\n 'unknown-asset': 'cms.issue.unknown-asset',\n 'duplicate-document': 'cms.issue.duplicate-document',\n 'unknown-locale': 'cms.issue.unknown-locale',\n 'missing-localized-value': 'cms.issue.missing-localized-value',\n 'route-conflict': 'cms.issue.route-conflict',\n 'redirect-loop': 'cms.issue.redirect-loop',\n 'unknown-route-target': 'cms.issue.unknown-route-target',\n 'hierarchy-cycle': 'cms.issue.hierarchy-cycle',\n 'document-nesting-depth': 'cms.issue.document-nesting-depth',\n 'singleton-document-drift': 'cms.issue.singleton-document-drift',\n 'duplicate-alias': 'cms.issue.duplicate-alias',\n 'unknown-alias': 'cms.issue.unknown-alias',\n 'unfilled-slot': 'cms.issue.unfilled-slot',\n 'custom-validation': 'cms.issue.custom-validation',\n 'unaddressable-url': 'cms.issue.unaddressable-url',\n 'unknown-version': 'cms.issue.unknown-version',\n}\n\n/**\n * EC-507 rider — the few codes whose TITLE overstates at `warning` severity.\n *\n * `invalid-field-value` reads \"Invalid value\", which is right when the value is\n * rejected (wrong type, not among its options) and wrong when it is not: EC-472\n * deliberately STORES `0202-09-18` as a legal ISO date and merely flags the year as\n * probably unintended. A title calling that invalid contradicts the value the editor\n * can plainly see was accepted, on all three surfaces at once.\n *\n * Keyed on SEVERITY rather than on the implausible-year case specifically, because\n * the overstatement belongs to the severity: any future warning under this code has\n * the same property (the value was kept), and the fix then needs no new entry. A\n * sparse `Partial` on purpose — a code with no entry keeps one title for both\n * severities, which is the correct default and keeps\n * {@link CMS_ISSUE_LABEL_KEYS} the single exhaustive map.\n */\nconst CMS_ISSUE_WARNING_LABEL_KEYS: Partial<Record<CmsIssueCode, string>> = {\n 'invalid-field-value': 'cms.issue.invalid-field-value.warning',\n}\n\n/**\n * EC-494: the badge titles live in the EC-241 catalog (`operations.errorLabel.<code>`),\n * exactly as EC-443 moved the CMS issue titles. **Measured before changing it:** this\n * map was a hard-coded English `Record`, so once the EC-494 bodies localized the banner\n * rendered a German sentence under an English badge — the inverse of the bug this\n * ticket exists for, and not what the ticket said was there. Still the exhaustive\n * `Record` over the Zod enum, so a new code fails typecheck until it has a key.\n */\nconst OPERATION_ERROR_LABEL_KEYS: Record<OperationErrorCode, string> = {\n validation: 'operations.errorLabel.validation',\n 'not-found': 'operations.errorLabel.not-found',\n conflict: 'operations.errorLabel.conflict',\n 'backend-unavailable': 'operations.errorLabel.backend-unavailable',\n unauthorized: 'operations.errorLabel.unauthorized',\n unknown: 'operations.errorLabel.unknown',\n}\n\nconst EXPORT_TARGET_LABELS: Record<string, string> = {\n nextjs: 'Next.js',\n}\n\n/**\n * Plain-language title for a CMS validation issue code — localized against the\n * active studio-UI locale (EC-443). A runtime-unknown code (impossible typewise;\n * a cast could smuggle one) degrades to its humanized form, never the raw enum.\n *\n * `severity` is optional and additive (EC-507): pass the issue's own severity and a\n * code with a softer warning title (see {@link CMS_ISSUE_WARNING_LABEL_KEYS}) uses\n * it; omit it, or pass `'error'`, and nothing changes. Every render site passes it —\n * the parameter is optional so that a pure/unit caller naming only a code still\n * compiles, not because a surface may skip it.\n */\nexport function cmsIssueLabel(code: CmsIssueCode, severity?: CmsIssueSeverity): string {\n const key =\n (severity === 'warning' ? CMS_ISSUE_WARNING_LABEL_KEYS[code] : undefined) ??\n (CMS_ISSUE_LABEL_KEYS[code] as string | undefined)\n return key !== undefined ? translate(key) : humanizeName(code)\n}\n\n/**\n * The localized text of a {@link CustomizationIssue} (EC-478) — the ONE place the\n * studio turns a config-level finding into rendered prose.\n *\n * The producing layer (`studio-core/customization.ts`, `app-views/default-modules.tsx`,\n * `builder/reuse.ts`) cannot own the wording: the EC-236 seam lives in `packages/core`,\n * a leaf of the DAG that may not import this catalog. So it emits a\n * `CustomizationMessageRef` — a stable key plus its interpolation values, all of\n * which are IDENTIFIERS (entry ids, extension points, component ids) and therefore the\n * same in every language — and this resolves it against the active UI locale. Same\n * shape as {@link cmsIssueLabel}, one layer down: the engine speaks codes and keys, the\n * studio speaks the editor's language.\n *\n * `messageRef.reason` fills the `{reason}` slot of `invalid-override`, whose detail is\n * itself prose (a `validate` rejection) and so carries its own key. A ref-less issue —\n * a third-party producer of this published type — degrades to its English\n * `issue.message`, which is honest: a visible English sentence beats a missing key.\n */\nexport function customizationIssueMessage(issue: CustomizationIssue): string {\n const ref = issue.messageRef\n if (!ref) return issue.message\n const vars = { ...ref.vars }\n if (ref.reason) vars.reason = translate(ref.reason.key, ref.reason.vars)\n return translate(ref.key, vars)\n}\n\n/**\n * The localized BODY of a {@link CmsValidationIssue} (EC-487) — the ONE place the\n * studio turns a CMS validation finding into rendered prose, exactly as\n * {@link customizationIssueMessage} does one layer up.\n *\n * EC-443 localized the issue TITLES ({@link cmsIssueLabel}); the bodies stayed English\n * because their producers live in `packages/core`, below this catalog in the DAG. So\n * the producer emits a `CmsMessageRef` — a stable key plus its interpolation values,\n * all of which are IDENTIFIERS (field/collection/route ids, locales, patterns, counts)\n * and therefore the same in every language — and this resolves it against the active\n * UI locale.\n *\n * `messageRef.details` fill placeholders whose text is itself prose, keyed by\n * placeholder name. A placeholder with no entry keeps whatever the producer put in\n * `vars` — the honest outcome for prose the engine did not author (a config-as-code\n * `validate` message, a composition finding), which then stays in its original\n * language rather than vanishing. A ref-less issue — a third-party producer of this\n * published type — degrades to its English `issue.message`: a visible English sentence\n * beats a missing key.\n */\nexport function cmsValidationIssueMessage(issue: CmsValidationIssue): string {\n return resolveMessageRef(issue.messageRef, issue.message)\n}\n\n/**\n * EC-494 — the ONE resolver every carried message ref goes through.\n *\n * Four published types now carry the same `{ key, vars?, details? }` reference —\n * `CmsValidationIssue` (EC-487), `CustomizationIssue` (EC-478, via its own `reason`\n * slot), and now `OperationIssue`/`OperationError` and the clipboard + content-query\n * errors. They are separate declarations only because the EC-265 DAG forbids the\n * lower subpaths from importing `cms-core`; the RESOLUTION is one rule, so it lives\n * in one function.\n *\n * `fallback` is the producer's prebuilt English, rendered whenever there is no ref —\n * a third-party producer, a Zod diagnostic, a persistence-layer message, a\n * config-authored `validate` string. A visible English sentence beats a missing key\n * (EC-443).\n */\nfunction resolveMessageRef(\n ref:\n | {\n readonly key: string\n readonly vars?: Readonly<Record<string, string | number>>\n readonly details?: Readonly<\n Record<string, { readonly key: string; readonly vars?: Readonly<Record<string, string | number>> }>\n >\n }\n | undefined,\n fallback: string,\n): string {\n if (!ref) return fallback\n const vars: Record<string, string | number> = { ...ref.vars }\n for (const [placeholder, nested] of Object.entries(ref.details ?? {})) {\n vars[placeholder] = translate(nested.key, nested.vars)\n }\n return translate(ref.key, vars)\n}\n\n/**\n * The localized text of ONE {@link OperationIssue} line in the error banner (EC-494).\n *\n * These issues cross a process boundary: `operations/documents.ts` rebuilds a\n * `CmsValidationIssue` as an `OperationIssue` **server-side, with no locale**, so the\n * producer cannot translate and the catalog key rides on the wire instead. A ref-less\n * issue — a Zod input diagnostic, a persistence-layer message, a third-party producer\n * of this published type — keeps its English.\n */\nexport function operationIssueMessage(issue: OperationIssue): string {\n return resolveMessageRef(issue.messageRef, issue.message)\n}\n\n/**\n * The localized ENVELOPE sentence of an {@link OperationError} (EC-494) — the line\n * beside the localized badge in `OperationErrorBanner`. Localizing only the issue\n * lines would have left exactly one English sentence under a German badge.\n */\nexport function operationErrorMessage(error: OperationError): string {\n return resolveMessageRef(error.messageRef, error.message)\n}\n\n/**\n * The localized text of a clipboard fragment failure (EC-494) — parse (`corrupt-json`,\n * `foreign-format`, `unsupported-version`, `invalid-payload`) or build\n * (`node-not-found`, `fixture-not-found`), rendered by the builder's paste notice.\n */\nexport function clipboardErrorMessage(error: FragmentParseError | BuildFragmentError): string {\n return resolveMessageRef(error.messageRef, error.message)\n}\n\n/**\n * The localized text of a {@link ContentQueryError} (EC-494) — the filter builder's\n * per-clause line and the canvas listing notice, both of which render errors built by\n * `core/content`'s `validateWhere`/`validateSort`/`validateLimit`.\n */\nexport function contentQueryErrorMessage(error: ContentQueryError): string {\n return resolveMessageRef(error.messageRef, error.message)\n}\n\n/**\n * Plain-language title for an operation failure code — localized against the active\n * studio-UI locale. A runtime-unknown code (impossible typewise; a cast could smuggle\n * one) degrades to the generic title, never the raw enum.\n */\nexport function operationErrorLabel(code: OperationErrorCode): string {\n const key = OPERATION_ERROR_LABEL_KEYS[code] as string | undefined\n return translate(key ?? OPERATION_ERROR_LABEL_KEYS.unknown)\n}\n\n/** Friendly name for a project export target (`nextjs` → `Next.js`). */\nexport function exportTargetLabel(target: string): string {\n return EXPORT_TARGET_LABELS[target] ?? humanizeName(target)\n}\n\n/** The leaf of a component id, namespace stripped (`base.Heading` → `Heading`). */\nexport function componentLeafId(componentId: string): string {\n const dot = componentId.lastIndexOf('.')\n return dot === -1 ? componentId : componentId.slice(dot + 1)\n}\n\n/**\n * Strip the `base.`/`project.` namespace prefix from a component id for compact,\n * editor-facing labels (`base.Heading` → `Heading`). Unlike {@link componentLeafId}\n * it removes only those two known prefixes, so a dotted custom id keeps its shape.\n */\nexport function shortId(componentId: string): string {\n return componentId.replace('base.', '').replace('project.', '')\n}\n\n/**\n * The human display name of a component TYPE for editor-facing surfaces: the\n * manifest's authored `title` (EC-407: resolved through {@link resolveLabel}, so a\n * `{ en, de }` localized title renders in the active studio-UI locale) when present,\n * else the humanized leaf of its id (`base.Heading` → `Heading`,\n * `project.MarketingHero` → `Marketing hero`). Never the raw namespaced id.\n *\n * `locale` is optional: omit it (the render-site default) to resolve against the\n * active studio-UI locale non-reactively; pass it explicitly for pure/unit callers.\n */\nexport function componentDisplayName(\n componentId: string,\n manifest?: ComponentManifest | undefined,\n locale?: string,\n): string {\n const resolved =\n locale === undefined ? localizedLabel(manifest?.title) : (resolveLabel(manifest?.title, locale) ?? '')\n return resolved || humanizeName(componentLeafId(componentId))\n}\n"]}
@@ -3,8 +3,11 @@ import type { OperationEvent, OperationEventBus } from '@elytracms/core/operatio
3
3
  * Revalidation webhook dispatcher (EC-146): subscribes to the EC-139
4
4
  * operations event bus and POSTs a signed `{projectId, event, tags,
5
5
  * timestamp}` payload to every configured host revalidate endpoint whenever a
6
- * publish-shaped command succeeds (document upsert/delete/setState, graph
7
- * publish/unpublish). The host's `createRevalidateRoute` (`@elytracms/next`)
6
+ * command that can change **published** output succeeds (document
7
+ * publish/unpublish/pinVersion/delete, editorial redirect save/remove, plus
8
+ * asset edits — EC-501 narrowed this from "any document write": a draft save
9
+ * cannot move published state, so it fires nothing; EC-510 added the redirect
10
+ * writes, which plainly can). The host's `createRevalidateRoute` (`@elytracms/next`)
8
11
  * verifies the HMAC and maps the tags onto `revalidateTag`.
9
12
  *
10
13
  * This runs **wherever the operations client runs** — in the v1 studio that
@@ -1 +1 @@
1
- {"version":3,"file":"dispatcher.js","sourceRoot":"","sources":["../../../src/lib/webhooks/dispatcher.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,wBAAwB,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AACrE,OAAO,EAAE,mBAAmB,EAAE,MAAM,QAAQ,CAAA;AAkE5C,KAAK,UAAU,OAAO,CACpB,QAAyB,EACzB,MAAqB,EACrB,SAAiB,EACjB,OAAqB;IAErB,MAAM,OAAO,GAA6B;QACxC,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,IAAI,EAAE,MAAM,CAAC,IAAI;QACjB,SAAS;KACV,CAAA;IACD,+CAA+C;IAC/C,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAA;IACvC,MAAM,SAAS,GAAG,MAAM,eAAe,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAA;IACjE,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,QAAQ,CAAC,GAAG,EAAE;QAC3C,MAAM,EAAE,MAAM;QACd,OAAO,EAAE;YACP,cAAc,EAAE,kBAAkB;YAClC,CAAC,wBAAwB,CAAC,EAAE,SAAS;SACtC;QACD,IAAI,EAAE,OAAO;KACd,CAAC,CAAA;IACF,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CAAC,2BAA2B,QAAQ,CAAC,GAAG,aAAa,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAA;IACzF,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,0BAA0B,CACxC,MAAyB,EACzB,OAAiC;IAEjC,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,KAAK,CAAA;IACxC,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,CAAA;IAE7C,IAAI,OAAO,GAAkB,OAAO,CAAC,OAAO,EAAE,CAAA;IAE9C,MAAM,QAAQ,GAAG,CAAC,KAAqB,EAAQ,EAAE;QAC/C,IAAI,OAAO,CAAC,SAAS,CAAC,MAAM,KAAK,CAAC;YAAE,OAAM;QAC1C,MAAM,MAAM,GAAG,mBAAmB,CAAC,KAAK,EAAE,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAA;QACvE,IAAI,CAAC,MAAM;YAAE,OAAM;QACnB,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC,WAAW,EAAE,CAAA;QACrC,KAAK,MAAM,QAAQ,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;YACzC,OAAO,GAAG,OAAO;iBACd,IAAI,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC;iBACzD,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;gBACxB,OAAO,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,GAAG,EAAE,QAAQ,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,CAAA;YACxD,CAAC,CAAC,CAAA;QACN,CAAC;IACH,CAAC,CAAA;IAED,MAAM,WAAW,GAAG,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAA;IAC9C,OAAO;QACL,MAAM,EAAE,WAAW;QACnB,KAAK,EAAE,KAAK,IAAI,EAAE;YAChB,4DAA4D;YAC5D,IAAI,OAAsB,CAAA;YAC1B,GAAG,CAAC;gBACF,OAAO,GAAG,OAAO,CAAA;gBACjB,MAAM,OAAO,CAAA;YACf,CAAC,QAAQ,OAAO,KAAK,OAAO,EAAC;QAC/B,CAAC;KACF,CAAA;AACH,CAAC","sourcesContent":["import type { OperationEvent, OperationEventBus } from '@elytracms/core/operations'\nimport { WEBHOOK_SIGNATURE_HEADER, signWebhookBody } from './signing'\nimport { webhookTagsForEvent } from './tags'\nimport type { WebhookTagSet } from './tags'\n\n/**\n * Revalidation webhook dispatcher (EC-146): subscribes to the EC-139\n * operations event bus and POSTs a signed `{projectId, event, tags,\n * timestamp}` payload to every configured host revalidate endpoint whenever a\n * publish-shaped command succeeds (document upsert/delete/setState, graph\n * publish/unpublish). The host's `createRevalidateRoute` (`@elytracms/next`)\n * verifies the HMAC and maps the tags onto `revalidateTag`.\n *\n * This runs **wherever the operations client runs** — in the v1 studio that\n * is the browser, so deliveries are cross-origin `fetch` calls. CORS\n * implications (documented honestly):\n *\n * - the custom signature header always triggers a preflight, so the host\n * endpoint must answer `OPTIONS` and allow the studio origin —\n * `createRevalidateRoute({ allowOrigin })` does both;\n * - the shared secret necessarily ships in the studio bundle in this mode.\n * Acceptable for v1 (the secret only gates cache invalidation, never\n * content access); the server-side Convex action variant\n * (`apps/builder/convex/webhooks.ts`) keeps the secret out of the browser\n * for Convex-backed deployments.\n *\n * Deliveries are fire-and-forget on a serial queue (same pattern as the\n * EC-155 reference index): a slow or unreachable host never blocks the\n * command pipeline, failures surface through `onError` only, and tests await\n * `flush()` for determinism.\n */\n\nexport interface WebhookEndpoint {\n /** Absolute URL of the host's revalidate route, e.g. `https://site/api/revalidate`. */\n url: string\n /** Shared HMAC secret for this endpoint. */\n secret: string\n}\n\nexport interface WebhookDispatcherOptions {\n endpoints: readonly WebhookEndpoint[]\n /**\n * All locales the project serves. Document events sweep the collection\n * membership tag once per locale (`collection:<name>:<locale>`).\n */\n locales: readonly string[]\n /** Injectable transport (tests); defaults to global `fetch`. */\n fetchFn?: typeof fetch\n /** Injectable clock (tests); payload `timestamp` is `now().toISOString()`. */\n now?: () => Date\n /** Delivery failures land here (never thrown into the command pipeline). */\n onError?: (error: unknown, context: { url: string; event: OperationEvent }) => void\n}\n\nexport interface WebhookDispatcherAttachment {\n detach(): void\n /** Await all queued deliveries (tests / shutdown). */\n flush(): Promise<void>\n}\n\n/** The signed payload shape (mirror of `revalidatePayloadSchema` in @elytracms/next). */\nexport interface RevalidateWebhookPayload {\n projectId: string\n event: string\n tags: string[]\n timestamp: string\n}\n\nasync function deliver(\n endpoint: WebhookEndpoint,\n tagSet: WebhookTagSet,\n timestamp: string,\n fetchFn: typeof fetch,\n): Promise<void> {\n const payload: RevalidateWebhookPayload = {\n projectId: tagSet.projectId,\n event: tagSet.event,\n tags: tagSet.tags,\n timestamp,\n }\n // Sign the exact string that goes on the wire.\n const rawBody = JSON.stringify(payload)\n const signature = await signWebhookBody(rawBody, endpoint.secret)\n const response = await fetchFn(endpoint.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n [WEBHOOK_SIGNATURE_HEADER]: signature,\n },\n body: rawBody,\n })\n if (!response.ok) {\n throw new Error(`Revalidation webhook to ${endpoint.url} answered ${response.status}.`)\n }\n}\n\n/**\n * Attach the dispatcher to an event bus; returns a detach/flush handle.\n * With no endpoints configured this still subscribes but never queues work.\n */\nexport function attachRevalidationWebhooks(\n events: OperationEventBus,\n options: WebhookDispatcherOptions,\n): WebhookDispatcherAttachment {\n const fetchFn = options.fetchFn ?? fetch\n const now = options.now ?? (() => new Date())\n\n let pending: Promise<void> = Promise.resolve()\n\n const listener = (event: OperationEvent): void => {\n if (options.endpoints.length === 0) return\n const tagSet = webhookTagsForEvent(event, { locales: options.locales })\n if (!tagSet) return\n const timestamp = now().toISOString()\n for (const endpoint of options.endpoints) {\n pending = pending\n .then(() => deliver(endpoint, tagSet, timestamp, fetchFn))\n .catch((error: unknown) => {\n options.onError?.(error, { url: endpoint.url, event })\n })\n }\n }\n\n const unsubscribe = events.subscribe(listener)\n return {\n detach: unsubscribe,\n flush: async () => {\n // Drain until no new deliveries were queued while awaiting.\n let current: Promise<void>\n do {\n current = pending\n await current\n } while (current !== pending)\n },\n }\n}\n"]}
1
+ {"version":3,"file":"dispatcher.js","sourceRoot":"","sources":["../../../src/lib/webhooks/dispatcher.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,wBAAwB,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AACrE,OAAO,EAAE,mBAAmB,EAAE,MAAM,QAAQ,CAAA;AAqE5C,KAAK,UAAU,OAAO,CACpB,QAAyB,EACzB,MAAqB,EACrB,SAAiB,EACjB,OAAqB;IAErB,MAAM,OAAO,GAA6B;QACxC,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,IAAI,EAAE,MAAM,CAAC,IAAI;QACjB,SAAS;KACV,CAAA;IACD,+CAA+C;IAC/C,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAA;IACvC,MAAM,SAAS,GAAG,MAAM,eAAe,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAA;IACjE,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,QAAQ,CAAC,GAAG,EAAE;QAC3C,MAAM,EAAE,MAAM;QACd,OAAO,EAAE;YACP,cAAc,EAAE,kBAAkB;YAClC,CAAC,wBAAwB,CAAC,EAAE,SAAS;SACtC;QACD,IAAI,EAAE,OAAO;KACd,CAAC,CAAA;IACF,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CAAC,2BAA2B,QAAQ,CAAC,GAAG,aAAa,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAA;IACzF,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,0BAA0B,CACxC,MAAyB,EACzB,OAAiC;IAEjC,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,KAAK,CAAA;IACxC,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,CAAA;IAE7C,IAAI,OAAO,GAAkB,OAAO,CAAC,OAAO,EAAE,CAAA;IAE9C,MAAM,QAAQ,GAAG,CAAC,KAAqB,EAAQ,EAAE;QAC/C,IAAI,OAAO,CAAC,SAAS,CAAC,MAAM,KAAK,CAAC;YAAE,OAAM;QAC1C,MAAM,MAAM,GAAG,mBAAmB,CAAC,KAAK,EAAE,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAA;QACvE,IAAI,CAAC,MAAM;YAAE,OAAM;QACnB,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC,WAAW,EAAE,CAAA;QACrC,KAAK,MAAM,QAAQ,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;YACzC,OAAO,GAAG,OAAO;iBACd,IAAI,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC;iBACzD,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;gBACxB,OAAO,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,GAAG,EAAE,QAAQ,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,CAAA;YACxD,CAAC,CAAC,CAAA;QACN,CAAC;IACH,CAAC,CAAA;IAED,MAAM,WAAW,GAAG,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAA;IAC9C,OAAO;QACL,MAAM,EAAE,WAAW;QACnB,KAAK,EAAE,KAAK,IAAI,EAAE;YAChB,4DAA4D;YAC5D,IAAI,OAAsB,CAAA;YAC1B,GAAG,CAAC;gBACF,OAAO,GAAG,OAAO,CAAA;gBACjB,MAAM,OAAO,CAAA;YACf,CAAC,QAAQ,OAAO,KAAK,OAAO,EAAC;QAC/B,CAAC;KACF,CAAA;AACH,CAAC","sourcesContent":["import type { OperationEvent, OperationEventBus } from '@elytracms/core/operations'\nimport { WEBHOOK_SIGNATURE_HEADER, signWebhookBody } from './signing'\nimport { webhookTagsForEvent } from './tags'\nimport type { WebhookTagSet } from './tags'\n\n/**\n * Revalidation webhook dispatcher (EC-146): subscribes to the EC-139\n * operations event bus and POSTs a signed `{projectId, event, tags,\n * timestamp}` payload to every configured host revalidate endpoint whenever a\n * command that can change **published** output succeeds (document\n * publish/unpublish/pinVersion/delete, editorial redirect save/remove, plus\n * asset edits — EC-501 narrowed this from \"any document write\": a draft save\n * cannot move published state, so it fires nothing; EC-510 added the redirect\n * writes, which plainly can). The host's `createRevalidateRoute` (`@elytracms/next`)\n * verifies the HMAC and maps the tags onto `revalidateTag`.\n *\n * This runs **wherever the operations client runs** — in the v1 studio that\n * is the browser, so deliveries are cross-origin `fetch` calls. CORS\n * implications (documented honestly):\n *\n * - the custom signature header always triggers a preflight, so the host\n * endpoint must answer `OPTIONS` and allow the studio origin —\n * `createRevalidateRoute({ allowOrigin })` does both;\n * - the shared secret necessarily ships in the studio bundle in this mode.\n * Acceptable for v1 (the secret only gates cache invalidation, never\n * content access); the server-side Convex action variant\n * (`apps/builder/convex/webhooks.ts`) keeps the secret out of the browser\n * for Convex-backed deployments.\n *\n * Deliveries are fire-and-forget on a serial queue (same pattern as the\n * EC-155 reference index): a slow or unreachable host never blocks the\n * command pipeline, failures surface through `onError` only, and tests await\n * `flush()` for determinism.\n */\n\nexport interface WebhookEndpoint {\n /** Absolute URL of the host's revalidate route, e.g. `https://site/api/revalidate`. */\n url: string\n /** Shared HMAC secret for this endpoint. */\n secret: string\n}\n\nexport interface WebhookDispatcherOptions {\n endpoints: readonly WebhookEndpoint[]\n /**\n * All locales the project serves. Document events sweep the collection\n * membership tag once per locale (`collection:<name>:<locale>`).\n */\n locales: readonly string[]\n /** Injectable transport (tests); defaults to global `fetch`. */\n fetchFn?: typeof fetch\n /** Injectable clock (tests); payload `timestamp` is `now().toISOString()`. */\n now?: () => Date\n /** Delivery failures land here (never thrown into the command pipeline). */\n onError?: (error: unknown, context: { url: string; event: OperationEvent }) => void\n}\n\nexport interface WebhookDispatcherAttachment {\n detach(): void\n /** Await all queued deliveries (tests / shutdown). */\n flush(): Promise<void>\n}\n\n/** The signed payload shape (mirror of `revalidatePayloadSchema` in @elytracms/next). */\nexport interface RevalidateWebhookPayload {\n projectId: string\n event: string\n tags: string[]\n timestamp: string\n}\n\nasync function deliver(\n endpoint: WebhookEndpoint,\n tagSet: WebhookTagSet,\n timestamp: string,\n fetchFn: typeof fetch,\n): Promise<void> {\n const payload: RevalidateWebhookPayload = {\n projectId: tagSet.projectId,\n event: tagSet.event,\n tags: tagSet.tags,\n timestamp,\n }\n // Sign the exact string that goes on the wire.\n const rawBody = JSON.stringify(payload)\n const signature = await signWebhookBody(rawBody, endpoint.secret)\n const response = await fetchFn(endpoint.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n [WEBHOOK_SIGNATURE_HEADER]: signature,\n },\n body: rawBody,\n })\n if (!response.ok) {\n throw new Error(`Revalidation webhook to ${endpoint.url} answered ${response.status}.`)\n }\n}\n\n/**\n * Attach the dispatcher to an event bus; returns a detach/flush handle.\n * With no endpoints configured this still subscribes but never queues work.\n */\nexport function attachRevalidationWebhooks(\n events: OperationEventBus,\n options: WebhookDispatcherOptions,\n): WebhookDispatcherAttachment {\n const fetchFn = options.fetchFn ?? fetch\n const now = options.now ?? (() => new Date())\n\n let pending: Promise<void> = Promise.resolve()\n\n const listener = (event: OperationEvent): void => {\n if (options.endpoints.length === 0) return\n const tagSet = webhookTagsForEvent(event, { locales: options.locales })\n if (!tagSet) return\n const timestamp = now().toISOString()\n for (const endpoint of options.endpoints) {\n pending = pending\n .then(() => deliver(endpoint, tagSet, timestamp, fetchFn))\n .catch((error: unknown) => {\n options.onError?.(error, { url: endpoint.url, event })\n })\n }\n }\n\n const unsubscribe = events.subscribe(listener)\n return {\n detach: unsubscribe,\n flush: async () => {\n // Drain until no new deliveries were queued while awaiting.\n let current: Promise<void>\n do {\n current = pending\n await current\n } while (current !== pending)\n },\n }\n}\n"]}
@@ -16,23 +16,41 @@
16
16
  *
17
17
  * ## Which events produce which tags
18
18
  *
19
- * - **Document publish/unpublish/delete/save** (`documents.publish`,
20
- * `documents.unpublish`, `documents.pinVersion`, `documents.upsert`,
21
- * `documents.delete`): the document's own
19
+ * The gate is **"can this operation change PUBLISHED output?"** — not "did this
20
+ * operation touch a document" (EC-501). Live delivery is published-only, so an
21
+ * operation that cannot move published state has no cache entry to drop and
22
+ * every tag it emits is waste by construction.
23
+ *
24
+ * - **Document publish/unpublish/pin/delete** (`documents.publish`,
25
+ * `documents.unpublish`, `documents.pinVersion`, `documents.delete`): the
26
+ * document's own
22
27
  * `doc:<collection>:<id>` tag — dropping its detail page *and every cached
23
28
  * list that actually contained it* (fetch-time tagging) — plus the coarse
24
29
  * `collection:<name>:<locale>` membership tag for **all** configured
25
30
  * locales, sweeping cached lists whose membership may have changed
26
31
  * (new/removed/newly-matching documents).
32
+ * - **Document SAVE** (`documents.upsert`) fires **nothing** — see
33
+ * {@link PUBLISHED_STATE_OPERATIONS}.
34
+ * - **Editorial redirect write** (`redirects.save`, `redirects.remove`,
35
+ * EC-510): the whole-project sweep tag — deliberately coarse, see
36
+ * {@link ROUTING_OPERATIONS}.
27
37
  * - **Asset edit** (`assets.upsert` of an existing asset, `assets.remove`,
28
38
  * EC-227): the `asset:<id>` tag, dropping exactly the pages that *baked* that
29
39
  * asset's delivery shape (alt/dimensions/url). An `assets.upsert` CREATE (no
30
40
  * id in the summary — minted server-side) fires nothing: a brand-new asset is
31
41
  * in no cached page yet.
32
42
  * - **Fallback**: when a `documents` (or `assets.remove`) event's input summary
33
- * was truncated past the identity (EC-139 caps summaries at 160 chars; only
34
- * very large upserts hit this), the project sweep tag is emitted — coarse,
35
- * always correct, mirroring the EC-155 reference-index fallback.
43
+ * was truncated past the identity (EC-139 caps summaries at 160 chars), the
44
+ * project sweep tag is emitted — coarse, always correct, mirroring the EC-155
45
+ * reference-index fallback. Since EC-501 the surviving document operations
46
+ * all carry a small `{ projectId, ref }` input, so this is a defensive path
47
+ * rather than a routine one — the routine truncation was the document-bearing
48
+ * `documents.upsert`, which no longer reaches here at all.
49
+ *
50
+ * Every branch needs only the `projectId`, which serializes FIRST in every
51
+ * operation input (measured 2026-08-01 across the real `redirects.save`
52
+ * shapes), so {@link projectIdOfSummary}'s regex survives truncation even when
53
+ * the rest of the summary is cut mid-string.
36
54
  */
37
55
  function scoped(projectId, tag) {
38
56
  return `p:${projectId}:${tag}`;
@@ -53,17 +71,125 @@ export function assetWebhookTag(projectId, assetId) {
53
71
  export function projectSweepWebhookTag(projectId) {
54
72
  return scoped(projectId, 'project');
55
73
  }
56
- /** The operations whose success must reach the host's revalidate endpoint. */
57
- const DOCUMENT_OPERATIONS = new Set([
58
- 'documents.upsert',
74
+ /**
75
+ * The document operations that can change **published** output, and therefore
76
+ * the ones whose success must reach the host's revalidate endpoint.
77
+ *
78
+ * EC-224: publish/unpublish/pinVersion move the version pointer; their
79
+ * `{ projectId, ref }` input drives the doc + per-locale collection tag set
80
+ * (the flat `documents.setState` op is gone). `documents.delete` removes the
81
+ * row outright, taking any pinned version with it.
82
+ *
83
+ * ## Why `documents.upsert` (a draft SAVE) is NOT here — EC-501
84
+ *
85
+ * A save writes the **draft row only**; published readers serve the *pinned
86
+ * version snapshot*, never the draft. That is structural, not a heuristic:
87
+ *
88
+ * - the operation hands the document straight to `cms.upsertDocument`
89
+ * (`core/operations/documents.ts`), whose Convex arg list has no
90
+ * `publishedVersion` field at all (`studio/convex/cms.ts` `upsertDocument`),
91
+ * and whose in-memory twin re-attaches the previous pin verbatim with the
92
+ * comment "only publish/unpublish move the pointer"
93
+ * (`core/persistence/cms.ts`);
94
+ * - the published delivery snapshot skips every row without a pin and serves
95
+ * the pinned snapshot, "never falling back to the draft"
96
+ * (`studio/convex/delivery.ts`);
97
+ * - the Convex write-chokepoint trigger already encodes exactly this verdict
98
+ * server-side — it schedules a revalidation only when `publishedVersion`
99
+ * actually changed, "a plain draft save must not invalidate the published
100
+ * host cache" (`studio/convex/triggers.ts`, EC-286/EC-146). This emitter was
101
+ * simply the one path that never asked.
102
+ *
103
+ * Measured before the change (connected studio → capture endpoint, one short
104
+ * editing session): 12 `documents.upsert` deliveries to 1 `documents.publish`,
105
+ * and 7 of those 12 carried the **whole-project sweep** tag rather than the
106
+ * document's own — because a real document's `inputSummary` exceeds the EC-139
107
+ * 160-char cap, so the identity parse below fails and falls back to the sweep.
108
+ * A single keystroke-batch on a live page was dropping every cached page *and*
109
+ * every cached 404 on the site (EC-489 widened 404s to carry collection tags).
110
+ *
111
+ * ## The one documented exception (deliberate, not an oversight)
112
+ *
113
+ * `cms-data-sources` rows are the sole document class the published delivery
114
+ * snapshot reads from the **draft** side, on purpose — data sources are project
115
+ * config, so published bindings must resolve even when the defining row is not
116
+ * itself published (`studio/convex/delivery.ts`). A save to that system
117
+ * collection therefore *can* change published output and is now silent.
118
+ * Accepted because: (a) it is a retired-builder remnant (EC-166/167) with
119
+ * **zero writers** left in the studio — `provisionSource`/`removeSource` have
120
+ * no call sites; (b) it only ever invalidated *by accident*, through the
121
+ * truncated-summary sweep fallback, never through a precise tag; and (c) a
122
+ * collection-scoped carve-out cannot work here — the collection name is exactly
123
+ * what the truncated summary loses. If that data layer is ever revived, the fix
124
+ * belongs at the delivery trust boundary (serve sources published-only), not by
125
+ * re-broadening this set.
126
+ */
127
+ const PUBLISHED_STATE_OPERATIONS = new Set([
59
128
  'documents.delete',
60
- // EC-224: publish/unpublish/pinVersion move the version pointer; their
61
- // `{ projectId, ref }` input drives the same doc + per-locale collection tag
62
- // set (the flat `documents.setState` op is gone).
63
129
  'documents.publish',
64
130
  'documents.unpublish',
65
131
  'documents.pinVersion',
66
132
  ]);
133
+ /**
134
+ * The **routing** operations (EC-510): editorial redirects, which EC-303 moved
135
+ * out of config and into the database. Every *enabled* redirect row is
136
+ * projected into the delivery snapshot the router and `resolvePage` consume
137
+ * (`studio/convex/delivery.ts`), in BOTH perspectives — so saving, retargeting,
138
+ * disabling or deleting one changes published output, exactly the question
139
+ * {@link PUBLISHED_STATE_OPERATIONS} asks. They belong to that question, not a
140
+ * parallel one; they are a separate set only because their tag is different.
141
+ *
142
+ * ## Why the coarse project sweep and NOT a targeted `route:<from>:<locale>`
143
+ *
144
+ * The tag vocabulary has `route:<pattern>:<locale>` and a redirect's `from` IS
145
+ * a route path, so a targeted tag looks available. It is not — it would be
146
+ * silently wrong in four independent ways, and a silent wrong invalidation is
147
+ * the exact failure family EC-489/EC-501/this ticket exist to remove:
148
+ *
149
+ * 1. **`redirects.remove` has no `from` at all.** Its input is
150
+ * `{ projectId, id }` (`core/operations/redirects.ts`), so the path whose
151
+ * cache entry must drop is not in the event. Precision is unobtainable, and
152
+ * a delete is the single most important case to get right.
153
+ * 2. **A save can RENAME `from`.** The event carries only the NEW path; the OLD
154
+ * one keeps serving a redirect that no longer exists. `OperationEvent` has
155
+ * `operation`/`kind`/`status`/`inputSummary` and nothing else — no result,
156
+ * no previous row (EC-501 rejected widening it for the same reason) — so the
157
+ * emitter cannot even detect that a rename happened.
158
+ * 3. **Normalization would have to be mirrored.** The cached entry is tagged
159
+ * with the resolver's `normalizePath(url)`; `from` is editor-typed free text
160
+ * (`z.string().min(1)`), stored verbatim and normalized only at match time.
161
+ * A trailing slash or a query string would produce a tag that matches
162
+ * nothing — no error, no invalidation.
163
+ * 4. **The identity truncates.** MEASURED 2026-08-01 on the real schema: a
164
+ * Konditorei-shaped migration redirect (legacy permalink → new path)
165
+ * serializes to 226 chars and an EC-311 `meta` bag to 216, both past the
166
+ * EC-139 160-char cap. A `from`-based tag would work on a demo-sized
167
+ * redirect and degrade on the real one — EC-501's pathology in reverse.
168
+ *
169
+ * The sweep has none of those failure modes: it needs only `projectId`, which
170
+ * serializes first and therefore survives truncation, so **the truncation
171
+ * fallback and the primary answer are the same tag** — correct by design rather
172
+ * than by accident. `nextCacheTags` stamps `project` on every cached entry, so
173
+ * it is guaranteed to reach the affected URL whatever the redirect changed.
174
+ *
175
+ * ## The cost, and why it is affordable here (unlike EC-501)
176
+ *
177
+ * A sweep drops every cached entry, lazily on next read. That was intolerable
178
+ * for `documents.upsert` because it fired on every ~1s keystroke batch and
179
+ * *none* of those invalidations were necessary. A redirect write is an
180
+ * explicit form save on a rail with `inlineEdit: false` and no autosave
181
+ * (`redirects-collection-view-source.ts`), and every one of them IS a routing
182
+ * change. Measured on a connected studio: a session that deliberately exercised
183
+ * every mutation kind — create, retarget, disable, rename, delete — produced 5
184
+ * writes. The Convex-side path already sweeps the project on every document
185
+ * publish (`convex/revalidate.ts`, "the honest, reliable v1"), so this is
186
+ * strictly rarer than a cost the engine already pays.
187
+ *
188
+ * If a precise redirect tag is ever wanted, the place to get it is the Convex
189
+ * write chokepoint, where `change.oldDoc` holds the previous `from` — not here,
190
+ * where the information does not exist.
191
+ */
192
+ const ROUTING_OPERATIONS = new Set(['redirects.save', 'redirects.remove']);
67
193
  /**
68
194
  * Asset-record mutations (EC-227): editing an asset (alt/dimensions/re-upload)
69
195
  * must drop the published pages that *baked* its delivery shape, which carry the
@@ -107,13 +233,19 @@ function documentRefOf(value) {
107
233
  export function webhookTagsForEvent(event, options) {
108
234
  if (event.status !== 'succeeded' || event.kind !== 'command')
109
235
  return null;
110
- const isDocument = DOCUMENT_OPERATIONS.has(event.operation);
236
+ const isDocument = PUBLISHED_STATE_OPERATIONS.has(event.operation);
111
237
  const isAsset = ASSET_OPERATIONS.has(event.operation);
112
- if (!isDocument && !isAsset)
238
+ const isRouting = ROUTING_OPERATIONS.has(event.operation);
239
+ if (!isDocument && !isAsset && !isRouting)
113
240
  return null;
114
241
  const projectId = projectIdOfSummary(event.inputSummary);
115
242
  if (projectId === undefined)
116
243
  return null;
244
+ // EC-510: one answer for every redirect write — see `ROUTING_OPERATIONS` for
245
+ // why the targeted `route:` tag is unobtainable and the sweep is the honest one.
246
+ if (isRouting) {
247
+ return { projectId, event: event.operation, tags: [projectSweepWebhookTag(projectId)] };
248
+ }
117
249
  if (isAsset)
118
250
  return assetTagsForEvent(projectId, event);
119
251
  const parsed = parseSummary(event.inputSummary);
@@ -126,9 +258,10 @@ export function webhookTagsForEvent(event, options) {
126
258
  for (const locale of options.locales) {
127
259
  tags.add(collectionWebhookTag(projectId, ref.collection, locale));
128
260
  }
129
- // EC-207: routes/redirects are config-owned (a code deploy, which rebuilds the
130
- // host) — they are no longer DB documents, so there is no route-document event
131
- // to sweep the project for.
261
+ // EC-207: ROUTES are config-owned (a code deploy, which rebuilds the host), so
262
+ // a document event never needs a `route:` tag. Redirects are NOT — EC-303 made
263
+ // them editorial data and EC-510 gives their own operations the project sweep
264
+ // (see `ROUTING_OPERATIONS`); a *document* write still cannot change them.
132
265
  return { projectId, event: event.operation, tags: [...tags].sort() };
133
266
  }
134
267
  /** A non-empty string, else `undefined` (the asset id out of a summary). */
@@ -1 +1 @@
1
- {"version":3,"file":"tags.js","sourceRoot":"","sources":["../../../src/lib/webhooks/tags.ts"],"names":[],"mappings":"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","sourcesContent":["import type { OperationEvent } from '@elytracms/core/operations'\n\n/**\n * Pure tag computation for the EC-146 revalidation webhooks: map a succeeded\n * publish-shaped operation event onto the Next cache tags it invalidates.\n *\n * ## Tag format (mirror of `packages/next/src/cache.ts`)\n *\n * The canonical tag format lives in `@elytracms/next` (`src/cache.ts`). The\n * builder cannot depend on that package — it would drag the `next` peer\n * dependency into the studio bundle — so the three formatters are mirrored\n * here. Both sides pin the exact literal strings in their tests\n * (`tags.test.ts` here, `cache.test.ts` there) to keep them in lock-step.\n *\n * Every emitted tag is scoped `p:<projectId>:…` — the dispatcher always knows\n * the project id, and the scope keeps multi-project hosts from\n * cross-invalidating. Hosts must configure the same id as `cache.scope`.\n *\n * ## Which events produce which tags\n *\n * - **Document publish/unpublish/delete/save** (`documents.publish`,\n * `documents.unpublish`, `documents.pinVersion`, `documents.upsert`,\n * `documents.delete`): the document's own\n * `doc:<collection>:<id>` tag — dropping its detail page *and every cached\n * list that actually contained it* (fetch-time tagging) — plus the coarse\n * `collection:<name>:<locale>` membership tag for **all** configured\n * locales, sweeping cached lists whose membership may have changed\n * (new/removed/newly-matching documents).\n * - **Asset edit** (`assets.upsert` of an existing asset, `assets.remove`,\n * EC-227): the `asset:<id>` tag, dropping exactly the pages that *baked* that\n * asset's delivery shape (alt/dimensions/url). An `assets.upsert` CREATE (no\n * id in the summary — minted server-side) fires nothing: a brand-new asset is\n * in no cached page yet.\n * - **Fallback**: when a `documents` (or `assets.remove`) event's input summary\n * was truncated past the identity (EC-139 caps summaries at 160 chars; only\n * very large upserts hit this), the project sweep tag is emitted — coarse,\n * always correct, mirroring the EC-155 reference-index fallback.\n */\n\nfunction scoped(projectId: string, tag: string): string {\n return `p:${projectId}:${tag}`\n}\n\n/** `p:<projectId>:doc:<collection>:<id>` — mirror of `docCacheTag`. */\nexport function docWebhookTag(projectId: string, collection: string, id: string): string {\n return scoped(projectId, `doc:${collection}:${id}`)\n}\n\n/** `p:<projectId>:collection:<name>:<locale>` — mirror of `collectionCacheTag`. */\nexport function collectionWebhookTag(\n projectId: string,\n collection: string,\n locale: string,\n): string {\n return scoped(projectId, `collection:${collection}:${locale}`)\n}\n\n/** `p:<projectId>:asset:<id>` — mirror of `assetCacheTag` (EC-227). */\nexport function assetWebhookTag(projectId: string, assetId: string): string {\n return scoped(projectId, `asset:${assetId}`)\n}\n\n/** `p:<projectId>:project` — mirror of `projectSweepTag`. */\nexport function projectSweepWebhookTag(projectId: string): string {\n return scoped(projectId, 'project')\n}\n\n/** The operations whose success must reach the host's revalidate endpoint. */\nconst DOCUMENT_OPERATIONS = new Set([\n 'documents.upsert',\n 'documents.delete',\n // EC-224: publish/unpublish/pinVersion move the version pointer; their\n // `{ projectId, ref }` input drives the same doc + per-locale collection tag\n // set (the flat `documents.setState` op is gone).\n 'documents.publish',\n 'documents.unpublish',\n 'documents.pinVersion',\n])\n/**\n * Asset-record mutations (EC-227): editing an asset (alt/dimensions/re-upload)\n * must drop the published pages that *baked* its delivery shape, which carry the\n * `asset:<id>` tag. `assets.remove` always carries `assetId`; `assets.upsert`\n * carries `asset.id` only on an EDIT (a CREATE mints the id server-side).\n */\nconst ASSET_OPERATIONS = new Set(['assets.upsert', 'assets.remove'])\n\n/** One computed webhook emission: which project, why, and which tags. */\nexport interface WebhookTagSet {\n projectId: string\n /** The operation name, carried verbatim as the webhook `event`. */\n event: string\n /** Sorted, deduplicated, fully scoped Next tag strings. */\n tags: string[]\n}\n\n/** Tolerant parse of an EC-139 input summary (may be truncated past 160 chars). */\nfunction parseSummary(summary: string): Record<string, unknown> | undefined {\n try {\n const parsed: unknown = JSON.parse(summary)\n return typeof parsed === 'object' && parsed !== null\n ? (parsed as Record<string, unknown>)\n : undefined\n } catch {\n return undefined\n }\n}\n\n/** Extract the projectId even from a truncated summary (it serializes first). */\nfunction projectIdOfSummary(summary: string): string | undefined {\n const match = /\"projectId\":\"([^\"]+)\"/.exec(summary)\n return match?.[1]\n}\n\nfunction documentRefOf(value: unknown): { collection: string; id: string } | undefined {\n if (typeof value !== 'object' || value === null) return undefined\n const candidate = value as { collection?: unknown; id?: unknown }\n return typeof candidate.collection === 'string' &&\n candidate.collection.length > 0 &&\n typeof candidate.id === 'string' &&\n candidate.id.length > 0\n ? { collection: candidate.collection, id: candidate.id }\n : undefined\n}\n\n/**\n * Compute the webhook tag set for one lifecycle event, or `null` when the\n * event is not a successful publish-shaped command (queries, failures, and\n * unrelated domains never fire webhooks).\n */\nexport function webhookTagsForEvent(\n event: Pick<OperationEvent, 'operation' | 'kind' | 'status' | 'inputSummary'>,\n options: { locales: readonly string[] },\n): WebhookTagSet | null {\n if (event.status !== 'succeeded' || event.kind !== 'command') return null\n\n const isDocument = DOCUMENT_OPERATIONS.has(event.operation)\n const isAsset = ASSET_OPERATIONS.has(event.operation)\n if (!isDocument && !isAsset) return null\n\n const projectId = projectIdOfSummary(event.inputSummary)\n if (projectId === undefined) return null\n\n if (isAsset) return assetTagsForEvent(projectId, event)\n\n const parsed = parseSummary(event.inputSummary)\n const ref = documentRefOf(parsed?.['ref']) ?? documentRefOf(parsed?.['document'])\n if (!ref) {\n // Truncated summary (large upsert payload): honest coarse fallback.\n return { projectId, event: event.operation, tags: [projectSweepWebhookTag(projectId)] }\n }\n\n const tags = new Set<string>([docWebhookTag(projectId, ref.collection, ref.id)])\n for (const locale of options.locales) {\n tags.add(collectionWebhookTag(projectId, ref.collection, locale))\n }\n // EC-207: routes/redirects are config-owned (a code deploy, which rebuilds the\n // host) — they are no longer DB documents, so there is no route-document event\n // to sweep the project for.\n return { projectId, event: event.operation, tags: [...tags].sort() }\n}\n\n/** A non-empty string, else `undefined` (the asset id out of a summary). */\nfunction stringOf(value: unknown): string | undefined {\n return typeof value === 'string' && value.length > 0 ? value : undefined\n}\n\n/**\n * Tags for an asset mutation (EC-227). The edit cases — `assets.remove` (carries\n * `assetId`) and `assets.upsert` of an existing asset (carries `asset.id`) —\n * emit exactly `asset:<id>`, dropping the pages that baked that asset. An\n * `assets.upsert` with **no** id is a CREATE: the id is minted server-side and a\n * brand-new asset cannot be baked into any existing page, so it invalidates\n * nothing (`null`, no webhook). A `remove` missing its id means a truncated\n * summary — sweep the project (coarse, always correct — the document fallback).\n */\nfunction assetTagsForEvent(\n projectId: string,\n event: Pick<OperationEvent, 'operation' | 'inputSummary'>,\n): WebhookTagSet | null {\n const parsed = parseSummary(event.inputSummary)\n const assetId =\n event.operation === 'assets.remove'\n ? stringOf(parsed?.['assetId'])\n : stringOf((parsed?.['asset'] as { id?: unknown } | undefined)?.id)\n if (assetId) {\n return { projectId, event: event.operation, tags: [assetWebhookTag(projectId, assetId)] }\n }\n return event.operation === 'assets.remove'\n ? { projectId, event: event.operation, tags: [projectSweepWebhookTag(projectId)] }\n : null\n}\n"]}
1
+ {"version":3,"file":"tags.js","sourceRoot":"","sources":["../../../src/lib/webhooks/tags.ts"],"names":[],"mappings":"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","sourcesContent":["import type { OperationEvent } from '@elytracms/core/operations'\n\n/**\n * Pure tag computation for the EC-146 revalidation webhooks: map a succeeded\n * publish-shaped operation event onto the Next cache tags it invalidates.\n *\n * ## Tag format (mirror of `packages/next/src/cache.ts`)\n *\n * The canonical tag format lives in `@elytracms/next` (`src/cache.ts`). The\n * builder cannot depend on that package — it would drag the `next` peer\n * dependency into the studio bundle — so the three formatters are mirrored\n * here. Both sides pin the exact literal strings in their tests\n * (`tags.test.ts` here, `cache.test.ts` there) to keep them in lock-step.\n *\n * Every emitted tag is scoped `p:<projectId>:…` — the dispatcher always knows\n * the project id, and the scope keeps multi-project hosts from\n * cross-invalidating. Hosts must configure the same id as `cache.scope`.\n *\n * ## Which events produce which tags\n *\n * The gate is **\"can this operation change PUBLISHED output?\"** — not \"did this\n * operation touch a document\" (EC-501). Live delivery is published-only, so an\n * operation that cannot move published state has no cache entry to drop and\n * every tag it emits is waste by construction.\n *\n * - **Document publish/unpublish/pin/delete** (`documents.publish`,\n * `documents.unpublish`, `documents.pinVersion`, `documents.delete`): the\n * document's own\n * `doc:<collection>:<id>` tag — dropping its detail page *and every cached\n * list that actually contained it* (fetch-time tagging) — plus the coarse\n * `collection:<name>:<locale>` membership tag for **all** configured\n * locales, sweeping cached lists whose membership may have changed\n * (new/removed/newly-matching documents).\n * - **Document SAVE** (`documents.upsert`) fires **nothing** — see\n * {@link PUBLISHED_STATE_OPERATIONS}.\n * - **Editorial redirect write** (`redirects.save`, `redirects.remove`,\n * EC-510): the whole-project sweep tag — deliberately coarse, see\n * {@link ROUTING_OPERATIONS}.\n * - **Asset edit** (`assets.upsert` of an existing asset, `assets.remove`,\n * EC-227): the `asset:<id>` tag, dropping exactly the pages that *baked* that\n * asset's delivery shape (alt/dimensions/url). An `assets.upsert` CREATE (no\n * id in the summary — minted server-side) fires nothing: a brand-new asset is\n * in no cached page yet.\n * - **Fallback**: when a `documents` (or `assets.remove`) event's input summary\n * was truncated past the identity (EC-139 caps summaries at 160 chars), the\n * project sweep tag is emitted — coarse, always correct, mirroring the EC-155\n * reference-index fallback. Since EC-501 the surviving document operations\n * all carry a small `{ projectId, ref }` input, so this is a defensive path\n * rather than a routine one — the routine truncation was the document-bearing\n * `documents.upsert`, which no longer reaches here at all.\n *\n * Every branch needs only the `projectId`, which serializes FIRST in every\n * operation input (measured 2026-08-01 across the real `redirects.save`\n * shapes), so {@link projectIdOfSummary}'s regex survives truncation even when\n * the rest of the summary is cut mid-string.\n */\n\nfunction scoped(projectId: string, tag: string): string {\n return `p:${projectId}:${tag}`\n}\n\n/** `p:<projectId>:doc:<collection>:<id>` — mirror of `docCacheTag`. */\nexport function docWebhookTag(projectId: string, collection: string, id: string): string {\n return scoped(projectId, `doc:${collection}:${id}`)\n}\n\n/** `p:<projectId>:collection:<name>:<locale>` — mirror of `collectionCacheTag`. */\nexport function collectionWebhookTag(\n projectId: string,\n collection: string,\n locale: string,\n): string {\n return scoped(projectId, `collection:${collection}:${locale}`)\n}\n\n/** `p:<projectId>:asset:<id>` — mirror of `assetCacheTag` (EC-227). */\nexport function assetWebhookTag(projectId: string, assetId: string): string {\n return scoped(projectId, `asset:${assetId}`)\n}\n\n/** `p:<projectId>:project` — mirror of `projectSweepTag`. */\nexport function projectSweepWebhookTag(projectId: string): string {\n return scoped(projectId, 'project')\n}\n\n/**\n * The document operations that can change **published** output, and therefore\n * the ones whose success must reach the host's revalidate endpoint.\n *\n * EC-224: publish/unpublish/pinVersion move the version pointer; their\n * `{ projectId, ref }` input drives the doc + per-locale collection tag set\n * (the flat `documents.setState` op is gone). `documents.delete` removes the\n * row outright, taking any pinned version with it.\n *\n * ## Why `documents.upsert` (a draft SAVE) is NOT here — EC-501\n *\n * A save writes the **draft row only**; published readers serve the *pinned\n * version snapshot*, never the draft. That is structural, not a heuristic:\n *\n * - the operation hands the document straight to `cms.upsertDocument`\n * (`core/operations/documents.ts`), whose Convex arg list has no\n * `publishedVersion` field at all (`studio/convex/cms.ts` `upsertDocument`),\n * and whose in-memory twin re-attaches the previous pin verbatim with the\n * comment \"only publish/unpublish move the pointer\"\n * (`core/persistence/cms.ts`);\n * - the published delivery snapshot skips every row without a pin and serves\n * the pinned snapshot, \"never falling back to the draft\"\n * (`studio/convex/delivery.ts`);\n * - the Convex write-chokepoint trigger already encodes exactly this verdict\n * server-side — it schedules a revalidation only when `publishedVersion`\n * actually changed, \"a plain draft save must not invalidate the published\n * host cache\" (`studio/convex/triggers.ts`, EC-286/EC-146). This emitter was\n * simply the one path that never asked.\n *\n * Measured before the change (connected studio → capture endpoint, one short\n * editing session): 12 `documents.upsert` deliveries to 1 `documents.publish`,\n * and 7 of those 12 carried the **whole-project sweep** tag rather than the\n * document's own — because a real document's `inputSummary` exceeds the EC-139\n * 160-char cap, so the identity parse below fails and falls back to the sweep.\n * A single keystroke-batch on a live page was dropping every cached page *and*\n * every cached 404 on the site (EC-489 widened 404s to carry collection tags).\n *\n * ## The one documented exception (deliberate, not an oversight)\n *\n * `cms-data-sources` rows are the sole document class the published delivery\n * snapshot reads from the **draft** side, on purpose — data sources are project\n * config, so published bindings must resolve even when the defining row is not\n * itself published (`studio/convex/delivery.ts`). A save to that system\n * collection therefore *can* change published output and is now silent.\n * Accepted because: (a) it is a retired-builder remnant (EC-166/167) with\n * **zero writers** left in the studio — `provisionSource`/`removeSource` have\n * no call sites; (b) it only ever invalidated *by accident*, through the\n * truncated-summary sweep fallback, never through a precise tag; and (c) a\n * collection-scoped carve-out cannot work here — the collection name is exactly\n * what the truncated summary loses. If that data layer is ever revived, the fix\n * belongs at the delivery trust boundary (serve sources published-only), not by\n * re-broadening this set.\n */\nconst PUBLISHED_STATE_OPERATIONS = new Set([\n 'documents.delete',\n 'documents.publish',\n 'documents.unpublish',\n 'documents.pinVersion',\n])\n/**\n * The **routing** operations (EC-510): editorial redirects, which EC-303 moved\n * out of config and into the database. Every *enabled* redirect row is\n * projected into the delivery snapshot the router and `resolvePage` consume\n * (`studio/convex/delivery.ts`), in BOTH perspectives — so saving, retargeting,\n * disabling or deleting one changes published output, exactly the question\n * {@link PUBLISHED_STATE_OPERATIONS} asks. They belong to that question, not a\n * parallel one; they are a separate set only because their tag is different.\n *\n * ## Why the coarse project sweep and NOT a targeted `route:<from>:<locale>`\n *\n * The tag vocabulary has `route:<pattern>:<locale>` and a redirect's `from` IS\n * a route path, so a targeted tag looks available. It is not — it would be\n * silently wrong in four independent ways, and a silent wrong invalidation is\n * the exact failure family EC-489/EC-501/this ticket exist to remove:\n *\n * 1. **`redirects.remove` has no `from` at all.** Its input is\n * `{ projectId, id }` (`core/operations/redirects.ts`), so the path whose\n * cache entry must drop is not in the event. Precision is unobtainable, and\n * a delete is the single most important case to get right.\n * 2. **A save can RENAME `from`.** The event carries only the NEW path; the OLD\n * one keeps serving a redirect that no longer exists. `OperationEvent` has\n * `operation`/`kind`/`status`/`inputSummary` and nothing else — no result,\n * no previous row (EC-501 rejected widening it for the same reason) — so the\n * emitter cannot even detect that a rename happened.\n * 3. **Normalization would have to be mirrored.** The cached entry is tagged\n * with the resolver's `normalizePath(url)`; `from` is editor-typed free text\n * (`z.string().min(1)`), stored verbatim and normalized only at match time.\n * A trailing slash or a query string would produce a tag that matches\n * nothing — no error, no invalidation.\n * 4. **The identity truncates.** MEASURED 2026-08-01 on the real schema: a\n * Konditorei-shaped migration redirect (legacy permalink → new path)\n * serializes to 226 chars and an EC-311 `meta` bag to 216, both past the\n * EC-139 160-char cap. A `from`-based tag would work on a demo-sized\n * redirect and degrade on the real one — EC-501's pathology in reverse.\n *\n * The sweep has none of those failure modes: it needs only `projectId`, which\n * serializes first and therefore survives truncation, so **the truncation\n * fallback and the primary answer are the same tag** — correct by design rather\n * than by accident. `nextCacheTags` stamps `project` on every cached entry, so\n * it is guaranteed to reach the affected URL whatever the redirect changed.\n *\n * ## The cost, and why it is affordable here (unlike EC-501)\n *\n * A sweep drops every cached entry, lazily on next read. That was intolerable\n * for `documents.upsert` because it fired on every ~1s keystroke batch and\n * *none* of those invalidations were necessary. A redirect write is an\n * explicit form save on a rail with `inlineEdit: false` and no autosave\n * (`redirects-collection-view-source.ts`), and every one of them IS a routing\n * change. Measured on a connected studio: a session that deliberately exercised\n * every mutation kind — create, retarget, disable, rename, delete — produced 5\n * writes. The Convex-side path already sweeps the project on every document\n * publish (`convex/revalidate.ts`, \"the honest, reliable v1\"), so this is\n * strictly rarer than a cost the engine already pays.\n *\n * If a precise redirect tag is ever wanted, the place to get it is the Convex\n * write chokepoint, where `change.oldDoc` holds the previous `from` — not here,\n * where the information does not exist.\n */\nconst ROUTING_OPERATIONS = new Set(['redirects.save', 'redirects.remove'])\n\n/**\n * Asset-record mutations (EC-227): editing an asset (alt/dimensions/re-upload)\n * must drop the published pages that *baked* its delivery shape, which carry the\n * `asset:<id>` tag. `assets.remove` always carries `assetId`; `assets.upsert`\n * carries `asset.id` only on an EDIT (a CREATE mints the id server-side).\n */\nconst ASSET_OPERATIONS = new Set(['assets.upsert', 'assets.remove'])\n\n/** One computed webhook emission: which project, why, and which tags. */\nexport interface WebhookTagSet {\n projectId: string\n /** The operation name, carried verbatim as the webhook `event`. */\n event: string\n /** Sorted, deduplicated, fully scoped Next tag strings. */\n tags: string[]\n}\n\n/** Tolerant parse of an EC-139 input summary (may be truncated past 160 chars). */\nfunction parseSummary(summary: string): Record<string, unknown> | undefined {\n try {\n const parsed: unknown = JSON.parse(summary)\n return typeof parsed === 'object' && parsed !== null\n ? (parsed as Record<string, unknown>)\n : undefined\n } catch {\n return undefined\n }\n}\n\n/** Extract the projectId even from a truncated summary (it serializes first). */\nfunction projectIdOfSummary(summary: string): string | undefined {\n const match = /\"projectId\":\"([^\"]+)\"/.exec(summary)\n return match?.[1]\n}\n\nfunction documentRefOf(value: unknown): { collection: string; id: string } | undefined {\n if (typeof value !== 'object' || value === null) return undefined\n const candidate = value as { collection?: unknown; id?: unknown }\n return typeof candidate.collection === 'string' &&\n candidate.collection.length > 0 &&\n typeof candidate.id === 'string' &&\n candidate.id.length > 0\n ? { collection: candidate.collection, id: candidate.id }\n : undefined\n}\n\n/**\n * Compute the webhook tag set for one lifecycle event, or `null` when the\n * event is not a successful publish-shaped command (queries, failures, and\n * unrelated domains never fire webhooks).\n */\nexport function webhookTagsForEvent(\n event: Pick<OperationEvent, 'operation' | 'kind' | 'status' | 'inputSummary'>,\n options: { locales: readonly string[] },\n): WebhookTagSet | null {\n if (event.status !== 'succeeded' || event.kind !== 'command') return null\n\n const isDocument = PUBLISHED_STATE_OPERATIONS.has(event.operation)\n const isAsset = ASSET_OPERATIONS.has(event.operation)\n const isRouting = ROUTING_OPERATIONS.has(event.operation)\n if (!isDocument && !isAsset && !isRouting) return null\n\n const projectId = projectIdOfSummary(event.inputSummary)\n if (projectId === undefined) return null\n\n // EC-510: one answer for every redirect write — see `ROUTING_OPERATIONS` for\n // why the targeted `route:` tag is unobtainable and the sweep is the honest one.\n if (isRouting) {\n return { projectId, event: event.operation, tags: [projectSweepWebhookTag(projectId)] }\n }\n\n if (isAsset) return assetTagsForEvent(projectId, event)\n\n const parsed = parseSummary(event.inputSummary)\n const ref = documentRefOf(parsed?.['ref']) ?? documentRefOf(parsed?.['document'])\n if (!ref) {\n // Truncated summary (large upsert payload): honest coarse fallback.\n return { projectId, event: event.operation, tags: [projectSweepWebhookTag(projectId)] }\n }\n\n const tags = new Set<string>([docWebhookTag(projectId, ref.collection, ref.id)])\n for (const locale of options.locales) {\n tags.add(collectionWebhookTag(projectId, ref.collection, locale))\n }\n // EC-207: ROUTES are config-owned (a code deploy, which rebuilds the host), so\n // a document event never needs a `route:` tag. Redirects are NOT — EC-303 made\n // them editorial data and EC-510 gives their own operations the project sweep\n // (see `ROUTING_OPERATIONS`); a *document* write still cannot change them.\n return { projectId, event: event.operation, tags: [...tags].sort() }\n}\n\n/** A non-empty string, else `undefined` (the asset id out of a summary). */\nfunction stringOf(value: unknown): string | undefined {\n return typeof value === 'string' && value.length > 0 ? value : undefined\n}\n\n/**\n * Tags for an asset mutation (EC-227). The edit cases — `assets.remove` (carries\n * `assetId`) and `assets.upsert` of an existing asset (carries `asset.id`) —\n * emit exactly `asset:<id>`, dropping the pages that baked that asset. An\n * `assets.upsert` with **no** id is a CREATE: the id is minted server-side and a\n * brand-new asset cannot be baked into any existing page, so it invalidates\n * nothing (`null`, no webhook). A `remove` missing its id means a truncated\n * summary — sweep the project (coarse, always correct — the document fallback).\n */\nfunction assetTagsForEvent(\n projectId: string,\n event: Pick<OperationEvent, 'operation' | 'inputSummary'>,\n): WebhookTagSet | null {\n const parsed = parseSummary(event.inputSummary)\n const assetId =\n event.operation === 'assets.remove'\n ? stringOf(parsed?.['assetId'])\n : stringOf((parsed?.['asset'] as { id?: unknown } | undefined)?.id)\n if (assetId) {\n return { projectId, event: event.operation, tags: [assetWebhookTag(projectId, assetId)] }\n }\n return event.operation === 'assets.remove'\n ? { projectId, event: event.operation, tags: [projectSweepWebhookTag(projectId)] }\n : null\n}\n"]}
@@ -49,6 +49,8 @@ export declare const PROJECT_CONFIG_ISSUE_KEYS: {
49
49
  * them a `locales` array, so "no locales" is unreachable from the rail's vantage
50
50
  * point; the CMS session guards it directly instead (`CmsProvider`), where it is
51
51
  * both reachable and actionable. Reporting an unreachable state would be copy
52
- * nobody can trigger — see the task file's found-but-left note on that default.
52
+ * nobody can trigger. Since EC-490 (owner-ratified 2026-08-01) that unreachability
53
+ * is STRUCTURAL rather than incidental: a config declaring no `locales` is rejected
54
+ * outright, so there is no resolved project behind which the question could hide.
53
55
  */
54
56
  export declare function projectConfigIssues(collections: readonly CollectionDef[]): CustomizationIssue[];
@@ -49,7 +49,9 @@ export const PROJECT_CONFIG_ISSUE_KEYS = {
49
49
  * them a `locales` array, so "no locales" is unreachable from the rail's vantage
50
50
  * point; the CMS session guards it directly instead (`CmsProvider`), where it is
51
51
  * both reachable and actionable. Reporting an unreachable state would be copy
52
- * nobody can trigger — see the task file's found-but-left note on that default.
52
+ * nobody can trigger. Since EC-490 (owner-ratified 2026-08-01) that unreachability
53
+ * is STRUCTURAL rather than incidental: a config declaring no `locales` is rejected
54
+ * outright, so there is no resolved project behind which the question could hide.
53
55
  */
54
56
  export function projectConfigIssues(collections) {
55
57
  if (collections.length > 0)
@@ -1 +1 @@
1
- {"version":3,"file":"config-issues.js","sourceRoot":"","sources":["../../../src/lib/workspace/config-issues.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,SAAS,CAAA;AAE7C,oFAAoF;AACpF,MAAM,WAAW,GAAG,kBAAkB,CAAA;AAEtC;;;;GAIG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG;IACvC,aAAa,EAAE,mCAAmC;CAC1C,CAAA;AAEV;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,mBAAmB,CACjC,WAAqC;IAErC,IAAI,WAAW,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,EAAE,CAAA;IACrC,OAAO;QACL;YACE,IAAI,EAAE,uBAAuB;YAC7B,QAAQ,EAAE,OAAO;YACjB,OAAO,EAAE,8DAA8D,WAAW,iEAAiE;YACnJ,UAAU,EAAE;gBACV,GAAG,EAAE,yBAAyB,CAAC,aAAa;gBAC5C,IAAI,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE;aAC5B;YACD,KAAK,EAAE,oBAAoB;SAC5B;KACF,CAAA;AACH,CAAC","sourcesContent":["import type { CollectionDef } from '@elytracms/core/cms-core'\nimport type { CustomizationIssue } from '@elytracms/core/studio-core'\n\n/**\n * EC-479 — project-level configuration findings for the section-rail foot.\n *\n * The engine used to fill a missing declaration with its own `cms-core` fixtures:\n * `?? sampleFilterableCollections` in the CMS store, `?? sampleDocuments` beside\n * it, `?? sampleLocaleConfig` in the locale store. A studio whose config never\n * arrived therefore looked *configured* — it showed six documents about Ada\n * Lovelace under a four-collection schema, in `en/de`, none of which the project\n * had declared. The owner's ruling (2026-07-30): \"fixtures should only show in the\n * playground, but not be hard-wired in the engine\".\n *\n * With the fallbacks retired the same state renders as an empty CMS, which on its\n * own is the OTHER silent failure — the house rule bans both (\"if you are writing\n * a fallback that hides a broken state, that *is* the bug\"). This module is the\n * third option: say so, at the rail foot, where EC-467's reuse issues and EC-469's\n * URL-map issues already report project-level problems, on every studio surface.\n *\n * Pure and deterministic — no config-holder reads, no React. The caller supplies\n * the resolved config (`resolveDeploymentCollections()`), which is also what makes\n * this testable against a synthetic project instead of the live one.\n */\n\n/**\n * The extension-point name stamped on these issues. Not a customization registry\n * like `'sidebar'`/`'fieldRenderers'` — the finding is about the project's own\n * declarations, so it locates the config, not an override list.\n */\nexport const PROJECT_CONFIG_POINT = 'project'\n\n/** The file a developer fixes these findings in — interpolated, never localized. */\nconst CONFIG_FILE = 'elytra.config.ts'\n\n/**\n * Catalog keys for the issues below (EC-478). Named here, next to the producer, so\n * the studio's i18n parity test asserts coverage against the producer rather than a\n * hand-copied list — the same shape as `default-modules.tsx`'s `KEYSPACE_KEYS`.\n */\nexport const PROJECT_CONFIG_ISSUE_KEYS = {\n noCollections: 'customization.issue.noCollections',\n} as const\n\n/**\n * Report what the project has NOT declared but the studio cannot work without.\n *\n * Today that is exactly one thing: collections. A studio with none has no content\n * model at all — the CMS rail expands to nothing, every list is empty, and before\n * EC-479 the store quietly substituted the engine's sample schema so none of that\n * was visible. The remedy is one line in `elytra.config.ts`, which is why the\n * message names the file.\n *\n * Deliberately NOT reported here: an absent locale config. A `Project` only exists\n * for a config-declared project and `resolveWorkspaceConfig` gives every one of\n * them a `locales` array, so \"no locales\" is unreachable from the rail's vantage\n * point; the CMS session guards it directly instead (`CmsProvider`), where it is\n * both reachable and actionable. Reporting an unreachable state would be copy\n * nobody can trigger — see the task file's found-but-left note on that default.\n */\nexport function projectConfigIssues(\n collections: readonly CollectionDef[],\n): CustomizationIssue[] {\n if (collections.length > 0) return []\n return [\n {\n code: 'missing-configuration',\n severity: 'error',\n message: `This project declares no content collections. Add them to \"${CONFIG_FILE}\" — the studio shows your schema or none, never sample content.`,\n messageRef: {\n key: PROJECT_CONFIG_ISSUE_KEYS.noCollections,\n vars: { file: CONFIG_FILE },\n },\n point: PROJECT_CONFIG_POINT,\n },\n ]\n}\n"]}
1
+ {"version":3,"file":"config-issues.js","sourceRoot":"","sources":["../../../src/lib/workspace/config-issues.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,SAAS,CAAA;AAE7C,oFAAoF;AACpF,MAAM,WAAW,GAAG,kBAAkB,CAAA;AAEtC;;;;GAIG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG;IACvC,aAAa,EAAE,mCAAmC;CAC1C,CAAA;AAEV;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,mBAAmB,CACjC,WAAqC;IAErC,IAAI,WAAW,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,EAAE,CAAA;IACrC,OAAO;QACL;YACE,IAAI,EAAE,uBAAuB;YAC7B,QAAQ,EAAE,OAAO;YACjB,OAAO,EAAE,8DAA8D,WAAW,iEAAiE;YACnJ,UAAU,EAAE;gBACV,GAAG,EAAE,yBAAyB,CAAC,aAAa;gBAC5C,IAAI,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE;aAC5B;YACD,KAAK,EAAE,oBAAoB;SAC5B;KACF,CAAA;AACH,CAAC","sourcesContent":["import type { CollectionDef } from '@elytracms/core/cms-core'\nimport type { CustomizationIssue } from '@elytracms/core/studio-core'\n\n/**\n * EC-479 — project-level configuration findings for the section-rail foot.\n *\n * The engine used to fill a missing declaration with its own `cms-core` fixtures:\n * `?? sampleFilterableCollections` in the CMS store, `?? sampleDocuments` beside\n * it, `?? sampleLocaleConfig` in the locale store. A studio whose config never\n * arrived therefore looked *configured* — it showed six documents about Ada\n * Lovelace under a four-collection schema, in `en/de`, none of which the project\n * had declared. The owner's ruling (2026-07-30): \"fixtures should only show in the\n * playground, but not be hard-wired in the engine\".\n *\n * With the fallbacks retired the same state renders as an empty CMS, which on its\n * own is the OTHER silent failure — the house rule bans both (\"if you are writing\n * a fallback that hides a broken state, that *is* the bug\"). This module is the\n * third option: say so, at the rail foot, where EC-467's reuse issues and EC-469's\n * URL-map issues already report project-level problems, on every studio surface.\n *\n * Pure and deterministic — no config-holder reads, no React. The caller supplies\n * the resolved config (`resolveDeploymentCollections()`), which is also what makes\n * this testable against a synthetic project instead of the live one.\n */\n\n/**\n * The extension-point name stamped on these issues. Not a customization registry\n * like `'sidebar'`/`'fieldRenderers'` — the finding is about the project's own\n * declarations, so it locates the config, not an override list.\n */\nexport const PROJECT_CONFIG_POINT = 'project'\n\n/** The file a developer fixes these findings in — interpolated, never localized. */\nconst CONFIG_FILE = 'elytra.config.ts'\n\n/**\n * Catalog keys for the issues below (EC-478). Named here, next to the producer, so\n * the studio's i18n parity test asserts coverage against the producer rather than a\n * hand-copied list — the same shape as `default-modules.tsx`'s `KEYSPACE_KEYS`.\n */\nexport const PROJECT_CONFIG_ISSUE_KEYS = {\n noCollections: 'customization.issue.noCollections',\n} as const\n\n/**\n * Report what the project has NOT declared but the studio cannot work without.\n *\n * Today that is exactly one thing: collections. A studio with none has no content\n * model at all — the CMS rail expands to nothing, every list is empty, and before\n * EC-479 the store quietly substituted the engine's sample schema so none of that\n * was visible. The remedy is one line in `elytra.config.ts`, which is why the\n * message names the file.\n *\n * Deliberately NOT reported here: an absent locale config. A `Project` only exists\n * for a config-declared project and `resolveWorkspaceConfig` gives every one of\n * them a `locales` array, so \"no locales\" is unreachable from the rail's vantage\n * point; the CMS session guards it directly instead (`CmsProvider`), where it is\n * both reachable and actionable. Reporting an unreachable state would be copy\n * nobody can trigger. Since EC-490 (owner-ratified 2026-08-01) that unreachability\n * is STRUCTURAL rather than incidental: a config declaring no `locales` is rejected\n * outright, so there is no resolved project behind which the question could hide.\n */\nexport function projectConfigIssues(\n collections: readonly CollectionDef[],\n): CustomizationIssue[] {\n if (collections.length > 0) return []\n return [\n {\n code: 'missing-configuration',\n severity: 'error',\n message: `This project declares no content collections. Add them to \"${CONFIG_FILE}\" — the studio shows your schema or none, never sample content.`,\n messageRef: {\n key: PROJECT_CONFIG_ISSUE_KEYS.noCollections,\n vars: { file: CONFIG_FILE },\n },\n point: PROJECT_CONFIG_POINT,\n },\n ]\n}\n"]}
@@ -171,14 +171,21 @@ export declare function resolveDeploymentRoutes(): RouteRecord[];
171
171
  */
172
172
  export declare function resolveDeploymentLocales(): string[];
173
173
  /**
174
- * The active config project's locales as a `LocaleConfig` (EC-357 finding #3).
175
- * Locales are structure and structure is code: when `elytra.config.ts` declares
176
- * them, they drive the studio's ambient locale switcher in EVERY mode — the CMS
177
- * surface must not fall back to the sample `en/de` fixture (playground, or a
178
- * connected first run with nothing persisted) when the config says otherwise.
179
- * `undefined` when no config project is active or it declares no locales (env-only
180
- * wiring keeps the existing kit/sample fallbacks). A missing/invalid explicit
181
- * default falls back to the first declared locale.
174
+ * The active config project's `LocaleConfig` (EC-357 finding #3). Locales are
175
+ * structure and structure is code: when `elytra.config.ts` declares them, they
176
+ * drive the studio's ambient locale switcher in EVERY mode — the CMS surface must
177
+ * not fall back to the sample `en/de` fixture (playground, or a connected first
178
+ * run with nothing persisted) when the config says otherwise. `undefined` when no
179
+ * config project is active (env-only wiring keeps the existing kit/sample
180
+ * fallbacks).
181
+ *
182
+ * EC-519: this used to REASSEMBLE the config's flat `locales` + `defaultLocale`
183
+ * pair into a `LocaleConfig`, and had grown its own repair rule — an absent or
184
+ * out-of-set default fell back to `locales[0]`. That is a fourth place a locale
185
+ * could be invented, in the surface furthest from the author. The resolver now
186
+ * hands over the same `LocaleConfig` the delivery host takes, so there is nothing
187
+ * to reassemble and nothing to repair: a default outside the set is a config
188
+ * ERROR (`default-locale-not-in-locales`) and the project never resolves.
182
189
  */
183
190
  export declare function resolveDeploymentLocaleConfig(): LocaleConfig | undefined;
184
191
  /**
@@ -249,28 +249,27 @@ export function resolveDeploymentRoutes() {
249
249
  * (e.g. `/de`, `/fr`), never just the default. `[]` when wired by env alone.
250
250
  */
251
251
  export function resolveDeploymentLocales() {
252
- return activeConfigProject()?.locales ?? [];
252
+ return activeConfigProject()?.locales.locales ?? [];
253
253
  }
254
254
  /**
255
- * The active config project's locales as a `LocaleConfig` (EC-357 finding #3).
256
- * Locales are structure and structure is code: when `elytra.config.ts` declares
257
- * them, they drive the studio's ambient locale switcher in EVERY mode — the CMS
258
- * surface must not fall back to the sample `en/de` fixture (playground, or a
259
- * connected first run with nothing persisted) when the config says otherwise.
260
- * `undefined` when no config project is active or it declares no locales (env-only
261
- * wiring keeps the existing kit/sample fallbacks). A missing/invalid explicit
262
- * default falls back to the first declared locale.
255
+ * The active config project's `LocaleConfig` (EC-357 finding #3). Locales are
256
+ * structure and structure is code: when `elytra.config.ts` declares them, they
257
+ * drive the studio's ambient locale switcher in EVERY mode — the CMS surface must
258
+ * not fall back to the sample `en/de` fixture (playground, or a connected first
259
+ * run with nothing persisted) when the config says otherwise. `undefined` when no
260
+ * config project is active (env-only wiring keeps the existing kit/sample
261
+ * fallbacks).
262
+ *
263
+ * EC-519: this used to REASSEMBLE the config's flat `locales` + `defaultLocale`
264
+ * pair into a `LocaleConfig`, and had grown its own repair rule — an absent or
265
+ * out-of-set default fell back to `locales[0]`. That is a fourth place a locale
266
+ * could be invented, in the surface furthest from the author. The resolver now
267
+ * hands over the same `LocaleConfig` the delivery host takes, so there is nothing
268
+ * to reassemble and nothing to repair: a default outside the set is a config
269
+ * ERROR (`default-locale-not-in-locales`) and the project never resolves.
263
270
  */
264
271
  export function resolveDeploymentLocaleConfig() {
265
- const project = activeConfigProject();
266
- if (!project || project.locales.length === 0)
267
- return undefined;
268
- const fallback = project.locales[0];
269
- const explicit = project.defaultLocale;
270
- return {
271
- default: explicit && project.locales.includes(explicit) ? explicit : fallback,
272
- locales: [...project.locales],
273
- };
272
+ return activeConfigProject()?.locales;
274
273
  }
275
274
  /**
276
275
  * The active project's components reference (EC-194, AD-11): where the consuming