@elytracms/studio 0.0.86 → 0.0.87

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 (203) hide show
  1. package/convex/asset-revalidate.test.ts +186 -0
  2. package/convex/assets.ts +53 -19
  3. package/convex/cms.ts +22 -0
  4. package/convex/delivery.ts +33 -116
  5. package/convex/functions.ts +3 -3
  6. package/convex/rail-write-parity.test.ts +180 -0
  7. package/convex/redirects.ts +33 -11
  8. package/convex/revalidate.ts +7 -5
  9. package/convex/triggers.ts +53 -3
  10. package/dist/components/app-views/default-modules.d.ts +20 -20
  11. package/dist/components/app-views/default-modules.js +27 -48
  12. package/dist/components/app-views/default-modules.js.map +1 -1
  13. package/dist/components/app-views/studio-config.d.ts +5 -6
  14. package/dist/components/app-views/studio-config.js.map +1 -1
  15. package/dist/components/assets/asset-metadata.d.ts +28 -19
  16. package/dist/components/assets/asset-metadata.js +24 -31
  17. package/dist/components/assets/asset-metadata.js.map +1 -1
  18. package/dist/components/assets/asset-selection.d.ts +24 -5
  19. package/dist/components/assets/asset-selection.js +39 -9
  20. package/dist/components/assets/asset-selection.js.map +1 -1
  21. package/dist/components/assets/assets-collection-view-source.d.ts +17 -14
  22. package/dist/components/assets/assets-collection-view-source.js +25 -31
  23. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  24. package/dist/components/builder/blocks-panel.js +3 -3
  25. package/dist/components/builder/blocks-panel.js.map +1 -1
  26. package/dist/components/builder/builder-store.d.ts +25 -1
  27. package/dist/components/builder/builder-store.js +3 -2
  28. package/dist/components/builder/builder-store.js.map +1 -1
  29. package/dist/components/builder/canvas-chrome-css.js +0 -17
  30. package/dist/components/builder/canvas-chrome-css.js.map +1 -1
  31. package/dist/components/builder/canvas-chrome.d.ts +10 -12
  32. package/dist/components/builder/canvas-chrome.js +12 -13
  33. package/dist/components/builder/canvas-chrome.js.map +1 -1
  34. package/dist/components/builder/canvas-dnd.d.ts +8 -0
  35. package/dist/components/builder/canvas-dnd.js +5 -3
  36. package/dist/components/builder/canvas-dnd.js.map +1 -1
  37. package/dist/components/builder/canvas-render-context.d.ts +12 -24
  38. package/dist/components/builder/canvas-render-context.js +15 -44
  39. package/dist/components/builder/canvas-render-context.js.map +1 -1
  40. package/dist/components/builder/canvas.js +5 -11
  41. package/dist/components/builder/canvas.js.map +1 -1
  42. package/dist/components/builder/clipboard-actions.d.ts +14 -6
  43. package/dist/components/builder/clipboard-actions.js +29 -20
  44. package/dist/components/builder/clipboard-actions.js.map +1 -1
  45. package/dist/components/builder/composition-field-editor.d.ts +19 -26
  46. package/dist/components/builder/composition-field-editor.js +30 -52
  47. package/dist/components/builder/composition-field-editor.js.map +1 -1
  48. package/dist/components/builder/insert-context.d.ts +3 -1
  49. package/dist/components/builder/insert-context.js +15 -10
  50. package/dist/components/builder/insert-context.js.map +1 -1
  51. package/dist/components/builder/page-tree.d.ts +29 -0
  52. package/dist/components/builder/page-tree.js +56 -58
  53. package/dist/components/builder/page-tree.js.map +1 -1
  54. package/dist/components/builder/reuse.js +5 -2
  55. package/dist/components/builder/reuse.js.map +1 -1
  56. package/dist/components/cms/asset-field.js +20 -32
  57. package/dist/components/cms/asset-field.js.map +1 -1
  58. package/dist/components/cms/cms-composition-validators.js +11 -2
  59. package/dist/components/cms/cms-composition-validators.js.map +1 -1
  60. package/dist/components/cms/editable-cell.js +5 -2
  61. package/dist/components/cms/editable-cell.js.map +1 -1
  62. package/dist/components/cms/use-document-versions.js +1 -0
  63. package/dist/components/cms/use-document-versions.js.map +1 -1
  64. package/dist/components/cms/versions-model.d.ts +0 -12
  65. package/dist/components/cms/versions-model.js +3 -15
  66. package/dist/components/cms/versions-model.js.map +1 -1
  67. package/dist/components/components/component-detail.js +1 -1
  68. package/dist/components/components/component-detail.js.map +1 -1
  69. package/dist/components/components/transfer-dialog.js +2 -4
  70. package/dist/components/components/transfer-dialog.js.map +1 -1
  71. package/dist/components/components/transfer.d.ts +13 -10
  72. package/dist/components/components/transfer.js +21 -19
  73. package/dist/components/components/transfer.js.map +1 -1
  74. package/dist/components/project-session.d.ts +9 -0
  75. package/dist/components/project-session.js +1 -1
  76. package/dist/components/project-session.js.map +1 -1
  77. package/dist/components/redirects/redirects-collection-view-source.d.ts +17 -2
  78. package/dist/components/redirects/redirects-collection-view-source.js +20 -6
  79. package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
  80. package/dist/components/section-surface.js +3 -2
  81. package/dist/components/section-surface.js.map +1 -1
  82. package/dist/components/settings-form.js +1 -2
  83. package/dist/components/settings-form.js.map +1 -1
  84. package/dist/components/studio-context.js +1 -18
  85. package/dist/components/studio-context.js.map +1 -1
  86. package/dist/components/studio-project-screen.js +3 -5
  87. package/dist/components/studio-project-screen.js.map +1 -1
  88. package/dist/lib/ai/apply-content.js +73 -29
  89. package/dist/lib/ai/apply-content.js.map +1 -1
  90. package/dist/lib/ai/constraints.d.ts +35 -0
  91. package/dist/lib/ai/constraints.js +104 -4
  92. package/dist/lib/ai/constraints.js.map +1 -1
  93. package/dist/lib/ai/route.js +4 -1
  94. package/dist/lib/ai/route.js.map +1 -1
  95. package/dist/lib/ai/shape.d.ts +3 -2
  96. package/dist/lib/ai/shape.js +16 -24
  97. package/dist/lib/ai/shape.js.map +1 -1
  98. package/dist/lib/ai/types.d.ts +8 -0
  99. package/dist/lib/ai/types.js.map +1 -1
  100. package/dist/lib/ai/update-fields.js +33 -12
  101. package/dist/lib/ai/update-fields.js.map +1 -1
  102. package/dist/lib/assets/asset-placements.d.ts +35 -0
  103. package/dist/lib/assets/asset-placements.js +74 -0
  104. package/dist/lib/assets/asset-placements.js.map +1 -0
  105. package/dist/lib/assets/backfill-thumbs.d.ts +9 -8
  106. package/dist/lib/assets/backfill-thumbs.js +13 -25
  107. package/dist/lib/assets/backfill-thumbs.js.map +1 -1
  108. package/dist/lib/clipboard/paste-issues.d.ts +17 -15
  109. package/dist/lib/clipboard/paste-issues.js +8 -45
  110. package/dist/lib/clipboard/paste-issues.js.map +1 -1
  111. package/dist/lib/clipboard/paste-target.d.ts +25 -1
  112. package/dist/lib/clipboard/paste-target.js +13 -6
  113. package/dist/lib/clipboard/paste-target.js.map +1 -1
  114. package/dist/lib/cms/published-documents.js +1 -0
  115. package/dist/lib/cms/published-documents.js.map +1 -1
  116. package/dist/lib/cms-views/view-model.d.ts +3 -3
  117. package/dist/lib/data/index.d.ts +0 -3
  118. package/dist/lib/data/index.js +4 -6
  119. package/dist/lib/data/index.js.map +1 -1
  120. package/dist/lib/dnd/drop-rules.d.ts +45 -2
  121. package/dist/lib/dnd/drop-rules.js +64 -9
  122. package/dist/lib/dnd/drop-rules.js.map +1 -1
  123. package/dist/lib/engine-version.d.ts +1 -1
  124. package/dist/lib/engine-version.js +1 -1
  125. package/dist/lib/engine-version.js.map +1 -1
  126. package/dist/lib/i18n/messages.de.js +4 -56
  127. package/dist/lib/i18n/messages.de.js.map +1 -1
  128. package/dist/lib/i18n/messages.en.d.ts +4 -53
  129. package/dist/lib/i18n/messages.en.js +8 -57
  130. package/dist/lib/i18n/messages.en.js.map +1 -1
  131. package/dist/lib/locale/fallback.js +8 -2
  132. package/dist/lib/locale/fallback.js.map +1 -1
  133. package/dist/lib/locale/index.d.ts +0 -1
  134. package/dist/lib/locale/index.js +0 -1
  135. package/dist/lib/locale/index.js.map +1 -1
  136. package/dist/lib/locale/locale-store.d.ts +10 -3
  137. package/dist/lib/locale/locale-store.js +12 -5
  138. package/dist/lib/locale/locale-store.js.map +1 -1
  139. package/dist/lib/operations/session-hydration.d.ts +9 -5
  140. package/dist/lib/operations/session-hydration.js +10 -10
  141. package/dist/lib/operations/session-hydration.js.map +1 -1
  142. package/dist/lib/persistence/convex-adapter.js +63 -75
  143. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  144. package/dist/lib/persona/labels.d.ts +0 -2
  145. package/dist/lib/persona/labels.js +1 -7
  146. package/dist/lib/persona/labels.js.map +1 -1
  147. package/dist/lib/registry/index.d.ts +4 -2
  148. package/dist/lib/registry/index.js +26 -21
  149. package/dist/lib/registry/index.js.map +1 -1
  150. package/dist/lib/registry/project-components.js +0 -3
  151. package/dist/lib/registry/project-components.js.map +1 -1
  152. package/dist/lib/routes/route-issues.js +4 -5
  153. package/dist/lib/routes/route-issues.js.map +1 -1
  154. package/dist/lib/theme.js +3 -2
  155. package/dist/lib/theme.js.map +1 -1
  156. package/dist/lib/webhooks/tags.js +11 -14
  157. package/dist/lib/webhooks/tags.js.map +1 -1
  158. package/dist/lib/workspace/index.d.ts +1 -1
  159. package/dist/lib/workspace/index.js +1 -1
  160. package/dist/lib/workspace/index.js.map +1 -1
  161. package/dist/previews/enumerate.d.ts +27 -9
  162. package/dist/previews/enumerate.js +27 -6
  163. package/dist/previews/enumerate.js.map +1 -1
  164. package/dist/previews/manifest-format.d.ts +9 -0
  165. package/dist/previews/manifest-format.js.map +1 -1
  166. package/dist/router/match.d.ts +0 -2
  167. package/dist/router/match.js +0 -2
  168. package/dist/router/match.js.map +1 -1
  169. package/dist/ui/badge.d.ts +1 -1
  170. package/package.json +2 -2
  171. package/src/styles/studio.css +5 -4
  172. package/src/styles/tokens.css +72 -55
  173. package/convex/content.ts +0 -291
  174. package/dist/components/collab/collab-context.d.ts +0 -27
  175. package/dist/components/collab/collab-context.js +0 -39
  176. package/dist/components/collab/collab-context.js.map +0 -1
  177. package/dist/components/collab/collab-seed.d.ts +0 -15
  178. package/dist/components/collab/collab-seed.js +0 -20
  179. package/dist/components/collab/collab-seed.js.map +0 -1
  180. package/dist/components/extensions/extensions-view.d.ts +0 -7
  181. package/dist/components/extensions/extensions-view.js +0 -127
  182. package/dist/components/extensions/extensions-view.js.map +0 -1
  183. package/dist/lib/bindings/section-bindings.d.ts +0 -24
  184. package/dist/lib/bindings/section-bindings.js +0 -9
  185. package/dist/lib/bindings/section-bindings.js.map +0 -1
  186. package/dist/lib/data/data-store.d.ts +0 -98
  187. package/dist/lib/data/data-store.js +0 -211
  188. package/dist/lib/data/data-store.js.map +0 -1
  189. package/dist/lib/data/providers.d.ts +0 -143
  190. package/dist/lib/data/providers.js +0 -255
  191. package/dist/lib/data/providers.js.map +0 -1
  192. package/dist/lib/data/sample-capture.d.ts +0 -30
  193. package/dist/lib/data/sample-capture.js +0 -70
  194. package/dist/lib/data/sample-capture.js.map +0 -1
  195. package/dist/lib/data/sources-store.d.ts +0 -42
  196. package/dist/lib/data/sources-store.js +0 -120
  197. package/dist/lib/data/sources-store.js.map +0 -1
  198. package/dist/lib/locale/binding-fallback.d.ts +0 -35
  199. package/dist/lib/locale/binding-fallback.js +0 -73
  200. package/dist/lib/locale/binding-fallback.js.map +0 -1
  201. package/dist/lib/starters/kit-session.d.ts +0 -5
  202. package/dist/lib/starters/kit-session.js +0 -17
  203. package/dist/lib/starters/kit-session.js.map +0 -1
