@elytracms/studio 0.0.92 → 0.0.93

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 (241) hide show
  1. package/convex/asset-alt-migration.test.ts +127 -0
  2. package/convex/asset-replace.test.ts +168 -0
  3. package/convex/asset-revalidate.test.ts +6 -6
  4. package/convex/assets.ts +41 -5
  5. package/convex/cms.ts +25 -11
  6. package/convex/contract-parity.test.ts +18 -3
  7. package/convex/document-revalidate.test.ts +10 -12
  8. package/convex/orphan-hydration.test.ts +116 -0
  9. package/convex/rail-write-parity.test.ts +11 -8
  10. package/convex/schema.ts +6 -0
  11. package/dist/components/ai/proposal-card.js +2 -1
  12. package/dist/components/ai/proposal-card.js.map +1 -1
  13. package/dist/components/ai/translate-document-dialog.js +4 -2
  14. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  15. package/dist/components/ai/translate-skipped.d.ts +19 -0
  16. package/dist/components/ai/translate-skipped.js +46 -0
  17. package/dist/components/ai/translate-skipped.js.map +1 -0
  18. package/dist/components/assets/asset-detail.js +18 -8
  19. package/dist/components/assets/asset-detail.js.map +1 -1
  20. package/dist/components/assets/asset-metadata.d.ts +15 -4
  21. package/dist/components/assets/asset-metadata.js +15 -2
  22. package/dist/components/assets/asset-metadata.js.map +1 -1
  23. package/dist/components/assets/asset-search.js +4 -1
  24. package/dist/components/assets/asset-search.js.map +1 -1
  25. package/dist/components/assets/asset-selection.d.ts +0 -8
  26. package/dist/components/assets/asset-selection.js +4 -23
  27. package/dist/components/assets/asset-selection.js.map +1 -1
  28. package/dist/components/assets/asset-thumb.js +3 -2
  29. package/dist/components/assets/asset-thumb.js.map +1 -1
  30. package/dist/components/assets/assets-collection-view-source.d.ts +3 -3
  31. package/dist/components/assets/assets-collection-view-source.js +9 -5
  32. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  33. package/dist/components/assets/assets-store.d.ts +14 -0
  34. package/dist/components/assets/assets-store.js +23 -1
  35. package/dist/components/assets/assets-store.js.map +1 -1
  36. package/dist/components/assets/replace-file.d.ts +22 -0
  37. package/dist/components/assets/replace-file.js +59 -0
  38. package/dist/components/assets/replace-file.js.map +1 -0
  39. package/dist/components/builder/blocks-model.d.ts +14 -26
  40. package/dist/components/builder/blocks-model.js +35 -41
  41. package/dist/components/builder/blocks-model.js.map +1 -1
  42. package/dist/components/builder/blocks-panel.d.ts +7 -3
  43. package/dist/components/builder/blocks-panel.js +6 -5
  44. package/dist/components/builder/blocks-panel.js.map +1 -1
  45. package/dist/components/builder/builder-store.d.ts +104 -15
  46. package/dist/components/builder/builder-store.js +156 -31
  47. package/dist/components/builder/builder-store.js.map +1 -1
  48. package/dist/components/builder/canvas-body.d.ts +30 -0
  49. package/dist/components/builder/canvas-body.js +38 -0
  50. package/dist/components/builder/canvas-body.js.map +1 -0
  51. package/dist/components/builder/canvas-chrome-css.js +7 -0
  52. package/dist/components/builder/canvas-chrome-css.js.map +1 -1
  53. package/dist/components/builder/canvas-dnd.d.ts +7 -7
  54. package/dist/components/builder/canvas-dnd.js +5 -5
  55. package/dist/components/builder/canvas-dnd.js.map +1 -1
  56. package/dist/components/builder/canvas-render-context.d.ts +27 -19
  57. package/dist/components/builder/canvas-render-context.js +42 -43
  58. package/dist/components/builder/canvas-render-context.js.map +1 -1
  59. package/dist/components/builder/canvas-runs.d.ts +45 -0
  60. package/dist/components/builder/canvas-runs.js +51 -0
  61. package/dist/components/builder/canvas-runs.js.map +1 -0
  62. package/dist/components/builder/canvas.js +32 -5
  63. package/dist/components/builder/canvas.js.map +1 -1
  64. package/dist/components/builder/clipboard-actions.d.ts +15 -0
  65. package/dist/components/builder/clipboard-actions.js +44 -7
  66. package/dist/components/builder/clipboard-actions.js.map +1 -1
  67. package/dist/components/builder/composition-field-editor.d.ts +3 -15
  68. package/dist/components/builder/composition-field-editor.js +29 -40
  69. package/dist/components/builder/composition-field-editor.js.map +1 -1
  70. package/dist/components/builder/delete-key.d.ts +5 -0
  71. package/dist/components/builder/delete-key.js +4 -0
  72. package/dist/components/builder/delete-key.js.map +1 -1
  73. package/dist/components/builder/insert-context.d.ts +17 -25
  74. package/dist/components/builder/insert-context.js +45 -92
  75. package/dist/components/builder/insert-context.js.map +1 -1
  76. package/dist/components/builder/page-tree.d.ts +3 -3
  77. package/dist/components/builder/page-tree.js +3 -3
  78. package/dist/components/builder/page-tree.js.map +1 -1
  79. package/dist/components/builder/placement-refusal.d.ts +18 -0
  80. package/dist/components/builder/placement-refusal.js +46 -0
  81. package/dist/components/builder/placement-refusal.js.map +1 -0
  82. package/dist/components/builder/property-inspector.js +4 -0
  83. package/dist/components/builder/property-inspector.js.map +1 -1
  84. package/dist/components/builder/reuse-context.js +65 -27
  85. package/dist/components/builder/reuse-context.js.map +1 -1
  86. package/dist/components/builder/reuse.d.ts +79 -1
  87. package/dist/components/builder/reuse.js +69 -2
  88. package/dist/components/builder/reuse.js.map +1 -1
  89. package/dist/components/builder/run-inspector.d.ts +11 -0
  90. package/dist/components/builder/run-inspector.js +33 -0
  91. package/dist/components/builder/run-inspector.js.map +1 -0
  92. package/dist/components/cms/asset-field.d.ts +38 -1
  93. package/dist/components/cms/asset-field.js +182 -58
  94. package/dist/components/cms/asset-field.js.map +1 -1
  95. package/dist/components/cms/asset-picker-core.d.ts +16 -0
  96. package/dist/components/cms/asset-picker-core.js +20 -0
  97. package/dist/components/cms/asset-picker-core.js.map +1 -1
  98. package/dist/components/cms/asset-picker.js +12 -18
  99. package/dist/components/cms/asset-picker.js.map +1 -1
  100. package/dist/components/cms/bulk-actions.d.ts +3 -19
  101. package/dist/components/cms/bulk-actions.js +3 -24
  102. package/dist/components/cms/bulk-actions.js.map +1 -1
  103. package/dist/components/cms/cell.js.map +1 -1
  104. package/dist/components/cms/cms-store.d.ts +5 -4
  105. package/dist/components/cms/cms-store.js +16 -36
  106. package/dist/components/cms/cms-store.js.map +1 -1
  107. package/dist/components/cms/detail-editor.js +0 -1
  108. package/dist/components/cms/detail-editor.js.map +1 -1
  109. package/dist/components/cms/document-diff-view.d.ts +3 -1
  110. package/dist/components/cms/document-diff-view.js +12 -7
  111. package/dist/components/cms/document-diff-view.js.map +1 -1
  112. package/dist/components/cms/field-anchors.d.ts +7 -4
  113. package/dist/components/cms/field-anchors.js +9 -7
  114. package/dist/components/cms/field-anchors.js.map +1 -1
  115. package/dist/components/cms/field-control-lib.d.ts +15 -1
  116. package/dist/components/cms/field-control-lib.js +22 -3
  117. package/dist/components/cms/field-control-lib.js.map +1 -1
  118. package/dist/components/cms/gate-validation.d.ts +21 -30
  119. package/dist/components/cms/gate-validation.js +11 -32
  120. package/dist/components/cms/gate-validation.js.map +1 -1
  121. package/dist/components/cms/object-array-field.js +4 -4
  122. package/dist/components/cms/object-array-field.js.map +1 -1
  123. package/dist/components/cms/prop-fields.d.ts +7 -2
  124. package/dist/components/cms/prop-fields.js +13 -6
  125. package/dist/components/cms/prop-fields.js.map +1 -1
  126. package/dist/components/cms/rich-text/embed-picker.d.ts +1 -1
  127. package/dist/components/cms/rich-text/embed-picker.js.map +1 -1
  128. package/dist/components/cms/rich-text/insert.d.ts +20 -9
  129. package/dist/components/cms/rich-text/insert.js +25 -17
  130. package/dist/components/cms/rich-text/insert.js.map +1 -1
  131. package/dist/components/cms/use-publish-controller.d.ts +1 -2
  132. package/dist/components/cms/use-publish-controller.js +7 -19
  133. package/dist/components/cms/use-publish-controller.js.map +1 -1
  134. package/dist/components/cms/versions-pane.js +8 -1
  135. package/dist/components/cms/versions-pane.js.map +1 -1
  136. package/dist/components/collection-view/cms-collection-view-source.js +3 -1
  137. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  138. package/dist/components/components/transfer-dialog.js +2 -1
  139. package/dist/components/components/transfer-dialog.js.map +1 -1
  140. package/dist/components/components/transfer.d.ts +10 -10
  141. package/dist/components/components/transfer.js +13 -12
  142. package/dist/components/components/transfer.js.map +1 -1
  143. package/dist/lib/ai/apply-content.js +56 -116
  144. package/dist/lib/ai/apply-content.js.map +1 -1
  145. package/dist/lib/ai/chat-context.d.ts +8 -0
  146. package/dist/lib/ai/chat-context.js +37 -6
  147. package/dist/lib/ai/chat-context.js.map +1 -1
  148. package/dist/lib/ai/chat-state.d.ts +6 -0
  149. package/dist/lib/ai/chat-state.js.map +1 -1
  150. package/dist/lib/ai/constraints.d.ts +20 -5
  151. package/dist/lib/ai/constraints.js +125 -33
  152. package/dist/lib/ai/constraints.js.map +1 -1
  153. package/dist/lib/ai/fake-provider.d.ts +16 -3
  154. package/dist/lib/ai/fake-provider.js +22 -6
  155. package/dist/lib/ai/fake-provider.js.map +1 -1
  156. package/dist/lib/ai/index.d.ts +2 -0
  157. package/dist/lib/ai/index.js +2 -0
  158. package/dist/lib/ai/index.js.map +1 -1
  159. package/dist/lib/ai/leash.d.ts +34 -0
  160. package/dist/lib/ai/leash.js +28 -0
  161. package/dist/lib/ai/leash.js.map +1 -0
  162. package/dist/lib/ai/rich-text-input.d.ts +8 -8
  163. package/dist/lib/ai/rich-text-input.js +2 -1
  164. package/dist/lib/ai/rich-text-input.js.map +1 -1
  165. package/dist/lib/ai/rich-text-translate.d.ts +27 -4
  166. package/dist/lib/ai/rich-text-translate.js +28 -10
  167. package/dist/lib/ai/rich-text-translate.js.map +1 -1
  168. package/dist/lib/ai/shape.d.ts +13 -13
  169. package/dist/lib/ai/shape.js +69 -93
  170. package/dist/lib/ai/shape.js.map +1 -1
  171. package/dist/lib/ai/tools.d.ts +36 -41
  172. package/dist/lib/ai/tools.js +83 -87
  173. package/dist/lib/ai/tools.js.map +1 -1
  174. package/dist/lib/ai/translate-document.d.ts +32 -4
  175. package/dist/lib/ai/translate-document.js +144 -46
  176. package/dist/lib/ai/translate-document.js.map +1 -1
  177. package/dist/lib/ai/types.d.ts +34 -23
  178. package/dist/lib/ai/types.js.map +1 -1
  179. package/dist/lib/ai/update-fields.d.ts +15 -12
  180. package/dist/lib/ai/update-fields.js +112 -188
  181. package/dist/lib/ai/update-fields.js.map +1 -1
  182. package/dist/lib/ai/writer-form.d.ts +116 -0
  183. package/dist/lib/ai/writer-form.js +429 -0
  184. package/dist/lib/ai/writer-form.js.map +1 -0
  185. package/dist/lib/assets/upload.d.ts +31 -2
  186. package/dist/lib/assets/upload.js +61 -6
  187. package/dist/lib/assets/upload.js.map +1 -1
  188. package/dist/lib/clipboard/os-clipboard.d.ts +10 -2
  189. package/dist/lib/clipboard/os-clipboard.js +8 -4
  190. package/dist/lib/clipboard/os-clipboard.js.map +1 -1
  191. package/dist/lib/clipboard/paste-target.d.ts +18 -9
  192. package/dist/lib/clipboard/paste-target.js +8 -7
  193. package/dist/lib/clipboard/paste-target.js.map +1 -1
  194. package/dist/lib/cms/field-patch.d.ts +0 -10
  195. package/dist/lib/cms/field-patch.js +4 -30
  196. package/dist/lib/cms/field-patch.js.map +1 -1
  197. package/dist/lib/cms-views/view-model.d.ts +3 -3
  198. package/dist/lib/collection-view/collection-view-source.d.ts +7 -3
  199. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  200. package/dist/lib/data/collection-content.js +3 -2
  201. package/dist/lib/data/collection-content.js.map +1 -1
  202. package/dist/lib/dnd/drop-rules.d.ts +25 -31
  203. package/dist/lib/dnd/drop-rules.js +53 -59
  204. package/dist/lib/dnd/drop-rules.js.map +1 -1
  205. package/dist/lib/engine-version.d.ts +1 -1
  206. package/dist/lib/engine-version.js +1 -1
  207. package/dist/lib/engine-version.js.map +1 -1
  208. package/dist/lib/i18n/messages.de.js +40 -3
  209. package/dist/lib/i18n/messages.de.js.map +1 -1
  210. package/dist/lib/i18n/messages.en.d.ts +34 -2
  211. package/dist/lib/i18n/messages.en.js +43 -3
  212. package/dist/lib/i18n/messages.en.js.map +1 -1
  213. package/dist/lib/operations/project-runtime-config.d.ts +25 -6
  214. package/dist/lib/operations/project-runtime-config.js +37 -10
  215. package/dist/lib/operations/project-runtime-config.js.map +1 -1
  216. package/dist/lib/operations/publish-with-revision.d.ts +33 -1
  217. package/dist/lib/operations/publish-with-revision.js +32 -0
  218. package/dist/lib/operations/publish-with-revision.js.map +1 -1
  219. package/dist/lib/operations/runtime-validator.d.ts +19 -68
  220. package/dist/lib/operations/runtime-validator.js +12 -138
  221. package/dist/lib/operations/runtime-validator.js.map +1 -1
  222. package/dist/lib/operations/studio-operations.d.ts +9 -2
  223. package/dist/lib/operations/studio-operations.js +13 -11
  224. package/dist/lib/operations/studio-operations.js.map +1 -1
  225. package/dist/lib/persistence/convex-adapter.js +6 -1
  226. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  227. package/dist/lib/persona/labels.js +3 -1
  228. package/dist/lib/persona/labels.js.map +1 -1
  229. package/dist/lib/workspace/index.d.ts +8 -11
  230. package/dist/lib/workspace/index.js +12 -24
  231. package/dist/lib/workspace/index.js.map +1 -1
  232. package/package.json +3 -3
  233. package/dist/components/cms/cms-composition-validators.d.ts +0 -16
  234. package/dist/components/cms/cms-composition-validators.js +0 -133
  235. package/dist/components/cms/cms-composition-validators.js.map +0 -1
  236. package/dist/components/cms/publish-clean.d.ts +0 -28
  237. package/dist/components/cms/publish-clean.js +0 -38
  238. package/dist/components/cms/publish-clean.js.map +0 -1
  239. package/dist/lib/assets/asset-placements.d.ts +0 -28
  240. package/dist/lib/assets/asset-placements.js +0 -83
  241. package/dist/lib/assets/asset-placements.js.map +0 -1
