@elytracms/studio 0.0.94 → 0.0.96

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 (164) hide show
  1. package/convex/asset-alt-migration.test.ts +2 -2
  2. package/convex/asset-meta-patch.test.ts +218 -0
  3. package/convex/asset-replace-delete-race.test.ts +127 -0
  4. package/convex/assets.ts +27 -2
  5. package/convex/cms.ts +124 -8
  6. package/convex/delivery-redirects.test.ts +167 -0
  7. package/convex/delivery.ts +15 -5
  8. package/convex/rail-write-parity.test.ts +6 -6
  9. package/convex/redirects.ts +1 -1
  10. package/convex/schema.ts +6 -0
  11. package/convex/strict-reference-race.test.ts +139 -0
  12. package/dist/addon.d.ts +1 -1
  13. package/dist/addon.js.map +1 -1
  14. package/dist/components/ai/ai-chat-panel.js +15 -2
  15. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  16. package/dist/components/ai/translate-document-action.d.ts +6 -0
  17. package/dist/components/ai/translate-document-action.js.map +1 -1
  18. package/dist/components/ai/translate-document-dialog.d.ts +1 -1
  19. package/dist/components/ai/translate-document-dialog.js +5 -5
  20. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  21. package/dist/components/assets/asset-detail.js +29 -11
  22. package/dist/components/assets/asset-detail.js.map +1 -1
  23. package/dist/components/assets/asset-locale.d.ts +16 -0
  24. package/dist/components/assets/asset-locale.js +30 -0
  25. package/dist/components/assets/asset-locale.js.map +1 -0
  26. package/dist/components/assets/asset-meta-drift-notice.d.ts +13 -0
  27. package/dist/components/assets/asset-meta-drift-notice.js +22 -0
  28. package/dist/components/assets/asset-meta-drift-notice.js.map +1 -0
  29. package/dist/components/assets/asset-meta-label.d.ts +13 -0
  30. package/dist/components/assets/asset-meta-label.js +17 -0
  31. package/dist/components/assets/asset-meta-label.js.map +1 -0
  32. package/dist/components/assets/asset-metadata.d.ts +89 -15
  33. package/dist/components/assets/asset-metadata.js +123 -11
  34. package/dist/components/assets/asset-metadata.js.map +1 -1
  35. package/dist/components/assets/asset-search.d.ts +6 -2
  36. package/dist/components/assets/asset-search.js +19 -3
  37. package/dist/components/assets/asset-search.js.map +1 -1
  38. package/dist/components/assets/asset-thumb.js +6 -2
  39. package/dist/components/assets/asset-thumb.js.map +1 -1
  40. package/dist/components/assets/assets-collection-view-source.d.ts +25 -9
  41. package/dist/components/assets/assets-collection-view-source.js +80 -26
  42. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  43. package/dist/components/assets/assets-store.js +17 -4
  44. package/dist/components/assets/assets-store.js.map +1 -1
  45. package/dist/components/builder/builder-store.d.ts +21 -0
  46. package/dist/components/builder/builder-store.js +22 -0
  47. package/dist/components/builder/builder-store.js.map +1 -1
  48. package/dist/components/builder/canvas-commit-boundary.d.ts +22 -0
  49. package/dist/components/builder/canvas-commit-boundary.js +27 -0
  50. package/dist/components/builder/canvas-commit-boundary.js.map +1 -0
  51. package/dist/components/builder/canvas-rebase.d.ts +101 -0
  52. package/dist/components/builder/canvas-rebase.js +179 -0
  53. package/dist/components/builder/canvas-rebase.js.map +1 -0
  54. package/dist/components/builder/canvas-render-context.d.ts +6 -3
  55. package/dist/components/builder/canvas-render-context.js +17 -8
  56. package/dist/components/builder/canvas-render-context.js.map +1 -1
  57. package/dist/components/builder/composition-field-editor.d.ts +26 -2
  58. package/dist/components/builder/composition-field-editor.js +46 -27
  59. package/dist/components/builder/composition-field-editor.js.map +1 -1
  60. package/dist/components/builder/node-factory.js +15 -3
  61. package/dist/components/builder/node-factory.js.map +1 -1
  62. package/dist/components/builder/property-inspector.js +3 -1
  63. package/dist/components/builder/property-inspector.js.map +1 -1
  64. package/dist/components/builder/run-inspector.js +10 -6
  65. package/dist/components/builder/run-inspector.js.map +1 -1
  66. package/dist/components/cms/asset-field.d.ts +5 -2
  67. package/dist/components/cms/asset-field.js +34 -17
  68. package/dist/components/cms/asset-field.js.map +1 -1
  69. package/dist/components/cms/asset-picker-core.d.ts +2 -2
  70. package/dist/components/cms/asset-picker-core.js +2 -2
  71. package/dist/components/cms/asset-picker-core.js.map +1 -1
  72. package/dist/components/cms/asset-picker.js +6 -2
  73. package/dist/components/cms/asset-picker.js.map +1 -1
  74. package/dist/components/cms/canvas-commit.d.ts +62 -0
  75. package/dist/components/cms/canvas-commit.js +39 -0
  76. package/dist/components/cms/canvas-commit.js.map +1 -0
  77. package/dist/components/cms/cms-store.d.ts +21 -8
  78. package/dist/components/cms/cms-store.js +19 -10
  79. package/dist/components/cms/cms-store.js.map +1 -1
  80. package/dist/components/cms/collection-table.js +27 -18
  81. package/dist/components/cms/collection-table.js.map +1 -1
  82. package/dist/components/cms/commit-policy.d.ts +6 -2
  83. package/dist/components/cms/commit-policy.js +1 -1
  84. package/dist/components/cms/commit-policy.js.map +1 -1
  85. package/dist/components/cms/detail-editor.js +12 -2
  86. package/dist/components/cms/detail-editor.js.map +1 -1
  87. package/dist/components/cms/draft-commit.d.ts +16 -3
  88. package/dist/components/cms/draft-commit.js +3 -2
  89. package/dist/components/cms/draft-commit.js.map +1 -1
  90. package/dist/components/cms/use-document-draft.d.ts +121 -5
  91. package/dist/components/cms/use-document-draft.js +197 -54
  92. package/dist/components/cms/use-document-draft.js.map +1 -1
  93. package/dist/components/cms/use-live-url.d.ts +6 -1
  94. package/dist/components/cms/use-live-url.js +12 -9
  95. package/dist/components/cms/use-live-url.js.map +1 -1
  96. package/dist/components/collection-view/cms-collection-view-source.js +11 -5
  97. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  98. package/dist/components/components/transfer-dialog.js +6 -4
  99. package/dist/components/components/transfer-dialog.js.map +1 -1
  100. package/dist/components/deployment-setup-gate.js +10 -1
  101. package/dist/components/deployment-setup-gate.js.map +1 -1
  102. package/dist/components/records/record-lens.js +3 -8
  103. package/dist/components/records/record-lens.js.map +1 -1
  104. package/dist/lib/ai/chat-state.d.ts +7 -0
  105. package/dist/lib/ai/chat-state.js.map +1 -1
  106. package/dist/lib/ai/tools.d.ts +8 -0
  107. package/dist/lib/ai/tools.js +4 -0
  108. package/dist/lib/ai/tools.js.map +1 -1
  109. package/dist/lib/assets/upload.d.ts +25 -7
  110. package/dist/lib/assets/upload.js +30 -21
  111. package/dist/lib/assets/upload.js.map +1 -1
  112. package/dist/lib/cms/published-documents.d.ts +24 -6
  113. package/dist/lib/cms/published-documents.js +39 -6
  114. package/dist/lib/cms/published-documents.js.map +1 -1
  115. package/dist/lib/cms-views/view-model.d.ts +25 -105
  116. package/dist/lib/cms-views/view-model.js +36 -103
  117. package/dist/lib/cms-views/view-model.js.map +1 -1
  118. package/dist/lib/collection-view/__fixtures__/document-writes.js +1 -0
  119. package/dist/lib/collection-view/__fixtures__/document-writes.js.map +1 -1
  120. package/dist/lib/collection-view/collection-view-source.d.ts +21 -8
  121. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  122. package/dist/lib/data/collection-content.js +5 -0
  123. package/dist/lib/data/collection-content.js.map +1 -1
  124. package/dist/lib/engine-version.d.ts +1 -1
  125. package/dist/lib/engine-version.js +1 -1
  126. package/dist/lib/engine-version.js.map +1 -1
  127. package/dist/lib/i18n/messages.de.js +20 -4
  128. package/dist/lib/i18n/messages.de.js.map +1 -1
  129. package/dist/lib/i18n/messages.en.d.ts +16 -1
  130. package/dist/lib/i18n/messages.en.js +22 -5
  131. package/dist/lib/i18n/messages.en.js.map +1 -1
  132. package/dist/lib/operations/cms-persistence.d.ts +80 -35
  133. package/dist/lib/operations/cms-persistence.js +348 -85
  134. package/dist/lib/operations/cms-persistence.js.map +1 -1
  135. package/dist/lib/operations/config-manifest-lookup.d.ts +1 -1
  136. package/dist/lib/operations/config-manifest-lookup.js +3 -3
  137. package/dist/lib/operations/config-manifest-lookup.js.map +1 -1
  138. package/dist/lib/persistence/convex-adapter.js +54 -8
  139. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  140. package/dist/lib/persistence/deployment-readiness.js +14 -11
  141. package/dist/lib/persistence/deployment-readiness.js.map +1 -1
  142. package/dist/lib/persona/labels.js +2 -0
  143. package/dist/lib/persona/labels.js.map +1 -1
  144. package/dist/lib/query/index.d.ts +2 -2
  145. package/dist/lib/query/index.js +1 -1
  146. package/dist/lib/query/index.js.map +1 -1
  147. package/dist/lib/query/operations-query.d.ts +26 -0
  148. package/dist/lib/query/operations-query.js +12 -0
  149. package/dist/lib/query/operations-query.js.map +1 -1
  150. package/dist/lib/registry/index.js +2 -2
  151. package/dist/lib/registry/index.js.map +1 -1
  152. package/dist/lib/registry/preview.d.ts +10 -0
  153. package/dist/lib/registry/preview.js +13 -4
  154. package/dist/lib/registry/preview.js.map +1 -1
  155. package/dist/lib/routes/route-issues.js +6 -3
  156. package/dist/lib/routes/route-issues.js.map +1 -1
  157. package/dist/lib/workspace/workspace-source.js +6 -7
  158. package/dist/lib/workspace/workspace-source.js.map +1 -1
  159. package/dist/previews/entry-node.d.ts +14 -0
  160. package/dist/previews/entry-node.js +22 -0
  161. package/dist/previews/entry-node.js.map +1 -0
  162. package/dist/previews/harness.js +2 -16
  163. package/dist/previews/harness.js.map +1 -1
  164. package/package.json +3 -3