@@ -1,4 +1,5 @@
1
1
  import { documentDefaultLocale, isFieldLocalized, resolveField } from '@elytracms/core/cms-core';
2
+ import { isEmptyBlockSequence } from '@elytracms/core/project-graph';
2
3
  /** True when the document stores an explicit variant of `field` for `locale`. */
3
4
  export function hasLocaleVariant(doc, fieldName, locale) {
4
5
  const bag = doc.localized?.[locale];
@@ -73,9 +74,14 @@ export function activeLocaleValues(doc, locale) {
73
74
  // store a cleared value as `undefined`, which DELETES the key — so clearing a
74
75
  // localized field reconnects it to base rather than diverging it to empty.
75
76
  // ───────────────────────────────────────────────────────────────────────────
76
- /** The "no value" sentinels the built-in controls store (nothing to fill from). */
77
+ /**
78
+ * The "no value" sentinels the built-in controls store (nothing to fill from) —
79
+ * including the empty v2 sequence a cleared canvas writes (EC-575, the same
80
+ * project-graph rule `required` reads). Filling from it would only diverge the
81
+ * locale to an empty value, the state this model deliberately cannot represent.
82
+ */
77
83
  function isBlankValue(value) {
78
- return value === undefined || value === null || value === '';
84
+ return value === undefined || value === null || value === '' || isEmptyBlockSequence(value);
79
85
  }
80
86
  /** Derive the connected/diverged state of `field` in `locale` (EC-318). */
81
87
  export function fieldConnection(collection, doc, fieldName, locale, config) {
@@ -1 +1 @@
1
- {"version":3,"file":"fallback.js","sourceRoot":"","sources":["../../../src/lib/locale/fallback.ts"],"names":[],"mappings":"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","sourcesContent":["import { documentDefaultLocale, isFieldLocalized, resolveField } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CollectionDef, Locale, LocaleConfig } from '@elytracms/core/cms-core'\n\n/**\n * Fallback visibility (EC-176) — the pure \"is this displayed value a\n * fallback?\" decision the CMS table cells, detail-editor fields, and the\n * builder's Content tab all share. One function answers it, so a value is\n * either labeled everywhere or a real translation everywhere — never\n * silently mixed.\n */\n\n/** A displayed value that came from the fallback locale, not the active one. */\nexport interface DisplayedFallback {\n /** The locale the rendered value was actually read from. */\n sourceLocale: Locale\n /** The locale the surface asked for (the ambient active locale). */\n requestedLocale: Locale\n}\n\n/** True when the document stores an explicit variant of `field` for `locale`. */\nexport function hasLocaleVariant(doc: CmsDocument, fieldName: string, locale: Locale): boolean {\n const bag = doc.localized?.[locale]\n return bag !== undefined && fieldName in bag\n}\n\n/**\n * Whether the value a surface displays for `field` in `locale` is served via\n * EC-018 default-locale fallback. `null` means \"not a fallback\": the field is\n * not localized (locale-independent by design), the active locale has its own\n * variant, or there is nothing to display at all (an empty field is empty,\n * not a fallback).\n */\nexport function displayedFieldFallback(\n collection: CollectionDef,\n doc: CmsDocument,\n fieldName: string,\n locale: Locale,\n config: LocaleConfig,\n): DisplayedFallback | null {\n if (!isFieldLocalized(collection, fieldName)) return null\n if (hasLocaleVariant(doc, fieldName, locale)) return null\n const resolved = resolveField(collection, doc, fieldName, locale, config)\n if (!resolved.fellBack || resolved.value === undefined) return null\n return { sourceLocale: resolved.sourceLocale, requestedLocale: locale }\n}\n\n/**\n * The value a surface should display for `field` in `locale` (explicit\n * variant, else fallback) together with its fallback marker. Display and\n * label come from the same resolution, so they can never disagree.\n */\nexport function resolveDisplayedValue(\n collection: CollectionDef,\n doc: CmsDocument,\n fieldName: string,\n locale: Locale,\n config: LocaleConfig,\n): { value: unknown; fallback: DisplayedFallback | null } {\n return {\n value: resolveField(collection, doc, fieldName, locale, config).value,\n fallback: displayedFieldFallback(collection, doc, fieldName, locale, config),\n }\n}\n\n/**\n * True when editing `field` in `locale` would CREATE a new localized variant\n * (rather than update an existing one): the field is localized and the active\n * locale isn't the document's fallback source and has no variant yet. Drives\n * the \"translating from EN\" affordance on editors.\n */\nexport function editCreatesVariant(\n collection: CollectionDef,\n doc: CmsDocument,\n fieldName: string,\n locale: Locale,\n config: LocaleConfig,\n): boolean {\n if (!isFieldLocalized(collection, fieldName)) return false\n if (locale === documentDefaultLocale(doc, config)) return false\n return !hasLocaleVariant(doc, fieldName, locale)\n}\n\n/** Compact display form of a locale code for chips/badges (\"en\" → \"EN\"). */\nexport function localeChipLabel(locale: Locale): string {\n return locale.toUpperCase()\n}\n\n/**\n * The active-locale value bag: the document's shared `values` overlaid with the\n * active locale's variants (`localized[locale]`). The merged view a surface reads\n * to evaluate conditional-visibility predicates (EC-273) and derive the auto-slug\n * source (EC-315) — deduped between the detail editor and the slug control. Pure.\n */\nexport function activeLocaleValues(doc: CmsDocument, locale: Locale): Record<string, unknown> {\n return { ...doc.values, ...(doc.localized?.[locale] ?? {}) }\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// EC-318 — connected fields\n//\n// The Sanity/Contentful \"connected field\" model, over the SAME present/absent-key\n// storage EC-176 already uses. Connection state is DERIVED, never stored:\n// • connected = NO per-locale key → the field FOLLOWS the base-locale value.\n// • diverged = a per-locale key EXISTS → the field OWNS a locale value.\n// Editing a connected field diverges it (writes the key); \"fill from base\" seeds a\n// divergent copy; \"reset to base\" deletes the key to reconnect.\n//\n// Accepted limitation (a deliberate product decision, not an oversight): an\n// intentionally-EMPTY divergent value is not representable. The built-in controls\n// store a cleared value as `undefined`, which DELETES the key — so clearing a\n// localized field reconnects it to base rather than diverging it to empty.\n// ───────────────────────────────────────────────────────────────────────────\n\n/** The \"no value\" sentinels the built-in controls store (nothing to fill from). */\nfunction isBlankValue(value: unknown): boolean {\n return value === undefined || value === null || value === ''\n}\n\n/**\n * The per-locale CONNECTION state of a localized field (EC-318), derived from the\n * document — never stored. `null` when the connected-field affordance does not\n * apply: the field isn't localized, or the requested locale IS the document's base\n * locale (there is nothing to follow — the base owns the value).\n */\nexport interface FieldConnection {\n /** The document's base/default locale — the value a connected field follows. */\n baseLocale: Locale\n /** True when NO per-locale key exists: the field follows the base value. */\n connected: boolean\n /** The current base-locale value (\"fill from base\"/\"reset\" act against it). */\n baseValue: unknown\n /** True when the base holds a non-empty value there is something to fill from. */\n canFillFromBase: boolean\n}\n\n/** Derive the connected/diverged state of `field` in `locale` (EC-318). */\nexport function fieldConnection(\n collection: CollectionDef,\n doc: CmsDocument,\n fieldName: string,\n locale: Locale,\n config: LocaleConfig,\n): FieldConnection | null {\n if (!isFieldLocalized(collection, fieldName)) return null\n const baseLocale = documentDefaultLocale(doc, config)\n if (locale === baseLocale) return null\n const baseValue = resolveField(collection, doc, fieldName, baseLocale, config).value\n return {\n baseLocale,\n connected: !hasLocaleVariant(doc, fieldName, locale),\n baseValue,\n canFillFromBase: !isBlankValue(baseValue),\n }\n}\n\n/**\n * The present/absent-key write for one field — the FOUNDATION of the connected-\n * field model (EC-176/EC-318). A localized field's value lives under\n * `localized[locale][field]`; writing `undefined` DELETES that key (reconnecting\n * the field to base — the accepted empty-value limitation above). Non-localized\n * fields write the shared `values` bag. Pure — returns a new document.\n */\nexport function setFieldValue(\n collection: CollectionDef,\n doc: CmsDocument,\n fieldName: string,\n locale: Locale,\n value: unknown,\n): CmsDocument {\n if (isFieldLocalized(collection, fieldName)) {\n const perLocale = { ...(doc.localized?.[locale] ?? {}) }\n if (value === undefined) delete perLocale[fieldName]\n else perLocale[fieldName] = value\n return { ...doc, localized: { ...doc.localized, [locale]: perLocale } }\n }\n const values = { ...doc.values }\n if (value === undefined) delete values[fieldName]\n else values[fieldName] = value\n return { ...doc, values }\n}\n\n/**\n * \"Fill from base\" (EC-318) — copy the current base-locale value into the active\n * locale, instantly DISCONNECTING the field with a pre-filled starting point. A\n * no-op when there is nothing to copy (blank base value). Pure.\n */\nexport function fillFieldFromBase(\n collection: CollectionDef,\n doc: CmsDocument,\n fieldName: string,\n locale: Locale,\n config: LocaleConfig,\n): CmsDocument {\n const connection = fieldConnection(collection, doc, fieldName, locale, config)\n if (!connection || !connection.canFillFromBase) return doc\n return setFieldValue(collection, doc, fieldName, locale, connection.baseValue)\n}\n\n/**\n * \"Reset to base\" / reconnect (EC-318) — delete the active-locale variant so the\n * field follows the base value again. Discards the translation, so gate it behind\n * a confirm. Pure.\n */\nexport function resetFieldToBase(\n collection: CollectionDef,\n doc: CmsDocument,\n fieldName: string,\n locale: Locale,\n): CmsDocument {\n return setFieldValue(collection, doc, fieldName, locale, undefined)\n}\n"]}
1
+ {"version":3,"file":"fallback.js","sourceRoot":"","sources":["../../../src/lib/locale/fallback.ts"],"names":[],"mappings":"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","sourcesContent":["import { documentDefaultLocale, isFieldLocalized, resolveField } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CollectionDef, Locale, LocaleConfig } from '@elytracms/core/cms-core'\nimport { isEmptyBlockSequence } from '@elytracms/core/project-graph'\n\n/**\n * Fallback visibility (EC-176) — the pure \"is this displayed value a\n * fallback?\" decision the CMS table cells, detail-editor fields, and the\n * builder's Content tab all share. One function answers it, so a value is\n * either labeled everywhere or a real translation everywhere — never\n * silently mixed.\n */\n\n/** A displayed value that came from the fallback locale, not the active one. */\nexport interface DisplayedFallback {\n /** The locale the rendered value was actually read from. */\n sourceLocale: Locale\n /** The locale the surface asked for (the ambient active locale). */\n requestedLocale: Locale\n}\n\n/** True when the document stores an explicit variant of `field` for `locale`. */\nexport function hasLocaleVariant(doc: CmsDocument, fieldName: string, locale: Locale): boolean {\n const bag = doc.localized?.[locale]\n return bag !== undefined && fieldName in bag\n}\n\n/**\n * Whether the value a surface displays for `field` in `locale` is served via\n * EC-018 default-locale fallback. `null` means \"not a fallback\": the field is\n * not localized (locale-independent by design), the active locale has its own\n * variant, or there is nothing to display at all (an empty field is empty,\n * not a fallback).\n */\nexport function displayedFieldFallback(\n collection: CollectionDef,\n doc: CmsDocument,\n fieldName: string,\n locale: Locale,\n config: LocaleConfig,\n): DisplayedFallback | null {\n if (!isFieldLocalized(collection, fieldName)) return null\n if (hasLocaleVariant(doc, fieldName, locale)) return null\n const resolved = resolveField(collection, doc, fieldName, locale, config)\n if (!resolved.fellBack || resolved.value === undefined) return null\n return { sourceLocale: resolved.sourceLocale, requestedLocale: locale }\n}\n\n/**\n * The value a surface should display for `field` in `locale` (explicit\n * variant, else fallback) together with its fallback marker. Display and\n * label come from the same resolution, so they can never disagree.\n */\nexport function resolveDisplayedValue(\n collection: CollectionDef,\n doc: CmsDocument,\n fieldName: string,\n locale: Locale,\n config: LocaleConfig,\n): { value: unknown; fallback: DisplayedFallback | null } {\n return {\n value: resolveField(collection, doc, fieldName, locale, config).value,\n fallback: displayedFieldFallback(collection, doc, fieldName, locale, config),\n }\n}\n\n/**\n * True when editing `field` in `locale` would CREATE a new localized variant\n * (rather than update an existing one): the field is localized and the active\n * locale isn't the document's fallback source and has no variant yet. Drives\n * the \"translating from EN\" affordance on editors.\n */\nexport function editCreatesVariant(\n collection: CollectionDef,\n doc: CmsDocument,\n fieldName: string,\n locale: Locale,\n config: LocaleConfig,\n): boolean {\n if (!isFieldLocalized(collection, fieldName)) return false\n if (locale === documentDefaultLocale(doc, config)) return false\n return !hasLocaleVariant(doc, fieldName, locale)\n}\n\n/** Compact display form of a locale code for chips/badges (\"en\" → \"EN\"). */\nexport function localeChipLabel(locale: Locale): string {\n return locale.toUpperCase()\n}\n\n/**\n * The active-locale value bag: the document's shared `values` overlaid with the\n * active locale's variants (`localized[locale]`). The merged view a surface reads\n * to evaluate conditional-visibility predicates (EC-273) and derive the auto-slug\n * source (EC-315) — deduped between the detail editor and the slug control. Pure.\n */\nexport function activeLocaleValues(doc: CmsDocument, locale: Locale): Record<string, unknown> {\n return { ...doc.values, ...(doc.localized?.[locale] ?? {}) }\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// EC-318 — connected fields\n//\n// The Sanity/Contentful \"connected field\" model, over the SAME present/absent-key\n// storage EC-176 already uses. Connection state is DERIVED, never stored:\n// • connected = NO per-locale key → the field FOLLOWS the base-locale value.\n// • diverged = a per-locale key EXISTS → the field OWNS a locale value.\n// Editing a connected field diverges it (writes the key); \"fill from base\" seeds a\n// divergent copy; \"reset to base\" deletes the key to reconnect.\n//\n// Accepted limitation (a deliberate product decision, not an oversight): an\n// intentionally-EMPTY divergent value is not representable. The built-in controls\n// store a cleared value as `undefined`, which DELETES the key — so clearing a\n// localized field reconnects it to base rather than diverging it to empty.\n// ───────────────────────────────────────────────────────────────────────────\n\n/**\n * The \"no value\" sentinels the built-in controls store (nothing to fill from) —\n * including the empty v2 sequence a cleared canvas writes (EC-575, the same\n * project-graph rule `required` reads). Filling from it would only diverge the\n * locale to an empty value, the state this model deliberately cannot represent.\n */\nfunction isBlankValue(value: unknown): boolean {\n return value === undefined || value === null || value === '' || isEmptyBlockSequence(value)\n}\n\n/**\n * The per-locale CONNECTION state of a localized field (EC-318), derived from the\n * document — never stored. `null` when the connected-field affordance does not\n * apply: the field isn't localized, or the requested locale IS the document's base\n * locale (there is nothing to follow — the base owns the value).\n */\nexport interface FieldConnection {\n /** The document's base/default locale — the value a connected field follows. */\n baseLocale: Locale\n /** True when NO per-locale key exists: the field follows the base value. */\n connected: boolean\n /** The current base-locale value (\"fill from base\"/\"reset\" act against it). */\n baseValue: unknown\n /** True when the base holds a non-empty value there is something to fill from. */\n canFillFromBase: boolean\n}\n\n/** Derive the connected/diverged state of `field` in `locale` (EC-318). */\nexport function fieldConnection(\n collection: CollectionDef,\n doc: CmsDocument,\n fieldName: string,\n locale: Locale,\n config: LocaleConfig,\n): FieldConnection | null {\n if (!isFieldLocalized(collection, fieldName)) return null\n const baseLocale = documentDefaultLocale(doc, config)\n if (locale === baseLocale) return null\n const baseValue = resolveField(collection, doc, fieldName, baseLocale, config).value\n return {\n baseLocale,\n connected: !hasLocaleVariant(doc, fieldName, locale),\n baseValue,\n canFillFromBase: !isBlankValue(baseValue),\n }\n}\n\n/**\n * The present/absent-key write for one field — the FOUNDATION of the connected-\n * field model (EC-176/EC-318). A localized field's value lives under\n * `localized[locale][field]`; writing `undefined` DELETES that key (reconnecting\n * the field to base — the accepted empty-value limitation above). Non-localized\n * fields write the shared `values` bag. Pure — returns a new document.\n */\nexport function setFieldValue(\n collection: CollectionDef,\n doc: CmsDocument,\n fieldName: string,\n locale: Locale,\n value: unknown,\n): CmsDocument {\n if (isFieldLocalized(collection, fieldName)) {\n const perLocale = { ...(doc.localized?.[locale] ?? {}) }\n if (value === undefined) delete perLocale[fieldName]\n else perLocale[fieldName] = value\n return { ...doc, localized: { ...doc.localized, [locale]: perLocale } }\n }\n const values = { ...doc.values }\n if (value === undefined) delete values[fieldName]\n else values[fieldName] = value\n return { ...doc, values }\n}\n\n/**\n * \"Fill from base\" (EC-318) — copy the current base-locale value into the active\n * locale, instantly DISCONNECTING the field with a pre-filled starting point. A\n * no-op when there is nothing to copy (blank base value). Pure.\n */\nexport function fillFieldFromBase(\n collection: CollectionDef,\n doc: CmsDocument,\n fieldName: string,\n locale: Locale,\n config: LocaleConfig,\n): CmsDocument {\n const connection = fieldConnection(collection, doc, fieldName, locale, config)\n if (!connection || !connection.canFillFromBase) return doc\n return setFieldValue(collection, doc, fieldName, locale, connection.baseValue)\n}\n\n/**\n * \"Reset to base\" / reconnect (EC-318) — delete the active-locale variant so the\n * field follows the base value again. Discards the translation, so gate it behind\n * a confirm. Pure.\n */\nexport function resetFieldToBase(\n collection: CollectionDef,\n doc: CmsDocument,\n fieldName: string,\n locale: Locale,\n): CmsDocument {\n return setFieldValue(collection, doc, fieldName, locale, undefined)\n}\n"]}
@@ -1,6 +1,5 @@
1
1
  export * from './locale-store.js';
2
2
  export * from './fallback.js';
3
3
  export * from './route-variant.js';
4
- export * from './binding-fallback.js';
5
4
  export * from './use-studio-locale.js';
6
5
  export * from './locale-switcher.js';
@@ -1,7 +1,6 @@
1
1
  export * from './locale-store.js';
2
2
  export * from './fallback.js';
3
3
  export * from './route-variant.js';
4
- export * from './binding-fallback.js';
5
4
  export * from './use-studio-locale.js';
6
5
  export * from './locale-switcher.js';
7
6
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/lib/locale/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAA;AAC9B,cAAc,YAAY,CAAA;AAC1B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,oBAAoB,CAAA;AAClC,cAAc,qBAAqB,CAAA;AACnC,cAAc,mBAAmB,CAAA","sourcesContent":["export * from './locale-store'\nexport * from './fallback'\nexport * from './route-variant'\nexport * from './binding-fallback'\nexport * from './use-studio-locale'\nexport * from './locale-switcher'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/lib/locale/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAA;AAC9B,cAAc,YAAY,CAAA;AAC1B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,qBAAqB,CAAA;AACnC,cAAc,mBAAmB,CAAA","sourcesContent":["export * from './locale-store'\nexport * from './fallback'\nexport * from './route-variant'\nexport * from './use-studio-locale'\nexport * from './locale-switcher'\n"]}
@@ -57,9 +57,16 @@ export declare function getStudioLocaleConfig(): LocaleConfig | null;
57
57
  */
58
58
  export declare function setStudioLocale(locale: Locale): void;
59
59
  /**
60
- * Install the project's locale config (e.g. when a CMS session or builder
61
- * shell mounts with a starter kit's locales). The active locale is kept when
62
- * it is still configured, otherwise it snaps back to the new default.
60
+ * Install the project's locale config (when a CMS session mounts). The active
61
+ * locale is kept when it is still configured, otherwise it snaps back to the new
62
+ * default.
63
+ *
64
+ * The config arrives valid by construction: every caller hands over a
65
+ * config-derived `LocaleConfig` that the workspace gate has already checked
66
+ * (`default-locale-not-in-locales` is an error there), so nothing is repaired
67
+ * here. The old `createLocaleConfig` repair existed for starter-kit locales, a
68
+ * rung EC-574 removed — a second, silent declaration of "the default is in the
69
+ * set" is exactly the drift the one gate exists to prevent.
63
70
  */
64
71
  export declare function configureStudioLocales(config: LocaleConfig): void;
65
72
  /** Subscribe to locale/config changes. Returns the unsubscribe function. */
@@ -1,4 +1,4 @@
1
- import { createLocaleConfig, isKnownLocale } from '@elytracms/core/cms-core';
1
+ import { isKnownLocale } from '@elytracms/core/cms-core';
2
2
  /** The pre-config state: no project locales have been installed yet. */
3
3
  const NOT_LOADED = { config: null, active: null };
4
4
  let state = NOT_LOADED;
@@ -42,12 +42,19 @@ export function setStudioLocale(locale) {
42
42
  emit();
43
43
  }
44
44
  /**
45
- * Install the project's locale config (e.g. when a CMS session or builder
46
- * shell mounts with a starter kit's locales). The active locale is kept when
47
- * it is still configured, otherwise it snaps back to the new default.
45
+ * Install the project's locale config (when a CMS session mounts). The active
46
+ * locale is kept when it is still configured, otherwise it snaps back to the new
47
+ * default.
48
+ *
49
+ * The config arrives valid by construction: every caller hands over a
50
+ * config-derived `LocaleConfig` that the workspace gate has already checked
51
+ * (`default-locale-not-in-locales` is an error there), so nothing is repaired
52
+ * here. The old `createLocaleConfig` repair existed for starter-kit locales, a
53
+ * rung EC-574 removed — a second, silent declaration of "the default is in the
54
+ * set" is exactly the drift the one gate exists to prevent.
48
55
  */
49
56
  export function configureStudioLocales(config) {
50
- const normalized = createLocaleConfig({ default: config.default, locales: config.locales });
57
+ const normalized = { default: config.default, locales: [...config.locales] };
51
58
  const current = state.config;
52
59
  const sameConfig = current !== null &&
53
60
  normalized.default === current.default &&
@@ -1 +1 @@
1
- {"version":3,"file":"locale-store.js","sourceRoot":"","sources":["../../../src/lib/locale/locale-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAqC5E,wEAAwE;AACxE,MAAM,UAAU,GAAsB,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAA;AAEpE,IAAI,KAAK,GAAsB,UAAU,CAAA;AAEzC,MAAM,SAAS,GAAG,IAAI,GAAG,EAAc,CAAA;AAEvC,SAAS,IAAI;IACX,KAAK,MAAM,QAAQ,IAAI,SAAS;QAAE,QAAQ,EAAE,CAAA;AAC9C,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,oBAAoB;IAClC,OAAO,KAAK,CAAA;AACd,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,KAAK,CAAC,MAAM,CAAA;AACrB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,qBAAqB;IACnC,OAAO,KAAK,CAAC,MAAM,CAAA;AACrB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,MAAc;IAC5C,IAAI,KAAK,CAAC,MAAM,KAAK,IAAI;QAAE,OAAM;IACjC,IAAI,MAAM,KAAK,KAAK,CAAC,MAAM,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,CAAC;QAAE,OAAM;IAC3E,KAAK,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,CAAA;IAChD,IAAI,EAAE,CAAA;AACR,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,sBAAsB,CAAC,MAAoB;IACzD,MAAM,UAAU,GAAG,kBAAkB,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAA;IAC3F,MAAM,OAAO,GAAG,KAAK,CAAC,MAAM,CAAA;IAC5B,MAAM,UAAU,GACd,OAAO,KAAK,IAAI;QAChB,UAAU,CAAC,OAAO,KAAK,OAAO,CAAC,OAAO;QACtC,UAAU,CAAC,OAAO,CAAC,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,MAAM;QACpD,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAA;IACxE,MAAM,MAAM,GACV,KAAK,CAAC,MAAM,KAAK,IAAI,IAAI,aAAa,CAAC,UAAU,EAAE,KAAK,CAAC,MAAM,CAAC;QAC9D,CAAC,CAAC,KAAK,CAAC,MAAM;QACd,CAAC,CAAC,UAAU,CAAC,OAAO,CAAA;IACxB,IAAI,UAAU,IAAI,MAAM,KAAK,KAAK,CAAC,MAAM;QAAE,OAAM;IACjD,KAAK,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,CAAA;IACtC,IAAI,EAAE,CAAA;AACR,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,qBAAqB,CAAC,QAAoB;IACxD,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IACvB,OAAO,GAAG,EAAE;QACV,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;IAC5B,CAAC,CAAA;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB;IAC/B,KAAK,GAAG,UAAU,CAAA;IAClB,IAAI,EAAE,CAAA;AACR,CAAC","sourcesContent":["import { createLocaleConfig, isKnownLocale } from '@elytracms/core/cms-core'\nimport type { Locale, LocaleConfig } from '@elytracms/core/cms-core'\n\n/**\n * The studio's ambient locale (EC-176) — framework-free core.\n *\n * One studio-level active locale, set once via the locale switcher, that the\n * builder canvas preview, the CMS table, and the detail editor all follow.\n * Module-level (not React context) on purpose: the playground's content\n * client (`lib/data/collection-content`) reads it at query time without any\n * provider plumbing, and React surfaces subscribe through\n * `useStudioLocale` (`useSyncExternalStore`).\n *\n * Deliberately NOT part of `lib/data/data-store` — the locale is studio\n * state, not a data source.\n *\n * EC-479/EC-483 — **before a project's locale config is installed, this store\n * holds `null`, not a locale.** It used to open on the engine's `en/de`\n * `sampleLocaleConfig` fixture, which is the worst fallback in the family: the\n * default locale is the literal STORAGE KEY for every localized field value\n * (`documents.ts:88`), so a fabricated one writes a German project's content\n * under `en` and correcting it later is a data migration, not a config edit. The\n * locale is a deliberate choice at project creation (EC-483) and reaches the\n * studio through {@link configureStudioLocales} from the project's own config —\n * so \"not loaded\" is an honest state that surfaces, never an invented one that\n * silently becomes a key.\n */\n\n/**\n * `{ config, active }` once a project's locales are installed; `{ null, null }`\n * before that. The two fields move together by construction — there is no state\n * with a config but no active locale, or an active locale nobody declared.\n */\nexport type StudioLocaleState =\n | { readonly config: LocaleConfig; readonly active: Locale }\n | { readonly config: null; readonly active: null }\n\n/** The pre-config state: no project locales have been installed yet. */\nconst NOT_LOADED: StudioLocaleState = { config: null, active: null }\n\nlet state: StudioLocaleState = NOT_LOADED\n\nconst listeners = new Set<() => void>()\n\nfunction emit(): void {\n for (const listener of listeners) listener()\n}\n\n/** Snapshot of `{ config, active }` — stable identity until the next change. */\nexport function getStudioLocaleState(): StudioLocaleState {\n return state\n}\n\n/**\n * The active studio locale every surface renders in — `null` until a project's\n * locale config is installed. A caller that genuinely needs a locale reads the\n * PROJECT's own default (`CmsStore.localeConfig.default`, the content session's\n * config); it must never substitute one of its own.\n */\nexport function getStudioLocale(): Locale | null {\n return state.active\n}\n\n/**\n * The project locale config driving the switcher options — `null` until one is\n * installed (the studio has no locales of its own to offer).\n */\nexport function getStudioLocaleConfig(): LocaleConfig | null {\n return state.config\n}\n\n/**\n * Switch the ambient locale. Unknown locales are ignored (the switcher only\n * offers configured ones; programmatic callers get a no-op, never a crash), and\n * so is any switch attempted before a config is installed — there is nothing to\n * validate the locale against.\n */\nexport function setStudioLocale(locale: Locale): void {\n if (state.config === null) return\n if (locale === state.active || !isKnownLocale(state.config, locale)) return\n state = { config: state.config, active: locale }\n emit()\n}\n\n/**\n * Install the project's locale config (e.g. when a CMS session or builder\n * shell mounts with a starter kit's locales). The active locale is kept when\n * it is still configured, otherwise it snaps back to the new default.\n */\nexport function configureStudioLocales(config: LocaleConfig): void {\n const normalized = createLocaleConfig({ default: config.default, locales: config.locales })\n const current = state.config\n const sameConfig =\n current !== null &&\n normalized.default === current.default &&\n normalized.locales.length === current.locales.length &&\n normalized.locales.every((locale, i) => locale === current.locales[i])\n const active =\n state.active !== null && isKnownLocale(normalized, state.active)\n ? state.active\n : normalized.default\n if (sameConfig && active === state.active) return\n state = { config: normalized, active }\n emit()\n}\n\n/** Subscribe to locale/config changes. Returns the unsubscribe function. */\nexport function subscribeStudioLocale(listener: () => void): () => void {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n}\n\n/**\n * Reset to the pre-config \"not loaded\" state (tests only — module state is\n * shared). Never re-seeds a fixture config: a test that queries localized content\n * must say which locale it means, exactly like a project does.\n */\nexport function resetStudioLocale(): void {\n state = NOT_LOADED\n emit()\n}\n"]}
1
+ {"version":3,"file":"locale-store.js","sourceRoot":"","sources":["../../../src/lib/locale/locale-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAqCxD,wEAAwE;AACxE,MAAM,UAAU,GAAsB,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAA;AAEpE,IAAI,KAAK,GAAsB,UAAU,CAAA;AAEzC,MAAM,SAAS,GAAG,IAAI,GAAG,EAAc,CAAA;AAEvC,SAAS,IAAI;IACX,KAAK,MAAM,QAAQ,IAAI,SAAS;QAAE,QAAQ,EAAE,CAAA;AAC9C,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,oBAAoB;IAClC,OAAO,KAAK,CAAA;AACd,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,KAAK,CAAC,MAAM,CAAA;AACrB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,qBAAqB;IACnC,OAAO,KAAK,CAAC,MAAM,CAAA;AACrB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,MAAc;IAC5C,IAAI,KAAK,CAAC,MAAM,KAAK,IAAI;QAAE,OAAM;IACjC,IAAI,MAAM,KAAK,KAAK,CAAC,MAAM,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,CAAC;QAAE,OAAM;IAC3E,KAAK,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,CAAA;IAChD,IAAI,EAAE,CAAA;AACR,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,sBAAsB,CAAC,MAAoB;IACzD,MAAM,UAAU,GAAiB,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,EAAE,CAAA;IAC1F,MAAM,OAAO,GAAG,KAAK,CAAC,MAAM,CAAA;IAC5B,MAAM,UAAU,GACd,OAAO,KAAK,IAAI;QAChB,UAAU,CAAC,OAAO,KAAK,OAAO,CAAC,OAAO;QACtC,UAAU,CAAC,OAAO,CAAC,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,MAAM;QACpD,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAA;IACxE,MAAM,MAAM,GACV,KAAK,CAAC,MAAM,KAAK,IAAI,IAAI,aAAa,CAAC,UAAU,EAAE,KAAK,CAAC,MAAM,CAAC;QAC9D,CAAC,CAAC,KAAK,CAAC,MAAM;QACd,CAAC,CAAC,UAAU,CAAC,OAAO,CAAA;IACxB,IAAI,UAAU,IAAI,MAAM,KAAK,KAAK,CAAC,MAAM;QAAE,OAAM;IACjD,KAAK,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,CAAA;IACtC,IAAI,EAAE,CAAA;AACR,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,qBAAqB,CAAC,QAAoB;IACxD,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IACvB,OAAO,GAAG,EAAE;QACV,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;IAC5B,CAAC,CAAA;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB;IAC/B,KAAK,GAAG,UAAU,CAAA;IAClB,IAAI,EAAE,CAAA;AACR,CAAC","sourcesContent":["import { isKnownLocale } from '@elytracms/core/cms-core'\nimport type { Locale, LocaleConfig } from '@elytracms/core/cms-core'\n\n/**\n * The studio's ambient locale (EC-176) — framework-free core.\n *\n * One studio-level active locale, set once via the locale switcher, that the\n * builder canvas preview, the CMS table, and the detail editor all follow.\n * Module-level (not React context) on purpose: the playground's content\n * client (`lib/data/collection-content`) reads it at query time without any\n * provider plumbing, and React surfaces subscribe through\n * `useStudioLocale` (`useSyncExternalStore`).\n *\n * Deliberately NOT part of `lib/data/data-store` — the locale is studio\n * state, not a data source.\n *\n * EC-479/EC-483 — **before a project's locale config is installed, this store\n * holds `null`, not a locale.** It used to open on the engine's `en/de`\n * `sampleLocaleConfig` fixture, which is the worst fallback in the family: the\n * default locale is the literal STORAGE KEY for every localized field value\n * (`documents.ts:88`), so a fabricated one writes a German project's content\n * under `en` and correcting it later is a data migration, not a config edit. The\n * locale is a deliberate choice at project creation (EC-483) and reaches the\n * studio through {@link configureStudioLocales} from the project's own config —\n * so \"not loaded\" is an honest state that surfaces, never an invented one that\n * silently becomes a key.\n */\n\n/**\n * `{ config, active }` once a project's locales are installed; `{ null, null }`\n * before that. The two fields move together by construction — there is no state\n * with a config but no active locale, or an active locale nobody declared.\n */\nexport type StudioLocaleState =\n | { readonly config: LocaleConfig; readonly active: Locale }\n | { readonly config: null; readonly active: null }\n\n/** The pre-config state: no project locales have been installed yet. */\nconst NOT_LOADED: StudioLocaleState = { config: null, active: null }\n\nlet state: StudioLocaleState = NOT_LOADED\n\nconst listeners = new Set<() => void>()\n\nfunction emit(): void {\n for (const listener of listeners) listener()\n}\n\n/** Snapshot of `{ config, active }` — stable identity until the next change. */\nexport function getStudioLocaleState(): StudioLocaleState {\n return state\n}\n\n/**\n * The active studio locale every surface renders in — `null` until a project's\n * locale config is installed. A caller that genuinely needs a locale reads the\n * PROJECT's own default (`CmsStore.localeConfig.default`, the content session's\n * config); it must never substitute one of its own.\n */\nexport function getStudioLocale(): Locale | null {\n return state.active\n}\n\n/**\n * The project locale config driving the switcher options — `null` until one is\n * installed (the studio has no locales of its own to offer).\n */\nexport function getStudioLocaleConfig(): LocaleConfig | null {\n return state.config\n}\n\n/**\n * Switch the ambient locale. Unknown locales are ignored (the switcher only\n * offers configured ones; programmatic callers get a no-op, never a crash), and\n * so is any switch attempted before a config is installed — there is nothing to\n * validate the locale against.\n */\nexport function setStudioLocale(locale: Locale): void {\n if (state.config === null) return\n if (locale === state.active || !isKnownLocale(state.config, locale)) return\n state = { config: state.config, active: locale }\n emit()\n}\n\n/**\n * Install the project's locale config (when a CMS session mounts). The active\n * locale is kept when it is still configured, otherwise it snaps back to the new\n * default.\n *\n * The config arrives valid by construction: every caller hands over a\n * config-derived `LocaleConfig` that the workspace gate has already checked\n * (`default-locale-not-in-locales` is an error there), so nothing is repaired\n * here. The old `createLocaleConfig` repair existed for starter-kit locales, a\n * rung EC-574 removed — a second, silent declaration of \"the default is in the\n * set\" is exactly the drift the one gate exists to prevent.\n */\nexport function configureStudioLocales(config: LocaleConfig): void {\n const normalized: LocaleConfig = { default: config.default, locales: [...config.locales] }\n const current = state.config\n const sameConfig =\n current !== null &&\n normalized.default === current.default &&\n normalized.locales.length === current.locales.length &&\n normalized.locales.every((locale, i) => locale === current.locales[i])\n const active =\n state.active !== null && isKnownLocale(normalized, state.active)\n ? state.active\n : normalized.default\n if (sameConfig && active === state.active) return\n state = { config: normalized, active }\n emit()\n}\n\n/** Subscribe to locale/config changes. Returns the unsubscribe function. */\nexport function subscribeStudioLocale(listener: () => void): () => void {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n}\n\n/**\n * Reset to the pre-config \"not loaded\" state (tests only — module state is\n * shared). Never re-seeds a fixture config: a test that queries localized content\n * must say which locale it means, exactly like a project does.\n */\nexport function resetStudioLocale(): void {\n state = NOT_LOADED\n emit()\n}\n"]}
@@ -34,11 +34,15 @@ export declare const VIEWS_COLLECTION = "cms-saved-views";
34
34
  * Convex-specific.
35
35
  */
36
36
  /**
37
- * Collections owned by studio subsystems (data sources, saved views). They live
38
- * in the same persisted schema but are NOT content: the CMS session, the
39
- * sidebar, and the content preview engine must never offer them as authorable
40
- * collections. Routes/redirects are NO LONGER system collections (EC-207): they
41
- * are config-sourced and never persisted to the backend.
37
+ * Collections owned by studio subsystems (saved views). They live in the same
38
+ * persisted schema but are NOT content: the CMS session, the sidebar, and the
39
+ * content preview engine must never offer them as authorable collections.
40
+ * Routes/redirects are NO LONGER system collections (EC-207): they are
41
+ * config-sourced and never persisted to the backend. EC-574: `cms-data-sources`
42
+ * (the data-binding subsystem's source records) is no longer a system id either
43
+ * — a deployment that still holds such rows now sees them as documents of a
44
+ * collection the config does not declare, exactly like any other orphaned row
45
+ * (`documents.clear` + re-seed is the ruled clean-up, EC-576).
42
46
  */
43
47
  export declare const STUDIO_SYSTEM_COLLECTION_IDS: ReadonlySet<string>;
44
48
  /** The persisted CMS content of a project: schema + documents, system collections excluded. */
@@ -1,5 +1,4 @@
1
1
  import { documentKey } from '@elytracms/core/cms-core';
2
- import { DATA_SOURCES_COLLECTION } from '../data/sources-store.js';
3
2
  /**
4
3
  * Legacy marker (EC-246b): editor-owned saved views are retired — list
5
4
  * presentation is config-as-code now (named views live in `cms/`). The id is kept
@@ -33,16 +32,17 @@ export const VIEWS_COLLECTION = 'cms-saved-views';
33
32
  * Convex-specific.
34
33
  */
35
34
  /**
36
- * Collections owned by studio subsystems (data sources, saved views). They live
37
- * in the same persisted schema but are NOT content: the CMS session, the
38
- * sidebar, and the content preview engine must never offer them as authorable
39
- * collections. Routes/redirects are NO LONGER system collections (EC-207): they
40
- * are config-sourced and never persisted to the backend.
35
+ * Collections owned by studio subsystems (saved views). They live in the same
36
+ * persisted schema but are NOT content: the CMS session, the sidebar, and the
37
+ * content preview engine must never offer them as authorable collections.
38
+ * Routes/redirects are NO LONGER system collections (EC-207): they are
39
+ * config-sourced and never persisted to the backend. EC-574: `cms-data-sources`
40
+ * (the data-binding subsystem's source records) is no longer a system id either
41
+ * — a deployment that still holds such rows now sees them as documents of a
42
+ * collection the config does not declare, exactly like any other orphaned row
43
+ * (`documents.clear` + re-seed is the ruled clean-up, EC-576).
41
44
  */
42
- export const STUDIO_SYSTEM_COLLECTION_IDS = new Set([
43
- DATA_SOURCES_COLLECTION,
44
- VIEWS_COLLECTION,
45
- ]);
45
+ export const STUDIO_SYSTEM_COLLECTION_IDS = new Set([VIEWS_COLLECTION]);
46
46
  /**
47
47
  * Load the persisted CMS content state. `null` when the project has no
48
48
  * persisted content schema (first run — the session seeds fixtures and the
@@ -1 +1 @@
1
- {"version":3,"file":"session-hydration.js","sourceRoot":"","sources":["../../../src/lib/operations/session-hydration.ts"],"names":[],"mappings":"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","sourcesContent":["import { documentKey } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CollectionDef } from '@elytracms/core/cms-core'\nimport type { AssetRecord } from '@elytracms/core/persistence'\nimport { DATA_SOURCES_COLLECTION } from '../data/sources-store'\nimport type { OperationResult, OperationsClient } from './operations-package'\n\n/**\n * Legacy marker (EC-246b): editor-owned saved views are retired — list\n * presentation is config-as-code now (named views live in `cms/`). The id is kept\n * only so hydration keeps excluding any `cms-saved-views` documents a pre-EC-246b\n * deployment persisted, so they never surface as content.\n */\nexport const VIEWS_COLLECTION = 'cms-saved-views'\n\n/**\n * Persisted-session hydration (Milestone B sign-off fix, EC-177 gap 1).\n *\n * Against a persistent backend (Convex mode) a studio session must BOOT from\n * what is persisted — the saved CMS schema + documents (+ asset records) — and\n * fall back to the deterministic fixtures only when the project has nothing\n * persisted yet (first run). Before this, every session seeded from fixtures,\n * so reloads hid the editor's work.\n *\n * EC-287: the DB-resident project graph and its publish path are retired —\n * layouts/config/schema/components are CODE, the DB holds content only. A\n * session no longer hydrates a graph revision or graph-publishing state; CMS\n * content (with per-document `publishedVersion`, EC-224) is the only persisted\n * surface a session boots from.\n *\n * This module is the pure load half: read-only `@elytracms/core/operations` queries\n * composed into one hydration result. `not-found` is a *normal* answer (the\n * first-run fallback); every other failure propagates as the structured\n * operation error so the gate renders it — never a silent fixture fallback.\n * The React gate that applies it lives in\n * `components/studio/project-session.tsx`. EC-361: the playground calls the\n * SAME loader (over the seeded in-memory adapter, through operations — AD-17)\n * to hydrate the canvas/preview content session; only the render-gating is\n * Convex-specific.\n */\n\n/**\n * Collections owned by studio subsystems (data sources, saved views). They live\n * in the same persisted schema but are NOT content: the CMS session, the\n * sidebar, and the content preview engine must never offer them as authorable\n * collections. Routes/redirects are NO LONGER system collections (EC-207): they\n * are config-sourced and never persisted to the backend.\n */\nexport const STUDIO_SYSTEM_COLLECTION_IDS: ReadonlySet<string> = new Set([\n DATA_SOURCES_COLLECTION,\n VIEWS_COLLECTION,\n])\n\n/** The persisted CMS content of a project: schema + documents, system collections excluded. */\nexport interface HydratedCms {\n collections: CollectionDef[]\n documents: CmsDocument[]\n /**\n * Published document keys (EC-224), via the version pointer: a record is\n * published iff `publishedVersion` is set. The session boots its\n * `publishedKeys` from this — published-ness is the pointer, nothing else.\n */\n publishedKeys: string[]\n /**\n * Per-document last-write timestamps (EC-445), keyed by `documentKey`\n * (`collection:id`) → the persisted `cmsDocuments.updatedAt` ISO string. Backs the\n * `$updatedAt` list column; the session seeds its `documentUpdatedAt` map from this.\n * Optional so pre-EC-445 fixtures/callers stay valid (the session falls back to a\n * session-start stamp for the seeded documents); `loadHydratedCms` always sets it.\n */\n timestamps?: Record<string, string>\n /**\n * Per-document FIRST-publish instants (EC-447), keyed by `documentKey`\n * (`collection:id`) → the persisted `cmsDocuments.publishedAt` ISO string. Present\n * only for documents that carry one (currently published, with a recorded publish\n * instant); the session seeds its `documentPublishedAt` map from this. Backs the\n * `$publishedAt` list column. UNLIKE {@link timestamps} there is no session-start\n * fallback — an absent entry is a real \"no publish date\" (EmDash), and the store\n * re-stamps/clears it authoritatively on publish/unpublish. Optional so pre-EC-447\n * fixtures/callers stay valid; `loadHydratedCms` always sets it.\n */\n publishTimestamps?: Record<string, string>\n /**\n * Document keys whose working draft carries UNPUBLISHED CHANGES (EC-449) — the\n * persisted `cmsDocuments.hasUnpublishedChanges === true` set, keyed like\n * {@link publishedKeys}. Backs the amber \"changed\" state of the `$state` list\n * dot; the session seeds its changed-set from this and re-stamps it\n * authoritatively on save/publish/unpublish from the returned records. Only\n * `true` flags contribute — an absent entry means \"no unpublished changes\"\n * (`false` or not yet materialized both render the same dot). Optional so\n * pre-EC-449 fixtures/callers stay valid; `loadHydratedCms` always sets it.\n */\n changedKeys?: string[]\n /**\n * The project's asset records (the `assets` table — NOT cms `asset`-collection\n * documents). The preview content engine wires these into its lookup so listing/\n * relation results resolve their `asset`-typed FIELDS (e.g. a ProductGrid product's\n * `gallery`) to delivery shape — matching what the renderer's `resolveAsset` already\n * does for static `asset` props (EC-262). Without them, the studio canvas renders\n * such nested images blank even though the documents reference real assets.\n */\n assets: AssetRecord[]\n}\n\n/** Everything a Convex-mode session boots from. `null` fields mean \"nothing persisted\" (first run). */\nexport interface HydratedProjectSession {\n /** Persisted CMS schema + documents, or `null` when no content schema was ever saved. */\n cms: HydratedCms | null\n}\n\n/**\n * Load the persisted CMS content state. `null` when the project has no\n * persisted content schema (first run — the session seeds fixtures and the\n * persister's baseline will persist them on the first edit). A schema that\n * contains only studio system collections counts as \"no content persisted\":\n * system stores (saved views, routes) may create the schema record before any\n * content baseline ran.\n */\nexport async function loadHydratedCms(\n operations: OperationsClient,\n projectId: string,\n): Promise<OperationResult<HydratedCms | null>> {\n const schema = await operations.schema.get({ projectId })\n if (!schema.ok) {\n return schema.error.code === 'not-found' ? { ok: true, value: null } : schema\n }\n const collections = schema.value.collections.filter(\n (collection) => !STUDIO_SYSTEM_COLLECTION_IDS.has(collection.id),\n )\n if (collections.length === 0) return { ok: true, value: null }\n const documents = await operations.documents.list({ projectId })\n if (!documents.ok) return documents\n // The project's asset records (assets table), wired into the preview lookup so\n // listing/relation results resolve their nested `asset` fields (EC-262). Non-fatal:\n // a failed asset list degrades previews to no images, never blocks the session.\n const assets = await operations.assets.list({ projectId })\n const contentIds = new Set(collections.map((collection) => collection.id))\n const contentRecords = documents.value.filter((record) =>\n contentIds.has(record.document.collection),\n )\n // EC-224: a record is published when its version pointer is set — the same\n // gate delivery reads. There is no flat-state fallback (the field is gone).\n const isRecordPublished = (record: (typeof contentRecords)[number]) =>\n record.publishedVersion !== undefined\n return {\n ok: true,\n value: {\n collections,\n // The live row hydrates AS PERSISTED — a continuous draft (EC-224). We no\n // longer stamp a `state:'published'` mirror: published-ness is the version\n // pointer (`publishedKeys`), which every studio reader now consults (the\n // table badge/$state via `isPublished`, the preview engine via the pin).\n documents: contentRecords.map((record) => record.document),\n publishedKeys: contentRecords\n .filter(isRecordPublished)\n .map((record) => documentKey(record.document)),\n // EC-445: carry each record's `updatedAt` onto the session for the $updatedAt\n // column — a per-document metadata accessor, exactly like `publishedKeys`.\n timestamps: Object.fromEntries(\n contentRecords.map((record) => [documentKey(record.document), record.updatedAt]),\n ),\n // EC-447: carry each PUBLISHED record's materialized `publishedAt` for the\n // $publishedAt column. Only records that actually have one contribute (no\n // session-start fallback) — an absent entry is a real \"no publish date\".\n publishTimestamps: Object.fromEntries(\n contentRecords\n .filter((record) => record.publishedAt !== undefined)\n .map((record) => [documentKey(record.document), record.publishedAt as string]),\n ),\n // EC-449: the documents whose draft carries unpublished changes (the amber\n // list dot) — only materialized `true` flags contribute (absent/false both\n // mean \"not changed\"; the flag only exists on published docs).\n changedKeys: contentRecords\n .filter((record) => record.hasUnpublishedChanges === true)\n .map((record) => documentKey(record.document)),\n assets: assets.ok ? assets.value : [],\n },\n }\n}\n\n/**\n * Load everything a project session hydrates from. Post-EC-287 that is just the\n * persisted CMS content (schema + documents + assets): the project graph and its\n * publish path are gone, so there is no graph revision or graph-publishing state\n * to load. Per-document `publishedVersion` (EC-224) is the only published surface.\n */\nexport async function loadProjectSession(\n operations: OperationsClient,\n projectId: string,\n): Promise<OperationResult<HydratedProjectSession>> {\n const cms = await loadHydratedCms(operations, projectId)\n if (!cms.ok) return cms\n return { ok: true, value: { cms: cms.value } }\n}\n"]}
1
+ {"version":3,"file":"session-hydration.js","sourceRoot":"","sources":["../../../src/lib/operations/session-hydration.ts"],"names":[],"mappings":"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","sourcesContent":["import { documentKey } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CollectionDef } from '@elytracms/core/cms-core'\nimport type { AssetRecord } from '@elytracms/core/persistence'\nimport type { OperationResult, OperationsClient } from './operations-package'\n\n/**\n * Legacy marker (EC-246b): editor-owned saved views are retired — list\n * presentation is config-as-code now (named views live in `cms/`). The id is kept\n * only so hydration keeps excluding any `cms-saved-views` documents a pre-EC-246b\n * deployment persisted, so they never surface as content.\n */\nexport const VIEWS_COLLECTION = 'cms-saved-views'\n\n/**\n * Persisted-session hydration (Milestone B sign-off fix, EC-177 gap 1).\n *\n * Against a persistent backend (Convex mode) a studio session must BOOT from\n * what is persisted — the saved CMS schema + documents (+ asset records) — and\n * fall back to the deterministic fixtures only when the project has nothing\n * persisted yet (first run). Before this, every session seeded from fixtures,\n * so reloads hid the editor's work.\n *\n * EC-287: the DB-resident project graph and its publish path are retired —\n * layouts/config/schema/components are CODE, the DB holds content only. A\n * session no longer hydrates a graph revision or graph-publishing state; CMS\n * content (with per-document `publishedVersion`, EC-224) is the only persisted\n * surface a session boots from.\n *\n * This module is the pure load half: read-only `@elytracms/core/operations` queries\n * composed into one hydration result. `not-found` is a *normal* answer (the\n * first-run fallback); every other failure propagates as the structured\n * operation error so the gate renders it — never a silent fixture fallback.\n * The React gate that applies it lives in\n * `components/studio/project-session.tsx`. EC-361: the playground calls the\n * SAME loader (over the seeded in-memory adapter, through operations — AD-17)\n * to hydrate the canvas/preview content session; only the render-gating is\n * Convex-specific.\n */\n\n/**\n * Collections owned by studio subsystems (saved views). They live in the same\n * persisted schema but are NOT content: the CMS session, the sidebar, and the\n * content preview engine must never offer them as authorable collections.\n * Routes/redirects are NO LONGER system collections (EC-207): they are\n * config-sourced and never persisted to the backend. EC-574: `cms-data-sources`\n * (the data-binding subsystem's source records) is no longer a system id either\n * — a deployment that still holds such rows now sees them as documents of a\n * collection the config does not declare, exactly like any other orphaned row\n * (`documents.clear` + re-seed is the ruled clean-up, EC-576).\n */\nexport const STUDIO_SYSTEM_COLLECTION_IDS: ReadonlySet<string> = new Set([VIEWS_COLLECTION])\n\n/** The persisted CMS content of a project: schema + documents, system collections excluded. */\nexport interface HydratedCms {\n collections: CollectionDef[]\n documents: CmsDocument[]\n /**\n * Published document keys (EC-224), via the version pointer: a record is\n * published iff `publishedVersion` is set. The session boots its\n * `publishedKeys` from this — published-ness is the pointer, nothing else.\n */\n publishedKeys: string[]\n /**\n * Per-document last-write timestamps (EC-445), keyed by `documentKey`\n * (`collection:id`) → the persisted `cmsDocuments.updatedAt` ISO string. Backs the\n * `$updatedAt` list column; the session seeds its `documentUpdatedAt` map from this.\n * Optional so pre-EC-445 fixtures/callers stay valid (the session falls back to a\n * session-start stamp for the seeded documents); `loadHydratedCms` always sets it.\n */\n timestamps?: Record<string, string>\n /**\n * Per-document FIRST-publish instants (EC-447), keyed by `documentKey`\n * (`collection:id`) → the persisted `cmsDocuments.publishedAt` ISO string. Present\n * only for documents that carry one (currently published, with a recorded publish\n * instant); the session seeds its `documentPublishedAt` map from this. Backs the\n * `$publishedAt` list column. UNLIKE {@link timestamps} there is no session-start\n * fallback — an absent entry is a real \"no publish date\" (EmDash), and the store\n * re-stamps/clears it authoritatively on publish/unpublish. Optional so pre-EC-447\n * fixtures/callers stay valid; `loadHydratedCms` always sets it.\n */\n publishTimestamps?: Record<string, string>\n /**\n * Document keys whose working draft carries UNPUBLISHED CHANGES (EC-449) — the\n * persisted `cmsDocuments.hasUnpublishedChanges === true` set, keyed like\n * {@link publishedKeys}. Backs the amber \"changed\" state of the `$state` list\n * dot; the session seeds its changed-set from this and re-stamps it\n * authoritatively on save/publish/unpublish from the returned records. Only\n * `true` flags contribute — an absent entry means \"no unpublished changes\"\n * (`false` or not yet materialized both render the same dot). Optional so\n * pre-EC-449 fixtures/callers stay valid; `loadHydratedCms` always sets it.\n */\n changedKeys?: string[]\n /**\n * The project's asset records (the `assets` table — NOT cms `asset`-collection\n * documents). The preview content engine wires these into its lookup so listing/\n * relation results resolve their `asset`-typed FIELDS (e.g. a ProductGrid product's\n * `gallery`) to delivery shape — matching what the renderer's `resolveAsset` already\n * does for static `asset` props (EC-262). Without them, the studio canvas renders\n * such nested images blank even though the documents reference real assets.\n */\n assets: AssetRecord[]\n}\n\n/** Everything a Convex-mode session boots from. `null` fields mean \"nothing persisted\" (first run). */\nexport interface HydratedProjectSession {\n /** Persisted CMS schema + documents, or `null` when no content schema was ever saved. */\n cms: HydratedCms | null\n}\n\n/**\n * Load the persisted CMS content state. `null` when the project has no\n * persisted content schema (first run — the session seeds fixtures and the\n * persister's baseline will persist them on the first edit). A schema that\n * contains only studio system collections counts as \"no content persisted\":\n * system stores (saved views, routes) may create the schema record before any\n * content baseline ran.\n */\nexport async function loadHydratedCms(\n operations: OperationsClient,\n projectId: string,\n): Promise<OperationResult<HydratedCms | null>> {\n const schema = await operations.schema.get({ projectId })\n if (!schema.ok) {\n return schema.error.code === 'not-found' ? { ok: true, value: null } : schema\n }\n const collections = schema.value.collections.filter(\n (collection) => !STUDIO_SYSTEM_COLLECTION_IDS.has(collection.id),\n )\n if (collections.length === 0) return { ok: true, value: null }\n const documents = await operations.documents.list({ projectId })\n if (!documents.ok) return documents\n // The project's asset records (assets table), wired into the preview lookup so\n // listing/relation results resolve their nested `asset` fields (EC-262). Non-fatal:\n // a failed asset list degrades previews to no images, never blocks the session.\n const assets = await operations.assets.list({ projectId })\n const contentIds = new Set(collections.map((collection) => collection.id))\n const contentRecords = documents.value.filter((record) =>\n contentIds.has(record.document.collection),\n )\n // EC-224: a record is published when its version pointer is set — the same\n // gate delivery reads. There is no flat-state fallback (the field is gone).\n const isRecordPublished = (record: (typeof contentRecords)[number]) =>\n record.publishedVersion !== undefined\n return {\n ok: true,\n value: {\n collections,\n // The live row hydrates AS PERSISTED — a continuous draft (EC-224). We no\n // longer stamp a `state:'published'` mirror: published-ness is the version\n // pointer (`publishedKeys`), which every studio reader now consults (the\n // table badge/$state via `isPublished`, the preview engine via the pin).\n documents: contentRecords.map((record) => record.document),\n publishedKeys: contentRecords\n .filter(isRecordPublished)\n .map((record) => documentKey(record.document)),\n // EC-445: carry each record's `updatedAt` onto the session for the $updatedAt\n // column — a per-document metadata accessor, exactly like `publishedKeys`.\n timestamps: Object.fromEntries(\n contentRecords.map((record) => [documentKey(record.document), record.updatedAt]),\n ),\n // EC-447: carry each PUBLISHED record's materialized `publishedAt` for the\n // $publishedAt column. Only records that actually have one contribute (no\n // session-start fallback) — an absent entry is a real \"no publish date\".\n publishTimestamps: Object.fromEntries(\n contentRecords\n .filter((record) => record.publishedAt !== undefined)\n .map((record) => [documentKey(record.document), record.publishedAt as string]),\n ),\n // EC-449: the documents whose draft carries unpublished changes (the amber\n // list dot) — only materialized `true` flags contribute (absent/false both\n // mean \"not changed\"; the flag only exists on published docs).\n changedKeys: contentRecords\n .filter((record) => record.hasUnpublishedChanges === true)\n .map((record) => documentKey(record.document)),\n assets: assets.ok ? assets.value : [],\n },\n }\n}\n\n/**\n * Load everything a project session hydrates from. Post-EC-287 that is just the\n * persisted CMS content (schema + documents + assets): the project graph and its\n * publish path are gone, so there is no graph revision or graph-publishing state\n * to load. Per-document `publishedVersion` (EC-224) is the only published surface.\n */\nexport async function loadProjectSession(\n operations: OperationsClient,\n projectId: string,\n): Promise<OperationResult<HydratedProjectSession>> {\n const cms = await loadHydratedCms(operations, projectId)\n if (!cms.ok) return cms\n return { ok: true, value: { cms: cms.value } }\n}\n"]}
@@ -1,5 +1,6 @@
1
+ import { ConvexError } from 'convex/values';
1
2
  import { documentKey, documentSchema } from '@elytracms/core/cms-core';
2
- import { RecordNotFoundError, PersistenceValidationError, compareReferenceEntries, highestRole, isKnownRole, validateForPersistence, } from '@elytracms/core/persistence';
3
+ import { RecordNotFoundError, PersistenceValidationError, assetCreateDefaults, assetRecordFromRow, compareReferenceEntries, highestRole, isKnownRole, persistenceIssueSchema, presentWriteKeys, redirectEntryFromRow, validateForPersistence, } from '@elytracms/core/persistence';
3
4
  import { normalizeInviteEmail } from '@elytracms/core/persistence';
4
5
  import { queryRef, mutationRef } from './convex-client.js';
5
6
  import { ConvexBlobStorage } from './convex-blob-storage.js';
@@ -17,9 +18,14 @@ function newId(prefix) {
17
18
  * carries a stable placeholder rather than DB-row timestamps.
18
19
  */
19
20
  const CONFIG_SCHEMA_TIMESTAMP = '2026-01-01T00:00:00.000Z';
20
- function docRecordFromDoc(doc) {
21
+ /**
22
+ * Row → contract record. `projectId` is the caller's (the row has none): before
23
+ * EC-577 this copied the row's nonexistent `projectId`, so every connected
24
+ * `CmsDocumentRecord.projectId` was `undefined` against a `min(1)` schema (MODE #6).
25
+ */
26
+ function docRecordFromDoc(doc, projectId) {
21
27
  return {
22
- projectId: doc.projectId,
28
+ projectId,
23
29
  document: doc.document,
24
30
  ...(doc.publishedVersion !== undefined ? { publishedVersion: doc.publishedVersion } : {}),
25
31
  // EC-447: carry the materialized publish instant onto the contract record so the
@@ -104,7 +110,7 @@ class ConvexCmsRepository {
104
110
  createdAt: now,
105
111
  updatedAt: now,
106
112
  }));
107
- return docRecordFromDoc(doc);
113
+ return docRecordFromDoc(doc, projectId);
108
114
  }
109
115
  async getDocument(projectId, ref) {
110
116
  const found = await this.findDocument(projectId, ref);
@@ -117,7 +123,7 @@ class ConvexCmsRepository {
117
123
  collection: ref.collection,
118
124
  docId: ref.id,
119
125
  }));
120
- return doc ? docRecordFromDoc(doc) : undefined;
126
+ return doc ? docRecordFromDoc(doc, projectId) : undefined;
121
127
  }
122
128
  async listDocuments(projectId, collection) {
123
129
  const docs = (await this.client.query(queryRef('cms:listDocuments'), {
@@ -126,7 +132,7 @@ class ConvexCmsRepository {
126
132
  // EC-153 parity: deterministic document-key ordering (Convex index order
127
133
  // within a project is insertion order, so we sort client-side).
128
134
  return docs
129
- .map(docRecordFromDoc)
135
+ .map((doc) => docRecordFromDoc(doc, projectId))
130
136
  .sort((a, b) => (documentKey(a.document) < documentKey(b.document) ? -1 : 1));
131
137
  }
132
138
  async listPublishedDocuments(projectId, collection) {
@@ -148,6 +154,10 @@ class ConvexCmsRepository {
148
154
  docId: ref.id,
149
155
  });
150
156
  }
157
+ async clearDocuments(_projectId) {
158
+ // EC-576: one deployment is one project — the mutation clears both tables at once.
159
+ await this.client.mutation(mutationRef('cms:clearDocuments'), {});
160
+ }
151
161
  async saveDocumentVersion(projectId, snapshot, options = {}) {
152
162
  const parsed = documentSchema.parse(snapshot);
153
163
  const doc = (await this.client.mutation(mutationRef('cms:saveDocumentVersion'), {
@@ -198,7 +208,7 @@ class ConvexCmsRepository {
198
208
  updatedAt: now,
199
209
  publishedAt: now,
200
210
  }));
201
- return docRecordFromDoc(doc);
211
+ return docRecordFromDoc(doc, projectId);
202
212
  }
203
213
  async unpublishDocument(projectId, ref) {
204
214
  const existing = await this.getDocument(projectId, ref);
@@ -216,30 +226,24 @@ class ConvexCmsRepository {
216
226
  docId: ref.id,
217
227
  updatedAt: this.clock(),
218
228
  }));
219
- return docRecordFromDoc(doc);
229
+ return docRecordFromDoc(doc, projectId);
220
230
  }
221
231
  }
222
- /** Map a Convex asset doc onto the contract record (assetId → id, drop _id/_creationTime). */
223
- function assetRecordFromDoc(doc) {
224
- return {
225
- id: doc.assetId,
226
- projectId: doc.projectId,
227
- filename: doc.filename,
228
- contentType: doc.contentType,
229
- byteSize: doc.byteSize,
230
- ...(doc.width !== undefined ? { width: doc.width } : {}),
231
- ...(doc.height !== undefined ? { height: doc.height } : {}),
232
- ...(doc.alt !== undefined ? { alt: doc.alt } : {}),
233
- ...(doc.title !== undefined ? { title: doc.title } : {}),
234
- ...(doc.url !== undefined ? { url: doc.url } : {}),
235
- ...(doc.focalPoint !== undefined ? { focalPoint: doc.focalPoint } : {}),
236
- ...(doc.thumb !== undefined ? { thumb: doc.thumb } : {}),
237
- ...(doc.lqip !== undefined ? { lqip: doc.lqip } : {}),
238
- ...(doc.meta !== undefined ? { meta: doc.meta } : {}),
239
- storageKey: doc.storageKey,
240
- createdAt: doc.createdAt,
241
- updatedAt: doc.updatedAt,
242
- };
232
+ /**
233
+ * A rail write the handler rejected (EC-577): `resolveRecordWrite` found the write
234
+ * invalid inside the transaction and threw a `validation` `ConvexError` — mapped
235
+ * back onto the provider-neutral {@link PersistenceValidationError} the in-memory
236
+ * adapter throws for the same write. `ConvexError` data survives a production
237
+ * deployment's error redaction, unlike a plain thrown message.
238
+ */
239
+ function asRailWriteError(error) {
240
+ if (!(error instanceof ConvexError))
241
+ return error;
242
+ const data = error.data;
243
+ if (data?.code !== 'validation' || typeof data.message !== 'string')
244
+ return error;
245
+ const issues = persistenceIssueSchema.array().safeParse(data.issues);
246
+ return new PersistenceValidationError(data.message, issues.success ? issues.data : []);
243
247
  }
244
248
  class ConvexAssetRepository {
245
249
  client;
@@ -250,27 +254,23 @@ class ConvexAssetRepository {
250
254
  }
251
255
  async upsertAsset(projectId, input) {
252
256
  const now = this.clock();
253
- const id = input.id ?? newId('asset');
254
- const doc = (await this.client.mutation(mutationRef('assets:upsert'), {
257
+ const { id: givenId, ...write } = input;
258
+ const id = givenId ?? newId('asset');
259
+ // EC-577: send the write as written — absent keys stay absent (keep), `null`
260
+ // crosses the wire as the clear. The handler resolves it against the stored
261
+ // row inside the transaction; the create defaults ride along for a create.
262
+ const doc = (await this.client
263
+ .mutation(mutationRef('assets:upsert'), {
255
264
  assetId: id,
256
- filename: input.filename,
257
- contentType: input.contentType,
258
- byteSize: input.byteSize,
259
- ...(input.width !== undefined ? { width: input.width } : {}),
260
- ...(input.height !== undefined ? { height: input.height } : {}),
261
- ...(input.alt !== undefined ? { alt: input.alt } : {}),
262
- // EC-153 parity: `title` (EC-152 metadata) must persist, not silently drop.
263
- ...(input.title !== undefined ? { title: input.title } : {}),
264
- ...(input.url !== undefined ? { url: input.url } : {}),
265
- ...(input.focalPoint !== undefined ? { focalPoint: input.focalPoint } : {}),
266
- ...(input.thumb !== undefined ? { thumb: input.thumb } : {}),
267
- ...(input.lqip !== undefined ? { lqip: input.lqip } : {}),
268
- ...(input.meta !== undefined ? { meta: input.meta } : {}),
269
- storageKey: input.storageKey ?? `${projectId}/${id}`,
265
+ ...presentWriteKeys(write),
266
+ defaults: assetCreateDefaults(projectId, id),
270
267
  createdAt: now,
271
268
  updatedAt: now,
269
+ })
270
+ .catch((error) => {
271
+ throw asRailWriteError(error);
272
272
  }));
273
- return assetRecordFromDoc(doc);
273
+ return assetRecordFromRow(doc, projectId);
274
274
  }
275
275
  async getAsset(projectId, id) {
276
276
  const found = await this.findAsset(projectId, id);
@@ -282,11 +282,11 @@ class ConvexAssetRepository {
282
282
  const doc = (await this.client.query(queryRef('assets:get'), {
283
283
  assetId: id,
284
284
  }));
285
- return doc ? assetRecordFromDoc(doc) : undefined;
285
+ return doc ? assetRecordFromRow(doc, projectId) : undefined;
286
286
  }
287
- async listAssets(_projectId) {
287
+ async listAssets(projectId) {
288
288
  const docs = (await this.client.query(queryRef('assets:list'), {}));
289
- return docs.map(assetRecordFromDoc);
289
+ return docs.map((doc) => assetRecordFromRow(doc, projectId));
290
290
  }
291
291
  async removeAsset(projectId, id) {
292
292
  // EC-153 parity: removing an unknown asset is not-found, never a no-op.
@@ -612,25 +612,12 @@ class ConvexInviteRepository {
612
612
  }
613
613
  }
614
614
  }
615
- function redirectEntryFromDoc(doc, projectId) {
616
- return {
617
- id: doc.redirectId,
618
- projectId,
619
- from: doc.from,
620
- to: doc.to,
621
- permanent: doc.permanent,
622
- enabled: doc.enabled,
623
- ...(doc.meta !== undefined ? { meta: doc.meta } : {}),
624
- createdAt: doc.createdAt,
625
- updatedAt: doc.updatedAt,
626
- };
627
- }
628
615
  /**
629
616
  * Convex-backed {@link RedirectRepository} (EC-303) — editorial redirects as data
630
617
  * (not config). Content-gated CRUD over the `redirects` table; the studio's
631
- * editorial surface + the delivery snapshot both read through here. The editorial
632
- * surface sends a full record on save, so create-defaults (permanent/enabled) also
633
- * cover edits.
618
+ * editorial surface + the delivery snapshot both read through here. A save sends
619
+ * the write as written (EC-577): the create defaults live in the record schema and
620
+ * apply inside the handler's transaction, only when the save creates the redirect.
634
621
  */
635
622
  class ConvexRedirectRepository {
636
623
  client;
@@ -641,18 +628,19 @@ class ConvexRedirectRepository {
641
628
  }
642
629
  async saveRedirect(projectId, input) {
643
630
  const now = this.clock();
644
- const id = input.id ?? newId('redirect');
645
- const doc = (await this.client.mutation(mutationRef('redirects:save'), {
631
+ const { id: givenId, ...write } = input;
632
+ const id = givenId ?? newId('redirect');
633
+ const doc = (await this.client
634
+ .mutation(mutationRef('redirects:save'), {
646
635
  redirectId: id,
647
- from: input.from,
648
- to: input.to,
649
- permanent: input.permanent ?? true,
650
- enabled: input.enabled ?? true,
651
- ...(input.meta !== undefined ? { meta: input.meta } : {}),
636
+ ...presentWriteKeys(write),
652
637
  createdAt: now,
653
638
  updatedAt: now,
639
+ })
640
+ .catch((error) => {
641
+ throw asRailWriteError(error);
654
642
  }));
655
- return redirectEntryFromDoc(doc, projectId);
643
+ return redirectEntryFromRow(doc, projectId);
656
644
  }
657
645
  async getRedirect(projectId, id) {
658
646
  const found = await this.findRedirect(projectId, id);
@@ -664,12 +652,12 @@ class ConvexRedirectRepository {
664
652
  const doc = (await this.client.query(queryRef('redirects:get'), {
665
653
  redirectId: id,
666
654
  }));
667
- return doc ? redirectEntryFromDoc(doc, projectId) : undefined;
655
+ return doc ? redirectEntryFromRow(doc, projectId) : undefined;
668
656
  }
669
657
  async listRedirects(projectId) {
670
658
  const docs = (await this.client.query(queryRef('redirects:list'), {}));
671
659
  return docs
672
- .map((doc) => redirectEntryFromDoc(doc, projectId))
660
+ .map((doc) => redirectEntryFromRow(doc, projectId))
673
661
  .sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
674
662
  }
675
663
  async removeRedirect(projectId, id) {