@@ -0,0 +1,127 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import { makeFunctionReference } from 'convex/server'
4
+ import { measureMaxDepth } from '@elytracms/core/cms-core'
5
+ import { InMemoryAssetRepository, assetAlt, liftLegacyAssetAlts } from '@elytracms/core/persistence'
6
+ import type { AssetRecord } from '@elytracms/core/persistence'
7
+ import schema from './schema'
8
+
9
+ // convex-test fires a `runAfter(0)` on a REAL timer; fake timers keep the scheduled
10
+ // revalidations queued (rows written, the `webhooks:deliver` fetch never fired).
11
+ beforeEach(() => {
12
+ vi.useFakeTimers()
13
+ })
14
+ afterEach(() => {
15
+ vi.useRealTimers()
16
+ })
17
+
18
+ /**
19
+ * EC-587 — the one-shot migration of the legacy `alt` column into `meta.alt`, its one
20
+ * home: the REAL internal mutation (`assets:liftLegacyAlt`, the line the operator runs
21
+ * with `npx convex run`) on convex-test's backend, and the in-memory runner
22
+ * (`liftLegacyAssetAlts`) held to the same expectations over the same rows.
23
+ */
24
+
25
+ const modules = {
26
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
27
+ './_generated/api.ts': async () => ({}),
28
+ }
29
+
30
+ const liftLegacyAlt = makeFunctionReference<'mutation'>('assets:liftLegacyAlt')
31
+
32
+ const STAMP = '2026-10-26T00:00:00.000Z'
33
+
34
+ /** The four shapes a stored asset row can hold its alt in. */
35
+ const ROWS = [
36
+ { assetId: 'column_only', alt: 'Harbour' },
37
+ { assetId: 'both', alt: 'stale column', meta: { alt: 'Meta wins', credit: 'Ada' } },
38
+ { assetId: 'meta_only', meta: { alt: 'Already home' } },
39
+ { assetId: 'none', meta: { credit: 'Jo' } },
40
+ ] as const
41
+
42
+ function row(input: (typeof ROWS)[number]) {
43
+ return {
44
+ filename: `${input.assetId}.jpg`,
45
+ contentType: 'image/jpeg',
46
+ byteSize: 1,
47
+ storageKey: `k_${input.assetId}`,
48
+ createdAt: STAMP,
49
+ updatedAt: STAMP,
50
+ ...input,
51
+ }
52
+ }
53
+
54
+ /** What every reader sees BEFORE the lift — the migration must not change it. */
55
+ const READ_BEFORE = { column_only: 'Harbour', both: 'Meta wins', meta_only: 'Already home', none: null }
56
+
57
+ const EXPECTED_META = {
58
+ column_only: { alt: 'Harbour' },
59
+ both: { alt: 'Meta wins', credit: 'Ada' },
60
+ meta_only: { alt: 'Already home' },
61
+ none: { credit: 'Jo' },
62
+ }
63
+
64
+ async function seededBackend() {
65
+ const t = convexTest(schema, modules)
66
+ await t.run(async (ctx) => {
67
+ for (const input of ROWS) await ctx.db.insert('assets', row(input) as never)
68
+ })
69
+ return t
70
+ }
71
+
72
+ async function storedRows(t: Awaited<ReturnType<typeof seededBackend>>) {
73
+ return t.run(async (ctx) => {
74
+ const rows = await ctx.db.query('assets').collect()
75
+ return Object.fromEntries(rows.map((r) => [r.assetId, { alt: r.alt, meta: r.meta }]))
76
+ })
77
+ }
78
+
79
+ describe('EC-587 — assets:liftLegacyAlt moves the legacy column home, once', () => {
80
+ it('lifts the column into meta.alt (meta.alt wins when both), unsets it, and keeps every alt reading', async () => {
81
+ const t = await seededBackend()
82
+ const before = await storedRows(t)
83
+ expect(Object.fromEntries(Object.entries(before).map(([id, r]) => [id, assetAlt(r)]))).toEqual(READ_BEFORE)
84
+
85
+ const report = (await t.mutation(liftLegacyAlt, {})) as { scanned: number; lifted: string[] }
86
+ expect(report.scanned).toBe(4)
87
+ expect([...report.lifted].sort()).toEqual(['both', 'column_only'])
88
+
89
+ const after = await storedRows(t)
90
+ for (const [id, stored] of Object.entries(after)) {
91
+ expect(stored.alt, `${id}: the column is gone`).toBeUndefined()
92
+ expect(stored.meta, `${id}: meta`).toEqual(EXPECTED_META[id as keyof typeof EXPECTED_META])
93
+ // convex-test enforces no nesting depth — the stored shape asserts it itself.
94
+ expect(measureMaxDepth(stored.meta).depth).toBeLessThanOrEqual(16)
95
+ }
96
+ // The lift changes no reading: every alt is what it was.
97
+ expect(Object.fromEntries(Object.entries(after).map(([id, r]) => [id, assetAlt(r)]))).toEqual(READ_BEFORE)
98
+ })
99
+
100
+ it('is idempotent: a second run lifts nothing and writes nothing', async () => {
101
+ const t = await seededBackend()
102
+ await t.mutation(liftLegacyAlt, {})
103
+ const once = await storedRows(t)
104
+ const scheduledAfterFirst = await t.run(async (ctx) => (await ctx.db.system.query('_scheduled_functions').collect()).length)
105
+ const again = (await t.mutation(liftLegacyAlt, {})) as { scanned: number; lifted: string[] }
106
+ expect(again).toEqual({ scanned: 4, lifted: [] })
107
+ expect(await storedRows(t)).toEqual(once)
108
+ const scheduledAfterSecond = await t.run(async (ctx) => (await ctx.db.system.query('_scheduled_functions').collect()).length)
109
+ expect(scheduledAfterSecond).toBe(scheduledAfterFirst)
110
+ })
111
+
112
+ it('the in-memory runner does the same to the same rows (the playground / headless equivalent)', async () => {
113
+ const records: AssetRecord[] = ROWS.map((input) => {
114
+ const { assetId, ...rest } = row(input)
115
+ return { ...rest, id: assetId, projectId: 'p' } as AssetRecord
116
+ })
117
+ const repo = new InMemoryAssetRepository({ seed: records })
118
+ const report = await liftLegacyAssetAlts(repo, 'p')
119
+ expect(report.scanned).toBe(4)
120
+ expect([...report.lifted].sort()).toEqual(['both', 'column_only'])
121
+ const after = await repo.listAssets('p')
122
+ expect(Object.fromEntries(after.map((r) => [r.id, { alt: r.alt, meta: r.meta }]))).toEqual(
123
+ Object.fromEntries(Object.entries(EXPECTED_META).map(([id, meta]) => [id, { alt: undefined, meta }])),
124
+ )
125
+ expect((await liftLegacyAssetAlts(repo, 'p')).lifted).toEqual([])
126
+ })
127
+ })
@@ -0,0 +1,168 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { convexTest } from 'convex-test'
4
+ import type { ConvexHttpClient } from 'convex/browser'
5
+ import type { FunctionReference } from 'convex/server'
6
+ import { defaultAssetCollection } from '@elytracms/core/cms-core'
7
+ import { InMemoryBlobStorage, InMemoryPersistenceAdapter } from '@elytracms/core/persistence'
8
+ import type { AssetRepository } from '@elytracms/core/persistence'
9
+ import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
10
+ import { runUpload } from '../src/lib/assets/upload'
11
+ import schema from './schema'
12
+
13
+ // convex-test fires a `runAfter(0)` on a REAL timer; fake timers keep the scheduled
14
+ // revalidation queued (its row written, the `webhooks:deliver` fetch never fired).
15
+ beforeEach(() => {
16
+ vi.useFakeTimers()
17
+ })
18
+ afterEach(() => {
19
+ vi.useRealTimers()
20
+ })
21
+
22
+ /**
23
+ * EC-568 — "replace works in both persistence modes, same id, derived data regenerated
24
+ * through the upload path" and "verify the revalidation covers a replace, do not assume".
25
+ * The REAL `runUpload` (replace mode) against the in-memory adapter and against the
26
+ * Convex adapter over convex-test (the real `assets:upsert` through the real trigger
27
+ * wrapper); the replace's revalidation is read off `_scheduled_functions`, as
28
+ * `asset-revalidate.test.ts` does (its redirect write is that file's known-positive
29
+ * control for the instrument).
30
+ */
31
+
32
+ const modules = {
33
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
34
+ './_generated/api.ts': async () => ({}),
35
+ }
36
+
37
+ const PROJECT_ID = 'asset-replace'
38
+ const SESSION_USER_ID = 'user_admin'
39
+
40
+ async function convexBackend() {
41
+ const t = convexTest(schema, modules)
42
+ const now = new Date().toISOString()
43
+ await t.run(async (ctx) => {
44
+ await ctx.db.insert('members', { userId: SESSION_USER_ID, role: 'admin', createdAt: now, updatedAt: now })
45
+ })
46
+ const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
47
+ const client = {
48
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin.query(ref, args ?? {}),
49
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => asAdmin.mutation(ref, args ?? {}),
50
+ } as unknown as ConvexHttpClient
51
+ return { t, adapter: new ConvexPersistenceAdapter(client, PROJECT_ID) }
52
+ }
53
+
54
+ function withRevalidationEnv<T>(run: () => Promise<T>): Promise<T> {
55
+ const env = (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
56
+ const keys = ['CANVAS_REVALIDATE_ENDPOINT', 'CANVAS_REVALIDATE_SECRET'] as const
57
+ const previous = keys.map((key) => env[key])
58
+ env['CANVAS_REVALIDATE_ENDPOINT'] = 'https://host.invalid/api/revalidate'
59
+ env['CANVAS_REVALIDATE_SECRET'] = 'ec568-secret'
60
+ return run().finally(() => {
61
+ keys.forEach((key, index) => {
62
+ const value = previous[index]
63
+ if (value === undefined) delete env[key]
64
+ else env[key] = value
65
+ })
66
+ })
67
+ }
68
+
69
+ const iptcJpeg = new Uint8Array(
70
+ readFileSync(new URL('../../core/src/cms-core/__fixtures__/embedded-metadata/iptc-xmp.jpg', import.meta.url)),
71
+ )
72
+ const newFile = {
73
+ name: 'new.jpg',
74
+ type: 'image/jpeg',
75
+ size: iptcJpeg.byteLength,
76
+ arrayBuffer: async () => iptcJpeg.slice().buffer as ArrayBuffer,
77
+ }
78
+
79
+ async function seedHero(assets: AssetRepository) {
80
+ await assets.upsertAsset(PROJECT_ID, {
81
+ id: 'hero',
82
+ filename: 'old.jpg',
83
+ contentType: 'image/jpeg',
84
+ byteSize: 10,
85
+ width: 4000,
86
+ height: 3000,
87
+ storageKey: 'blob_old',
88
+ url: 'https://assets.test/old.jpg',
89
+ focalPoint: { x: 0.2, y: 0.8 },
90
+ thumb: { storageKey: 'blob_thumb_old', url: 'https://assets.test/old-t.webp', width: 480, height: 360 },
91
+ lqip: 'data:image/webp;base64,OLD',
92
+ meta: { alt: 'Editor alt', note: 'kept' },
93
+ })
94
+ }
95
+
96
+ /** Replace hero's file through THE upload path; the comparable record after it. */
97
+ async function replaceHero(assets: AssetRepository) {
98
+ await seedHero(assets)
99
+ const result = await runUpload(
100
+ {
101
+ blobs: new InMemoryBlobStorage(),
102
+ assets,
103
+ assetFields: defaultAssetCollection().fields,
104
+ detectDimensions: async () => ({ width: 8, height: 6 }),
105
+ generateVariants: async () => ({ lqip: 'data:image/webp;base64,NEW' }),
106
+ },
107
+ { projectId: PROJECT_ID, file: newFile, replace: { assetId: 'hero' } },
108
+ )
109
+ expect(result.ok).toBe(true)
110
+ const after = await assets.getAsset(PROJECT_ID, 'hero')
111
+ return {
112
+ ids: (await assets.listAssets(PROJECT_ID)).map((a) => a.id),
113
+ filename: after.filename,
114
+ byteSize: after.byteSize,
115
+ storageKey: after.storageKey,
116
+ width: after.width,
117
+ height: after.height,
118
+ thumb: after.thumb ?? null,
119
+ lqip: after.lqip ?? null,
120
+ focalPoint: after.focalPoint ?? null,
121
+ meta: after.meta,
122
+ }
123
+ }
124
+
125
+ describe('EC-568 — a file replace means the same thing on both adapters', () => {
126
+ it('same id; new bytes and every derived column regenerated (or cleared); meta overridden per key; focal kept', async () => {
127
+ const memory = await replaceHero(new InMemoryPersistenceAdapter().assets)
128
+ const convex = await replaceHero((await convexBackend()).adapter.assets)
129
+ const expected = {
130
+ ids: ['hero'],
131
+ filename: 'new.jpg',
132
+ byteSize: iptcJpeg.byteLength,
133
+ storageKey: 'blob-1',
134
+ width: 8,
135
+ height: 6,
136
+ thumb: null,
137
+ lqip: 'data:image/webp;base64,NEW',
138
+ focalPoint: { x: 0.2, y: 0.8 },
139
+ meta: {
140
+ alt: 'A harbour at dusk & boats',
141
+ note: 'kept',
142
+ credit: 'Ana Credit (XMP)',
143
+ copyright: '© 2026 Example (XMP)',
144
+ },
145
+ }
146
+ expect({ memory, convex }).toEqual({ memory: expected, convex: expected })
147
+ })
148
+
149
+ it('the replace reaches the live site: it schedules the asset revalidation (kind asset, the asset id)', async () => {
150
+ await withRevalidationEnv(async () => {
151
+ const { t, adapter } = await convexBackend()
152
+ await seedHero(adapter.assets) // a create schedules nothing (EC-579)
153
+ const scheduled = async () =>
154
+ t.run(async (ctx) =>
155
+ (await ctx.db.system.query('_scheduled_functions').collect())
156
+ .filter((row) => row.name.startsWith('webhooks'))
157
+ .map((row) => (row.args as [{ event?: unknown }])[0]?.event),
158
+ )
159
+ expect(await scheduled()).toEqual([])
160
+ const result = await runUpload(
161
+ { blobs: new InMemoryBlobStorage(), assets: adapter.assets, assetFields: defaultAssetCollection().fields },
162
+ { projectId: PROJECT_ID, file: newFile, replace: { assetId: 'hero' } },
163
+ )
164
+ expect(result.ok).toBe(true)
165
+ expect(await scheduled()).toEqual([{ kind: 'asset', id: 'hero' }])
166
+ })
167
+ })
168
+ })
@@ -101,7 +101,7 @@ const assetArgs = {
101
101
  byteSize: 1,
102
102
  storageKey: 'placeholder',
103
103
  url: 'https://assets.invalid/photo.png',
104
- alt: 'old',
104
+ meta: { alt: 'old' },
105
105
  createdAt: STAMP,
106
106
  updatedAt: STAMP,
107
107
  }
@@ -128,13 +128,13 @@ describe('EC-579 — the write chokepoint revalidates on every asset edit', () =
128
128
  await asAdmin.mutation(upsertAsset, assetArgs)
129
129
  expect(await scheduledRevalidations(t)).toHaveLength(1)
130
130
 
131
- await asAdmin.mutation(upsertAsset, { ...assetArgs, alt: 'new' })
131
+ await asAdmin.mutation(upsertAsset, { ...assetArgs, meta: { alt: 'new' } })
132
132
 
133
133
  // The edit IS live output: the published snapshot already serves it.
134
134
  const snapshot = (await t.query(deliverySnapshot, { projectId: 'ec579' })) as {
135
- assets: { alt?: string }[]
135
+ assets: { meta?: { alt?: string } }[]
136
136
  }
137
- expect(snapshot.assets[0]?.alt).toBe('new')
137
+ expect(snapshot.assets[0]?.meta?.alt).toBe('new')
138
138
 
139
139
  expect(await scheduledRevalidations(t)).toEqual([{ kind: 'redirects' }, ASSET_EVENT])
140
140
  })