@@ -80,7 +80,7 @@ describe('EC-587 — assets:liftLegacyAlt moves the legacy column home, once', (
80
80
  it('lifts the column into meta.alt (meta.alt wins when both), unsets it, and keeps every alt reading', async () => {
81
81
  const t = await seededBackend()
82
82
  const before = await storedRows(t)
83
- expect(Object.fromEntries(Object.entries(before).map(([id, r]) => [id, assetAlt(r)]))).toEqual(READ_BEFORE)
83
+ expect(Object.fromEntries(Object.entries(before).map(([id, r]) => [id, assetAlt(r, null)]))).toEqual(READ_BEFORE)
84
84
 
85
85
  const report = (await t.mutation(liftLegacyAlt, {})) as { scanned: number; lifted: string[] }
86
86
  expect(report.scanned).toBe(4)
@@ -94,7 +94,7 @@ describe('EC-587 — assets:liftLegacyAlt moves the legacy column home, once', (
94
94
  expect(measureMaxDepth(stored.meta).depth).toBeLessThanOrEqual(16)
95
95
  }
96
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)
97
+ expect(Object.fromEntries(Object.entries(after).map(([id, r]) => [id, assetAlt(r, null)]))).toEqual(READ_BEFORE)
98
98
  })
99
99
 
100
100
  it('is idempotent: a second run lifts nothing and writes nothing', async () => {
@@ -0,0 +1,218 @@
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, measureMaxDepth } from '@elytracms/core/cms-core'
7
+ import { InMemoryBlobStorage, InMemoryPersistenceAdapter, PersistenceValidationError, assetAlt } 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 {
12
+ assetMetaDraft,
13
+ assetMetaDraftPatch,
14
+ setAssetMetaField,
15
+ updateAssetMetadata,
16
+ } from '../src/components/assets/asset-metadata'
17
+ import schema from './schema'
18
+
19
+ // convex-test fires a `runAfter(0)` on a REAL timer; fake timers keep the scheduled
20
+ // revalidation queued (its row written, the `webhooks:deliver` fetch never fired).
21
+ beforeEach(() => {
22
+ vi.useFakeTimers()
23
+ })
24
+ afterEach(() => {
25
+ vi.useRealTimers()
26
+ })
27
+
28
+ /**
29
+ * EC-634 — an asset's `meta` is written per key, inside the mutation, on BOTH adapters:
30
+ * two sessions (two adapters over ONE backend — two studio tabs on one deployment) whose
31
+ * dialog saves interleave keep both keys; a dialog save landing while a file replace is in
32
+ * flight survives the replace; a not-yet-migrated legacy `alt` column is lifted into the
33
+ * stored bag by the same transaction. convex-test serialises mutations, so this proves the
34
+ * per-key MERGE in `assets:upsert`, not Convex's OCC retry (which re-runs the same handler
35
+ * against the newer row — the merge is what makes that retry correct).
36
+ */
37
+
38
+ const modules = {
39
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
40
+ './_generated/api.ts': async () => ({}),
41
+ }
42
+
43
+ const PROJECT_ID = 'asset-meta-patch'
44
+ const SESSION_USER_ID = 'user_admin'
45
+ const STAMP = '2026-10-27T00:00:00.000Z'
46
+
47
+ async function convexBackend() {
48
+ const t = convexTest(schema, modules)
49
+ const now = new Date().toISOString()
50
+ await t.run(async (ctx) => {
51
+ await ctx.db.insert('members', { userId: SESSION_USER_ID, role: 'admin', createdAt: now, updatedAt: now })
52
+ })
53
+ const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
54
+ const session = () =>
55
+ new ConvexPersistenceAdapter(
56
+ {
57
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin.query(ref, args ?? {}),
58
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) =>
59
+ asAdmin.mutation(ref, args ?? {}),
60
+ } as unknown as ConvexHttpClient,
61
+ PROJECT_ID,
62
+ ).assets
63
+ return { t, a: session(), b: session() }
64
+ }
65
+
66
+ function memoryBackend() {
67
+ const adapter = new InMemoryPersistenceAdapter()
68
+ return { a: adapter.assets, b: adapter.assets }
69
+ }
70
+
71
+ async function seed(assets: AssetRepository, meta: Record<string, unknown>) {
72
+ await assets.upsertAsset(PROJECT_ID, {
73
+ id: 'hero',
74
+ filename: 'hero.jpg',
75
+ contentType: 'image/jpeg',
76
+ byteSize: 1,
77
+ storageKey: 'blob_old',
78
+ url: 'https://assets.test/old.jpg',
79
+ meta,
80
+ })
81
+ }
82
+
83
+ async function openDialog(assets: AssetRepository) {
84
+ const opened = assetMetaDraft(await assets.getAsset(PROJECT_ID, 'hero'))
85
+ let draft = opened
86
+ return {
87
+ edit: (key: string, value: unknown) => {
88
+ draft = setAssetMetaField(draft, { name: key }, null, value)
89
+ },
90
+ commit: () => updateAssetMetadata({ assets }, PROJECT_ID, 'hero', assetMetaDraftPatch(opened, draft)),
91
+ }
92
+ }
93
+
94
+ /** Two dialogs open on one asset, each edits one key, the commits interleave. */
95
+ async function interleavedDialogs({ a, b }: { a: AssetRepository; b: AssetRepository }) {
96
+ await seed(a, { alt: 'old alt', credit: 'old credit' })
97
+ const dialogA = await openDialog(a)
98
+ const dialogB = await openDialog(b)
99
+ dialogA.edit('alt', 'A alt')
100
+ dialogB.edit('credit', 'B credit')
101
+ expect((await dialogA.commit()).ok).toBe(true)
102
+ expect((await dialogB.commit()).ok).toBe(true)
103
+ return (await b.getAsset(PROJECT_ID, 'hero')).meta
104
+ }
105
+
106
+ const iptcJpeg = new Uint8Array(
107
+ readFileSync(new URL('../../core/src/cms-core/__fixtures__/embedded-metadata/iptc-xmp.jpg', import.meta.url)),
108
+ )
109
+
110
+ /** A replace whose read is followed — before its write — by another session's dialog save. */
111
+ async function replaceRacingADialog({ a, b }: { a: AssetRepository; b: AssetRepository }) {
112
+ await seed(a, { alt: 'old alt', note: 'old note' })
113
+ const dialogB = await openDialog(b)
114
+ dialogB.edit('note', 'B note')
115
+ const racing: AssetRepository = {
116
+ // B's dialog commits at the worst moment: right before A's replace writes.
117
+ upsertAsset: async (projectId, input) => {
118
+ await dialogB.commit()
119
+ return a.upsertAsset(projectId, input)
120
+ },
121
+ getAsset: (projectId, id) => a.getAsset(projectId, id),
122
+ listAssets: (projectId) => a.listAssets(projectId),
123
+ removeAsset: (projectId, id) => a.removeAsset(projectId, id),
124
+ resolveMany: (projectId, ids) => a.resolveMany(projectId, ids),
125
+ findAsset: (projectId, id) => a.findAsset(projectId, id),
126
+ }
127
+ const result = await runUpload(
128
+ {
129
+ blobs: new InMemoryBlobStorage(),
130
+ assets: racing,
131
+ assetFields: defaultAssetCollection().fields,
132
+ detectDimensions: async () => ({ width: 8, height: 6 }),
133
+ generateVariants: async () => ({}),
134
+ },
135
+ {
136
+ projectId: PROJECT_ID,
137
+ file: {
138
+ name: 'new.jpg',
139
+ type: 'image/jpeg',
140
+ size: iptcJpeg.byteLength,
141
+ arrayBuffer: async () => iptcJpeg.slice().buffer as ArrayBuffer,
142
+ },
143
+ replace: { assetId: 'hero' },
144
+ },
145
+ )
146
+ expect(result.ok).toBe(true)
147
+ return (await a.getAsset(PROJECT_ID, 'hero')).meta
148
+ }
149
+
150
+ describe('EC-634 — asset meta is patched per key in the write, on both adapters', () => {
151
+ it('two sessions’ interleaved dialog saves of different keys keep both (the worst row)', async () => {
152
+ const expected = { alt: 'A alt', credit: 'B credit' }
153
+ expect(await interleavedDialogs(memoryBackend())).toEqual(expected)
154
+ expect(await interleavedDialogs(await convexBackend())).toEqual(expected)
155
+ })
156
+
157
+ it('a dialog save landing just before a replace’s write survives the replace', async () => {
158
+ const memory = await replaceRacingADialog(memoryBackend())
159
+ const convex = await replaceRacingADialog(await convexBackend())
160
+ expect(convex).toEqual(memory)
161
+ expect(convex).toMatchObject({ alt: 'A harbour at dusk & boats', note: 'B note' })
162
+ })
163
+
164
+ it('a patch lifts a not-yet-migrated legacy alt column into the stored bag, in the same mutation', async () => {
165
+ const { t, a } = await convexBackend()
166
+ await t.run(async (ctx) => {
167
+ await ctx.db.insert('assets', {
168
+ assetId: 'legacy',
169
+ filename: 'l.jpg',
170
+ contentType: 'image/jpeg',
171
+ byteSize: 1,
172
+ storageKey: 'k_legacy',
173
+ alt: 'Column alt',
174
+ createdAt: STAMP,
175
+ updatedAt: STAMP,
176
+ })
177
+ })
178
+ // An edit of ANOTHER key: the alt it did not name moves home and is kept.
179
+ const kept = await a.upsertAsset(PROJECT_ID, { id: 'legacy', metaPatch: { credit: 'Ada' } })
180
+ expect(kept.meta).toEqual({ alt: 'Column alt', credit: 'Ada' })
181
+ expect(kept.alt).toBeUndefined()
182
+ expect(assetAlt(kept, null)).toBe('Column alt')
183
+ // A clear of the alt stays cleared — no column left to re-surface it (ASSETS D21).
184
+ const cleared = await a.upsertAsset(PROJECT_ID, { id: 'legacy', metaPatch: { alt: null } })
185
+ expect(assetAlt(cleared, null)).toBeNull()
186
+ const row = await t.run((ctx) => ctx.db.query('assets').filter((q) => q.eq(q.field('assetId'), 'legacy')).first())
187
+ expect(row?.alt).toBeUndefined()
188
+ expect(row?.meta).toEqual({ credit: 'Ada' })
189
+ })
190
+
191
+ it('EC-632 — per-locale buckets are stored by the real mutation, and the stored row measures its depth', async () => {
192
+ const { t, a, b } = await convexBackend()
193
+ await seed(a, { credit: 'Ada' })
194
+ // Two translators, two sessions, interleaved: both locales are kept.
195
+ await a.upsertAsset(PROJECT_ID, { id: 'hero', localizedMetaPatch: { de: { alt: 'Hafen' } } })
196
+ await b.upsertAsset(PROJECT_ID, {
197
+ id: 'hero',
198
+ localizedMetaPatch: { en: { alt: 'Harbour', disclosure: { shown: true, badge: { corner: 'tl', tone: 'dark' } } } },
199
+ })
200
+ const row = await t.run((ctx) => ctx.db.query('assets').filter((q) => q.eq(q.field('assetId'), 'hero')).first())
201
+ expect(row?.localizedMeta).toEqual({
202
+ de: { alt: 'Hafen' },
203
+ en: { alt: 'Harbour', disclosure: { shown: true, badge: { corner: 'tl', tone: 'dark' } } },
204
+ })
205
+ expect(row?.meta).toEqual({ credit: 'Ada' })
206
+ // The stored Convex document (system fields included): 5 of Convex's 16 levels.
207
+ expect(measureMaxDepth(row).depth).toBe(5)
208
+ })
209
+
210
+ it('refuses meta and metaPatch together, through the rule (a PersistenceValidationError naming metaPatch)', async () => {
211
+ const { a } = await convexBackend()
212
+ await seed(a, { alt: 'x' })
213
+ await expect(
214
+ a.upsertAsset(PROJECT_ID, { id: 'hero', meta: { alt: 'y' }, metaPatch: { credit: 'z' } }),
215
+ ).rejects.toBeInstanceOf(PersistenceValidationError)
216
+ expect((await a.getAsset(PROJECT_ID, 'hero')).meta).toEqual({ alt: 'x' })
217
+ })
218
+ })
@@ -0,0 +1,127 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import type { ConvexHttpClient } from 'convex/browser'
4
+ import type { FunctionReference } from 'convex/server'
5
+ import { InMemoryBlobStorage, InMemoryPersistenceAdapter } from '@elytracms/core/persistence'
6
+ import type { AssetRepository } from '@elytracms/core/persistence'
7
+ import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
8
+ import { runUpload } from '../src/lib/assets/upload'
9
+ import schema from './schema'
10
+
11
+ // convex-test fires a `runAfter(0)` on a REAL timer; fake timers keep the scheduled
12
+ // revalidation queued (its row written, the `webhooks:deliver` fetch never fired).
13
+ beforeEach(() => {
14
+ vi.useFakeTimers()
15
+ })
16
+ afterEach(() => {
17
+ vi.useRealTimers()
18
+ })
19
+
20
+ /**
21
+ * EC-634 follow-on — a replace is an UPDATE of a stored asset, never a create. Session A
22
+ * replaces the file; session B deletes the asset in the window between A's check and A's
23
+ * write. The replace used to carry every column a create needs, so it silently RE-CREATED
24
+ * the asset B had deleted (a colleague's delete undone). Two sessions over ONE backend,
25
+ * in-memory AND Convex (the real `assets:upsert` over convex-test).
26
+ */
27
+
28
+ const modules = {
29
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
30
+ './_generated/api.ts': async () => ({}),
31
+ }
32
+
33
+ const PROJECT_ID = 'asset-replace-race'
34
+ const SESSION_USER_ID = 'user_admin'
35
+
36
+ async function convexSessions() {
37
+ const t = convexTest(schema, modules)
38
+ const now = new Date().toISOString()
39
+ await t.run(async (ctx) => {
40
+ await ctx.db.insert('members', { userId: SESSION_USER_ID, role: 'admin', createdAt: now, updatedAt: now })
41
+ })
42
+ const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
43
+ const session = () =>
44
+ new ConvexPersistenceAdapter(
45
+ {
46
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin.query(ref, args ?? {}),
47
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) =>
48
+ asAdmin.mutation(ref, args ?? {}),
49
+ } as unknown as ConvexHttpClient,
50
+ PROJECT_ID,
51
+ ).assets
52
+ return { a: session(), b: session() }
53
+ }
54
+
55
+ function memorySessions() {
56
+ const adapter = new InMemoryPersistenceAdapter()
57
+ return { a: adapter.assets, b: adapter.assets }
58
+ }
59
+
60
+ /** A's replace, with B's delete landing right after A checked that the asset exists. */
61
+ async function replaceRacingADelete({ a, b }: { a: AssetRepository; b: AssetRepository }) {
62
+ await a.upsertAsset(PROJECT_ID, {
63
+ id: 'hero',
64
+ filename: 'old.jpg',
65
+ contentType: 'image/jpeg',
66
+ byteSize: 1,
67
+ storageKey: 'blob_old',
68
+ url: 'https://assets.test/old.jpg',
69
+ })
70
+ // B's delete lands immediately before A's write — after anything A checked or read.
71
+ const racing: AssetRepository = {
72
+ upsertAsset: async (projectId, input) => {
73
+ await b.removeAsset(projectId, 'hero')
74
+ return a.upsertAsset(projectId, input)
75
+ },
76
+ getAsset: (projectId, id) => a.getAsset(projectId, id),
77
+ listAssets: (projectId) => a.listAssets(projectId),
78
+ removeAsset: (projectId, id) => a.removeAsset(projectId, id),
79
+ resolveMany: (projectId, ids) => a.resolveMany(projectId, ids),
80
+ findAsset: (projectId, id) => a.findAsset(projectId, id),
81
+ }
82
+ const result = await runUpload(
83
+ {
84
+ blobs: new InMemoryBlobStorage(),
85
+ assets: racing,
86
+ detectDimensions: async () => ({ width: 8, height: 6 }),
87
+ generateVariants: async () => ({}),
88
+ },
89
+ {
90
+ projectId: PROJECT_ID,
91
+ file: { name: 'new.jpg', type: 'image/jpeg', size: 3, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer },
92
+ replace: { assetId: 'hero' },
93
+ },
94
+ )
95
+ return { result, stored: await b.findAsset(PROJECT_ID, 'hero') }
96
+ }
97
+
98
+ describe('EC-634 follow-on — a replace never re-creates an asset another session deleted', () => {
99
+ for (const [name, backend] of [
100
+ ['in-memory', async () => memorySessions()],
101
+ ['Convex', convexSessions],
102
+ ] as const) {
103
+ it(`${name}: the replace is refused, visibly, and the delete stands`, async () => {
104
+ const { result, stored } = await replaceRacingADelete(await backend())
105
+ expect(stored).toBeUndefined()
106
+ expect(result.ok).toBe(false)
107
+ if (!result.ok) {
108
+ expect(result.failure.stage).toBe('confirm')
109
+ expect(result.failure.message).toContain('hero')
110
+ }
111
+ })
112
+ }
113
+
114
+ it('control: without the delete the replace lands (same id, the new file)', async () => {
115
+ const { a } = memorySessions()
116
+ await a.upsertAsset(PROJECT_ID, { id: 'hero', filename: 'old.jpg', contentType: 'image/jpeg', byteSize: 1 })
117
+ const result = await runUpload(
118
+ { blobs: new InMemoryBlobStorage(), assets: a, detectDimensions: async () => undefined, generateVariants: async () => ({}) },
119
+ {
120
+ projectId: PROJECT_ID,
121
+ file: { name: 'new.jpg', type: 'image/jpeg', size: 3, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer },
122
+ replace: { assetId: 'hero' },
123
+ },
124
+ )
125
+ expect(result.ok && result.asset).toMatchObject({ id: 'hero', filename: 'new.jpg' })
126
+ })
127
+ })
package/convex/assets.ts CHANGED
@@ -25,8 +25,9 @@ const clearable = <T, F extends string>(validator: Validator<T, 'required', F>)
25
25
  /**
26
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 — and the legacy
29
- * `alt` column can only be cleared (EC-587: an asset's alt is `meta.alt`). An edit is
28
+ * the args keeps its stored value, `null` clears it, a value sets it — the legacy
29
+ * `alt` column can only be cleared (EC-587: an asset's alt is `meta.alt`), and a
30
+ * `metaPatch` changes `meta` per key against the row read here (EC-634). An edit is
30
31
  * ONE `db.patch` — the rule's store patch, where a cleared column is `undefined`,
31
32
  * which `db.patch` removes — so the write-chokepoint triggers fire once. No stored
32
33
  * asset → ONE `db.insert` of the complete row (the `defaults` fill what the write
@@ -65,6 +66,30 @@ export const upsert = mutation({
65
66
  lqip: clearable(v.string()),
66
67
  /** Schema-driven asset metadata bag (EC-301): alt, copyright, credit, …. `null` clears it. */
67
68
  meta: v.optional(v.any()),
69
+ /**
70
+ * EC-634 — a per-key change to `meta` (a value sets the key, `null` removes it), applied
71
+ * by the rule to the row as stored HERE, in this transaction — so an editor's save never
72
+ * carries a stale copy of a key another session changed. Validated by the rule (an
73
+ * object of keys, never beside `meta`), as `meta` is.
74
+ */
75
+ metaPatch: v.optional(v.any()),
76
+ /**
77
+ * EC-632 — the per-locale buckets of the asset collection's `localized: true` fields
78
+ * (`{ [locale]: { [field]: value } }`): `null` clears them, an object replaces them
79
+ * whole (a create, an import); an editor sends `localizedMetaPatch` instead.
80
+ */
81
+ localizedMeta: v.optional(v.any()),
82
+ /**
83
+ * EC-632 — a per-locale, per-key change to those buckets, applied by the rule to the
84
+ * row as stored HERE (as `metaPatch` is to `meta`). Validated by the rule.
85
+ */
86
+ localizedMetaPatch: v.optional(v.any()),
87
+ /**
88
+ * EC-634 follow-on — this write only UPDATES a stored asset (a file replace): with the
89
+ * row gone (deleted by another session) the rule refuses it here, so it never re-creates
90
+ * what a colleague deleted.
91
+ */
92
+ mustExist: v.optional(v.boolean()),
68
93
  /**
69
94
  * What a CREATE fills in when the write leaves it absent (`assetCreateDefaults`
70
95
  * in core). Sent by the adapter because only it knows the project — rows carry
package/convex/cms.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { v } from 'convex/values'
2
2
  import {
3
+ DOCUMENT_KEY_SEPARATOR,
3
4
  DRAFT_VARIANT,
4
5
  applyDocumentPatches,
6
+ documentKey,
5
7
  documentsSemanticEqual,
6
8
  foldDocument,
7
9
  foldPatchesForStorage,
@@ -26,10 +28,13 @@ import { assertWithinWriteLimits } from './write-limits'
26
28
  *
27
29
  * Owner, verbatim: "yes keep validation client-side. server-side validation enforces
28
30
  * migrations or could lead to invalid/lost data (the payload-sql-approach). schema should
29
- * be the lens and the mask to put on the data." So these handlers enforce exactly three
30
- * things, all data-only: **the revision** (every write and publish carries the one it was
31
- * made against; a moved document refuses with `conflict`), **pin integrity** (a pinned
32
- * version exists and belongs to this document) and **authority** (`requireCapability`).
31
+ * be the lens and the mask to put on the data." So these handlers enforce only data-only
32
+ * facts: **the revision** (every write and publish carries the one it was made against; a
33
+ * moved document refuses with `conflict`), **pin integrity** (a pinned version exists and
34
+ * belongs to this document), **authority** (`requireCapability`) and — EC-649 — the
35
+ * **strict-reference facts the operation's guard judged** (a publish's strict targets are
36
+ * still published; an unpublish / delete target has no published referrer the guard did not
37
+ * judge): the operation decides what is strict, these handlers only re-check that data.
33
38
  * They never validate a document against a schema and never canonicalise a value — the
34
39
  * client gate does both (a `content.write` member bypassing the studio can publish an
35
40
  * invalid document; delivery parse raises it visibly, AD-14).
@@ -156,6 +161,70 @@ const preconditionValidator = v.union(
156
161
  v.object({ ifCollectionEmpty: v.literal(true) }),
157
162
  )
158
163
 
164
+ // --- EC-649: strict-reference integrity as data preconditions (Codex F08) -------------
165
+ //
166
+ // The operation judges strictness (it holds the schema); these handlers hold none (EC-565
167
+ // 1e). They re-check, inside their own transaction, only the DATA the operation's guard
168
+ // judged: a publish / pin's strict targets are still published; an unpublish / delete's
169
+ // target has no published referrer the guard did not judge at its current pin. Convex OCC
170
+ // makes the check and the write one serializable step: a concurrent publish of a referrer
171
+ // writes the very `referenceEntries` range (by_target) and `cmsDocuments` rows read here.
172
+
173
+ const documentRefValidator = v.object({ collection: v.string(), id: v.string() })
174
+ const publishedReferrerValidator = v.object({
175
+ collection: v.string(),
176
+ id: v.string(),
177
+ publishedVersion: v.number(),
178
+ })
179
+
180
+ interface WireRef {
181
+ collection: string
182
+ id: string
183
+ }
184
+
185
+ /** The strict targets that are gone or carry no pin (a publish / pin refuses on any). */
186
+ async function unpublishedStrictTargets(ctx: MutationCtx, targets: readonly WireRef[] | undefined): Promise<WireRef[]> {
187
+ const unpublished: WireRef[] = []
188
+ for (const target of targets ?? []) {
189
+ const row = await draftRow(ctx, target.collection, target.id)
190
+ if (row?.publishedVersion === undefined) unpublished.push({ collection: target.collection, id: target.id })
191
+ }
192
+ return unpublished
193
+ }
194
+
195
+ /**
196
+ * The published referrers of `collection:docId` the guard did not judge at their current pin —
197
+ * read from the reference index's PUBLISHED variant (`by_target`, written from the pinned
198
+ * snapshot in the same transaction as every pin move — EC-286 / EC-589), so a referrer published
199
+ * after the guard read is seen here. `undefined` judged = not checked (a caller without the
200
+ * precondition).
201
+ */
202
+ async function unjudgedPublishedReferrers(
203
+ ctx: MutationCtx,
204
+ collection: string,
205
+ docId: string,
206
+ judged: readonly (WireRef & { publishedVersion: number })[] | undefined,
207
+ ): Promise<WireRef[]> {
208
+ if (!judged) return []
209
+ const targetKey = documentKey({ collection, id: docId })
210
+ const pins = new Map(judged.map((referrer) => [documentKey(referrer), referrer.publishedVersion]))
211
+ const entries = await ctx.db
212
+ .query('referenceEntries')
213
+ .withIndex('by_target', (q) => q.eq('targetType', 'document').eq('targetId', targetKey).eq('variant', 'published'))
214
+ .collect()
215
+ const unjudged: WireRef[] = []
216
+ for (const sourceId of new Set(entries.map((entry) => entry.sourceId))) {
217
+ if (sourceId === targetKey) continue
218
+ // A collection id never contains the separator (EC-578 F10), so the first one splits.
219
+ const at = sourceId.indexOf(DOCUMENT_KEY_SEPARATOR)
220
+ const source = { collection: sourceId.slice(0, at), id: sourceId.slice(at + 1) }
221
+ const row = await draftRow(ctx, source.collection, source.id)
222
+ if (row?.publishedVersion === undefined) continue
223
+ if (pins.get(sourceId) !== row.publishedVersion) unjudged.push(source)
224
+ }
225
+ return unjudged
226
+ }
227
+
159
228
  /**
160
229
  * The whole-document write (EC-595 binding point 2, EC-578): create (`ifAbsent`), a
161
230
  * singleton's first document (`ifCollectionEmpty`), or a replace at an exact revision —
@@ -421,11 +490,35 @@ export const listDocumentPatches = query({
421
490
  },
422
491
  })
423
492
 
493
+ /**
494
+ * Delete a document with its version history, patch log and presence. EC-648: an optional
495
+ * `precondition: { revision }` — the delete happens only while the draft is at exactly that
496
+ * revision, checked in this transaction (a changeset reverting its own create carries the
497
+ * revision the create recorded, so a colleague's later edit refuses it). Data-only (1e).
498
+ * Returns `{ status: 'conflict', current }` when the precondition does not hold. EC-649: the
499
+ * precondition's `publishedReferrers` (the ones the operation's strict guard judged) — a
500
+ * published referrer not among them refuses the delete with `{ status: 'unjudged-referrers' }`.
501
+ */
424
502
  export const deleteDocument = mutation({
425
- args: { collection: v.string(), docId: v.string() },
503
+ args: {
504
+ collection: v.string(),
505
+ docId: v.string(),
506
+ precondition: v.optional(
507
+ v.object({
508
+ revision: v.optional(v.number()),
509
+ publishedReferrers: v.optional(v.array(publishedReferrerValidator)),
510
+ }),
511
+ ),
512
+ },
426
513
  handler: async (ctx, args) => {
427
514
  await requireCapability(ctx, 'content.write') // EC-150
428
515
  const existing = await draftRow(ctx, args.collection, args.docId)
516
+ const revision = args.precondition?.revision
517
+ if (revision !== undefined && existing?.revision !== revision) {
518
+ return { status: 'conflict' as const, current: existing ? withUnfoldedDocument(existing) : null }
519
+ }
520
+ const unjudged = await unjudgedPublishedReferrers(ctx, args.collection, args.docId, args.precondition?.publishedReferrers)
521
+ if (unjudged.length > 0) return { status: 'unjudged-referrers' as const, refs: unjudged }
429
522
  if (existing) await ctx.db.delete(existing._id)
430
523
  // Cascade the version history (EC-224), the patch log and presence (EC-595).
431
524
  const versions = await ctx.db
@@ -436,6 +529,7 @@ export const deleteDocument = mutation({
436
529
  await deleteBookkeeping(ctx, args.collection, args.docId)
437
530
  // EC-286: the write-chokepoint trigger drops this document's index rows (and
438
531
  // revalidates if it was published) when the `cmsDocuments` delete commits.
532
+ return { status: 'ok' as const }
439
533
  },
440
534
  })
441
535
 
@@ -518,6 +612,8 @@ export const saveDocumentVersion = mutation({
518
612
  * bump the revision and log the publish — all in this transaction; otherwise refuse with
519
613
  * `conflict` and the current row. The `cmsDocuments` trigger sees the pin move and
520
614
  * reindexes + revalidates (EC-286 / EC-146) — the version row it reads exists already.
615
+ * EC-649: `strictTargets` (the ones the operation's guard judged published) must still be
616
+ * published, else `{ status: 'strict-targets', refs }` and nothing is written.
521
617
  */
522
618
  export const publishDocument = mutation({
523
619
  args: {
@@ -527,6 +623,7 @@ export const publishDocument = mutation({
527
623
  label: v.optional(v.string()),
528
624
  origin: v.optional(v.string()),
529
625
  at: v.string(),
626
+ strictTargets: v.optional(v.array(documentRefValidator)),
530
627
  },
531
628
  handler: async (ctx, args) => {
532
629
  const actor = await requireCapability(ctx, 'content.write') // EC-150
@@ -535,6 +632,8 @@ export const publishDocument = mutation({
535
632
  if (existing.revision !== args.expectedRevision) {
536
633
  return { status: 'conflict' as const, current: withUnfoldedDocument(existing) }
537
634
  }
635
+ const unpublished = await unpublishedStrictTargets(ctx, args.strictTargets)
636
+ if (unpublished.length > 0) return { status: 'strict-targets' as const, refs: unpublished }
538
637
  const draft = unfoldDocument(existing.document as CmsDocument)
539
638
  const version = await insertVersion(ctx, {
540
639
  collection: args.collection,
@@ -652,8 +751,10 @@ export const listAllDocumentVersions = query({
652
751
  * draft reads `true` — Codex P05's sibling), the revision bumps, a `pin` row is logged.
653
752
  *
654
753
  * 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).
754
+ * pin integrity, authority, and (EC-649) the strict targets the operation judged are still
755
+ * published (`{ status: 'strict-targets', refs }` otherwise). Schema validation stays the
756
+ * client gate (`documents.publish` runs it, with the hidden-clear, before `publishDocument`
757
+ * records the row this pins).
657
758
  */
658
759
  export const publishDocumentVersion = mutation({
659
760
  args: {
@@ -663,6 +764,7 @@ export const publishDocumentVersion = mutation({
663
764
  updatedAt: v.string(),
664
765
  // EC-447: the candidate first-publish instant, written SET-ONCE below.
665
766
  publishedAt: v.string(),
767
+ strictTargets: v.optional(v.array(documentRefValidator)),
666
768
  },
667
769
  handler: async (ctx, args) => {
668
770
  const actor = await requireCapability(ctx, 'content.write') // EC-150
@@ -671,6 +773,8 @@ export const publishDocumentVersion = mutation({
671
773
  const target = await versionRow(ctx, args.collection, args.docId, args.version)
672
774
  if (!target) return { status: 'missing-version' as const }
673
775
  if (!isPinnableVersion(target)) return { status: 'not-pinnable' as const }
776
+ const unpublished = await unpublishedStrictTargets(ctx, args.strictTargets)
777
+ if (unpublished.length > 0) return { status: 'strict-targets' as const, refs: unpublished }
674
778
  const revision = doc.revision + 1
675
779
  const patch = {
676
780
  revision,
@@ -692,12 +796,24 @@ export const publishDocumentVersion = mutation({
692
796
  },
693
797
  })
694
798
 
799
+ /**
800
+ * Clear the live pin (EC-224). EC-649: `publishedReferrers` — the published referrers the
801
+ * operation's strict guard judged; a published referrer of this document not among them (at
802
+ * its current pin) refuses the unpublish with `{ status: 'unjudged-referrers', refs }`.
803
+ */
695
804
  export const unpublishDocument = mutation({
696
- args: { collection: v.string(), docId: v.string(), updatedAt: v.string() },
805
+ args: {
806
+ collection: v.string(),
807
+ docId: v.string(),
808
+ updatedAt: v.string(),
809
+ publishedReferrers: v.optional(v.array(publishedReferrerValidator)),
810
+ },
697
811
  handler: async (ctx, args) => {
698
812
  const actor = await requireCapability(ctx, 'content.write') // EC-150
699
813
  const doc = await draftRow(ctx, args.collection, args.docId)
700
814
  if (!doc) return null
815
+ const unjudged = await unjudgedPublishedReferrers(ctx, args.collection, args.docId, args.publishedReferrers)
816
+ if (unjudged.length > 0) return { status: 'unjudged-referrers' as const, refs: unjudged }
701
817
  const revision = doc.revision + 1
702
818
  // Convex patch removes a field set to `undefined` — clears the live pin. EC-286: the
703
819
  // trigger drops the published index rows and revalidates. EC-447/EC-449: the