@@ -179,13 +179,13 @@ describe('EC-579 — the write chokepoint revalidates on every asset edit', () =
179
179
  const t = await freshBackend()
180
180
  const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
181
181
  await asAdmin.mutation(upsertAsset, assetArgs)
182
- await asAdmin.mutation(upsertAsset, { ...assetArgs, alt: 'new' })
182
+ await asAdmin.mutation(upsertAsset, { ...assetArgs, meta: { alt: 'new' } })
183
183
  // The first test is the known-positive control: the same edit on the same
184
184
  // harness DOES schedule when the scope is configured.
185
185
  expect(await scheduledRevalidations(t)).toEqual([])
186
186
  // The write itself still happened — silence is at the webhook, not the save.
187
187
  expect(
188
- await t.run(async (ctx) => (await ctx.db.query('assets').first())?.alt),
188
+ await t.run(async (ctx) => (await ctx.db.query('assets').first())?.meta?.alt),
189
189
  ).toBe('new')
190
190
  })
191
191
  })
package/convex/assets.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { ConvexError, v } from 'convex/values'
2
2
  import type { GenericId, Validator } from 'convex/values'
3
3
  import type { DocumentByName, WithoutSystemFields } from 'convex/server'
4
- import { assetRowSchema, rejectedWriteMessage, resolveRecordWrite } from '@elytracms/core/persistence'
4
+ import { liftLegacyAlt as legacyAltLift, rejectedWriteMessage, resolveAssetWrite } from '@elytracms/core/persistence'
5
5
  import type { DataModel } from './functions'
6
- import { mutation, query } from './functions'
6
+ import { internalMutation, mutation, query } from './functions'
7
7
  import { requireCapability } from './guard'
8
8
 
9
9
  /**
@@ -23,9 +23,10 @@ const clearable = <T, F extends string>(validator: Validator<T, 'required', F>)
23
23
  v.optional(v.union(v.null(), validator))
24
24
 
25
25
  /**
26
- * Write one asset (EC-577). The ONE write rule (`resolveRecordWrite`, shared with
26
+ * Write one asset (EC-577). The ONE write rule (`resolveAssetWrite`, shared with
27
27
  * the in-memory adapter) runs here, inside the transaction: a column absent from
28
- * the args keeps its stored value, `null` clears it, a value sets it. An edit is
28
+ * the args keeps its stored value, `null` clears it, a value sets it — and the legacy
29
+ * `alt` column can only be cleared (EC-587: an asset's alt is `meta.alt`). An edit is
29
30
  * ONE `db.patch` — the rule's store patch, where a cleared column is `undefined`,
30
31
  * which `db.patch` removes — so the write-chokepoint triggers fire once. No stored
31
32
  * asset → ONE `db.insert` of the complete row (the `defaults` fill what the write
@@ -41,6 +42,12 @@ export const upsert = mutation({
41
42
  storageKey: v.optional(v.string()),
42
43
  width: clearable(v.number()),
43
44
  height: clearable(v.number()),
45
+ /**
46
+ * LEGACY column (EC-587): accepted on the wire only so a write that still sets it
47
+ * is refused BY THE RULE with a message naming `meta.alt` (not a bare validator
48
+ * error); `null` clears it. Dropped with the column once `liftLegacyAlt` ran on
49
+ * every deployment.
50
+ */
44
51
  alt: clearable(v.string()),
45
52
  title: clearable(v.string()),
46
53
  url: clearable(v.string()),
@@ -73,7 +80,7 @@ export const upsert = mutation({
73
80
  .query('assets')
74
81
  .withIndex('by_asset', (q) => q.eq('assetId', assetId))
75
82
  .first()
76
- const resolved = resolveRecordWrite(assetRowSchema, existing ?? undefined, write, defaults ?? {})
83
+ const resolved = resolveAssetWrite(existing ?? undefined, write, defaults ?? {})
77
84
  if (!resolved.ok) {
78
85
  throw new ConvexError({
79
86
  code: 'validation',
@@ -91,6 +98,35 @@ export const upsert = mutation({
91
98
  },
92
99
  })
93
100
 
101
+ /**
102
+ * EC-587 — the one-shot migration of the legacy `alt` column: every asset row holding
103
+ * it gets the value lifted into `meta.alt` (unless `meta` already carries an `alt`
104
+ * key, which wins — it is what every reader already reads) and the column unset, by
105
+ * the ONE pure rule (core `liftLegacyAlt`, shared with the in-memory runner
106
+ * `liftLegacyAssetAlts`). Idempotent: a second run lifts nothing.
107
+ *
108
+ * Internal: run by an admin from the CLI, never by a client —
109
+ * `npx convex run assets:liftLegacyAlt` (no args). Each lifted row is one `db.patch`
110
+ * through the write-chokepoint triggers, so it schedules the asset's revalidation like
111
+ * any asset edit (the delivered alt is unchanged by the lift; the sweep is harmless).
112
+ * One transaction: Convex bounds it at 8192 written rows, far above any v1 library —
113
+ * documented, not paginated (as `cms:clearDocuments`).
114
+ */
115
+ export const liftLegacyAlt = internalMutation({
116
+ args: {},
117
+ handler: async (ctx) => {
118
+ const rows = await ctx.db.query('assets').collect()
119
+ const lifted: string[] = []
120
+ for (const row of rows) {
121
+ const patch = legacyAltLift(row)
122
+ if (!patch) continue
123
+ await ctx.db.patch(row._id, patch as Partial<AssetDoc>)
124
+ lifted.push(row.assetId)
125
+ }
126
+ return { scanned: rows.length, lifted }
127
+ },
128
+ })
129
+
94
130
  export const get = query({
95
131
  args: { assetId: v.string() },
96
132
  handler: async (ctx, args) => {
package/convex/cms.ts CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  unfoldValue,
11
11
  } from '@elytracms/core/cms-core'
12
12
  import type { CmsDocument, DocumentPatch, PatchLogKind, ReplacePrecondition } from '@elytracms/core/cms-core'
13
+ import { isPinnableVersion } from '@elytracms/core/persistence'
14
+ import type { DocumentVersionKind } from '@elytracms/core/persistence'
13
15
  import { mutation, query } from './functions'
14
16
  import type { MutationCtx, QueryCtx } from './functions'
15
17
  import { requireCapability } from './guard'
@@ -454,10 +456,15 @@ export const clearDocuments = mutation({
454
456
  },
455
457
  })
456
458
 
457
- /** Insert the next monotonic version row of a document (folded); returns it. */
459
+ /**
460
+ * Insert the next monotonic version row of a document (folded); returns it. `kind` is the
461
+ * mutation that records it (EC-585) — decided HERE, never taken from a client argument, so
462
+ * a `content.write` caller cannot mint a pinnable row through `saveDocumentVersion`.
463
+ */
458
464
  async function insertVersion(
459
465
  ctx: MutationCtx,
460
466
  args: { collection: string; docId: string; snapshot: unknown; label?: string; origin?: string; createdAt: string },
467
+ kind: DocumentVersionKind,
461
468
  ) {
462
469
  // EC-302: versions are appended monotonically, so the latest-created row holds the
463
470
  // highest version — read just that one instead of the whole history.
@@ -475,6 +482,7 @@ async function insertVersion(
475
482
  snapshot: foldDocument(args.snapshot as CmsDocument),
476
483
  ...(args.label ? { label: args.label } : {}),
477
484
  ...(args.origin ? { origin: args.origin } : {}),
485
+ kind,
478
486
  createdAt: args.createdAt,
479
487
  })
480
488
  return (await ctx.db.get(id))!
@@ -484,7 +492,8 @@ async function insertVersion(
484
492
  * Document version history (EC-224): append-only snapshots in `documentVersions`; the
485
493
  * live one is pinned by `cmsDocuments.publishedVersion`. Version numbers are 1-based,
486
494
  * monotonic per document. Used by the pre-restore snapshot and `documents.snapshot`;
487
- * publish records its version inside `publishDocument` (one step).
495
+ * publish records its version inside `publishDocument` (one step). A row written here is
496
+ * a `'snapshot'` (EC-585): a restore point `publishDocumentVersion` refuses to pin.
488
497
  */
489
498
  export const saveDocumentVersion = mutation({
490
499
  args: {
@@ -498,7 +507,7 @@ export const saveDocumentVersion = mutation({
498
507
  handler: async (ctx, args) => {
499
508
  await requireCapability(ctx, 'content.write') // EC-150
500
509
  assertWithinWriteLimits(args.snapshot, 'snapshot') // EC-304 B-4
501
- const row = await insertVersion(ctx, args)
510
+ const row = await insertVersion(ctx, args, 'snapshot')
502
511
  return withUnfoldedSnapshot(row)
503
512
  },
504
513
  })
@@ -534,7 +543,7 @@ export const publishDocument = mutation({
534
543
  label: args.label ?? 'published',
535
544
  ...(args.origin ? { origin: args.origin } : {}),
536
545
  createdAt: args.at,
537
- })
546
+ }, 'publish')
538
547
  const revision = existing.revision + 1
539
548
  // EC-595 retention: compact the log behind the PREVIOUS publish (one cycle kept).
540
549
  const previousPublish = (await logAfter(ctx, args.collection, args.docId, 0))
@@ -636,10 +645,15 @@ export const listAllDocumentVersions = query({
636
645
  /**
637
646
  * Pin an EXISTING version live (EC-224 `pinVersion`, changeset revert). Pin integrity is
638
647
  * enforced here, in the transaction: the version must exist for THIS document
639
- * (`missing-version` otherwise). The changed flag is DERIVED against the pinned snapshot
640
- * (a pin of an older version against a newer draft reads `true` — Codex P05's sibling),
641
- * the revision bumps, a `pin` row is logged. EC-585 reroutes this through the one
642
- * publish operation (Wave 4).
648
+ * (`missing-version` otherwise) and must have been recorded by `publishDocument`
649
+ * (`not-pinnable` otherwise — EC-585: a `saveDocumentVersion` row was never validated, so
650
+ * an editor calling this mutation directly cannot put one live; Codex P03). The changed
651
+ * flag is DERIVED against the pinned snapshot (a pin of an older version against a newer
652
+ * draft reads `true` — Codex P05's sibling), the revision bumps, a `pin` row is logged.
653
+ *
654
+ * Server-side trust (EC-565 1e, owner-ratified): the checks here are DATA-ONLY — revision,
655
+ * pin integrity, authority. Schema validation stays the client gate (`documents.publish`
656
+ * runs it, with the hidden-clear, before `publishDocument` records the row this pins).
643
657
  */
644
658
  export const publishDocumentVersion = mutation({
645
659
  args: {
@@ -654,9 +668,9 @@ export const publishDocumentVersion = mutation({
654
668
  const actor = await requireCapability(ctx, 'content.write') // EC-150
655
669
  const doc = await draftRow(ctx, args.collection, args.docId)
656
670
  if (!doc) return { status: 'missing' as const }
657
- if (!(await versionRow(ctx, args.collection, args.docId, args.version))) {
658
- return { status: 'missing-version' as const }
659
- }
671
+ const target = await versionRow(ctx, args.collection, args.docId, args.version)
672
+ if (!target) return { status: 'missing-version' as const }
673
+ if (!isPinnableVersion(target)) return { status: 'not-pinnable' as const }
660
674
  const revision = doc.revision + 1
661
675
  const patch = {
662
676
  revision,
@@ -3,7 +3,11 @@ import { convexTest } from 'convex-test'
3
3
  import type { ConvexHttpClient } from 'convex/browser'
4
4
  import type { FunctionReference } from 'convex/server'
5
5
  import { fieldPath, measureMaxDepth, nodePropPath } from '@elytracms/core/cms-core'
6
- import { PERSISTENCE_CONTRACT_SCENARIOS, replaceAtCurrentRevision } from '@elytracms/core/persistence'
6
+ import {
7
+ PERSISTENCE_CONTRACT_SCENARIOS,
8
+ publishCurrentDraft,
9
+ replaceAtCurrentRevision,
10
+ } from '@elytracms/core/persistence'
7
11
  import type { ContractContext } from '@elytracms/core/persistence'
8
12
  import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
9
13
  import type { ReactiveDocumentClient } from '../src/lib/persistence/convex-client'
@@ -190,8 +194,7 @@ describe('EC-286 write-chokepoint triggers (Convex-only: in-transaction referenc
190
194
  )
191
195
 
192
196
  // Pinning a version re-runs maintenance: published rows mirror the draft rows.
193
- const v = await adapter.cms.saveDocumentVersion(projectId, doc, { label: 'published' })
194
- await adapter.cms.publishDocumentVersion(projectId, ref, v.version)
197
+ await publishCurrentDraft(adapter.cms, projectId, ref)
195
198
  expect(
196
199
  (await adapter.references.outbound(projectId, source, { variant: 'published' })).length,
197
200
  ).toBe(2)
@@ -266,6 +269,18 @@ describe('EC-260 fold-at-rest (Convex-only: the stored form respects the depth c
266
269
  })
267
270
  expect(atRest).toBeDefined()
268
271
  expect(measureMaxDepth(atRest).depth).toBeLessThanOrEqual(16) // folded at rest
272
+
273
+ // EC-585: the publish step's version row now carries `kind` — a top-level scalar
274
+ // beside the folded snapshot, so the WHOLE stored row stays within the cap.
275
+ await publishCurrentDraft(adapter.cms, PROJECT_ID, { collection: doc.collection, id: doc.id })
276
+ const versionRow = await t.run(async (ctx) =>
277
+ ctx.db
278
+ .query('documentVersions')
279
+ .withIndex('by_doc_version', (q) => q.eq('collection', doc.collection).eq('docId', doc.id).eq('version', 1))
280
+ .unique(),
281
+ )
282
+ expect(versionRow?.kind).toBe('publish')
283
+ expect(measureMaxDepth(versionRow).depth).toBeLessThanOrEqual(16)
269
284
  })
270
285
  })
271
286
 
@@ -39,8 +39,7 @@ const modules = {
39
39
 
40
40
  const replaceDocument = makeFunctionReference<'mutation'>('cms:replaceDocument')
41
41
  const getDocument = makeFunctionReference<'query'>('cms:getDocument')
42
- const saveVersion = makeFunctionReference<'mutation'>('cms:saveDocumentVersion')
43
- const publishVersion = makeFunctionReference<'mutation'>('cms:publishDocumentVersion')
42
+ const publishDocument = makeFunctionReference<'mutation'>('cms:publishDocument')
44
43
  const unpublishDocument = makeFunctionReference<'mutation'>('cms:unpublishDocument')
45
44
  const deleteDocument = makeFunctionReference<'mutation'>('cms:deleteDocument')
46
45
  const deliverySnapshot = makeFunctionReference<'query'>('delivery:snapshot')
@@ -104,19 +103,18 @@ async function saveDraft({ asAdmin }: Backend, collection: string, id: string, v
104
103
  })
105
104
  }
106
105
 
106
+ /**
107
+ * Publish `values` the way the one publish operation does (EC-585): the draft holds them,
108
+ * then the one-step `publishDocument` records a publish-kind version and pins it.
109
+ */
107
110
  async function publish(backend: Backend, collection: string, id: string, values: Record<string, unknown>) {
108
- const version = (await backend.asAdmin.mutation(saveVersion, {
111
+ await saveDraft(backend, collection, id, values)
112
+ const current = (await backend.asAdmin.query(getDocument, { collection, docId: id })) as { revision: number }
113
+ await backend.asAdmin.mutation(publishDocument, {
109
114
  collection,
110
115
  docId: id,
111
- snapshot: doc(collection, id, values),
112
- createdAt: STAMP,
113
- })) as { version: number }
114
- await backend.asAdmin.mutation(publishVersion, {
115
- collection,
116
- docId: id,
117
- version: version.version,
118
- updatedAt: STAMP,
119
- publishedAt: STAMP,
116
+ expectedRevision: current.revision,
117
+ at: STAMP,
120
118
  })
121
119
  }
122
120