@elytracms/studio 0.0.94 → 0.0.95

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 (126) 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/delivery-redirects.test.ts +167 -0
  6. package/convex/delivery.ts +15 -5
  7. package/convex/rail-write-parity.test.ts +6 -6
  8. package/convex/redirects.ts +1 -1
  9. package/convex/schema.ts +6 -0
  10. package/dist/addon.d.ts +1 -1
  11. package/dist/addon.js.map +1 -1
  12. package/dist/components/assets/asset-detail.js +26 -11
  13. package/dist/components/assets/asset-detail.js.map +1 -1
  14. package/dist/components/assets/asset-locale.d.ts +9 -0
  15. package/dist/components/assets/asset-locale.js +16 -0
  16. package/dist/components/assets/asset-locale.js.map +1 -0
  17. package/dist/components/assets/asset-meta-label.d.ts +12 -0
  18. package/dist/components/assets/asset-meta-label.js +15 -0
  19. package/dist/components/assets/asset-meta-label.js.map +1 -0
  20. package/dist/components/assets/asset-metadata.d.ts +69 -15
  21. package/dist/components/assets/asset-metadata.js +99 -11
  22. package/dist/components/assets/asset-metadata.js.map +1 -1
  23. package/dist/components/assets/asset-search.js +8 -2
  24. package/dist/components/assets/asset-search.js.map +1 -1
  25. package/dist/components/assets/asset-thumb.js +6 -2
  26. package/dist/components/assets/asset-thumb.js.map +1 -1
  27. package/dist/components/assets/assets-collection-view-source.d.ts +23 -9
  28. package/dist/components/assets/assets-collection-view-source.js +78 -26
  29. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  30. package/dist/components/assets/assets-store.js +17 -4
  31. package/dist/components/assets/assets-store.js.map +1 -1
  32. package/dist/components/builder/builder-store.d.ts +21 -0
  33. package/dist/components/builder/builder-store.js +22 -0
  34. package/dist/components/builder/builder-store.js.map +1 -1
  35. package/dist/components/builder/canvas-commit-boundary.d.ts +22 -0
  36. package/dist/components/builder/canvas-commit-boundary.js +27 -0
  37. package/dist/components/builder/canvas-commit-boundary.js.map +1 -0
  38. package/dist/components/builder/canvas-rebase.d.ts +77 -0
  39. package/dist/components/builder/canvas-rebase.js +169 -0
  40. package/dist/components/builder/canvas-rebase.js.map +1 -0
  41. package/dist/components/builder/canvas-render-context.d.ts +6 -3
  42. package/dist/components/builder/canvas-render-context.js +17 -9
  43. package/dist/components/builder/canvas-render-context.js.map +1 -1
  44. package/dist/components/builder/composition-field-editor.d.ts +20 -2
  45. package/dist/components/builder/composition-field-editor.js +45 -27
  46. package/dist/components/builder/composition-field-editor.js.map +1 -1
  47. package/dist/components/builder/node-factory.js +15 -3
  48. package/dist/components/builder/node-factory.js.map +1 -1
  49. package/dist/components/builder/property-inspector.js +3 -1
  50. package/dist/components/builder/property-inspector.js.map +1 -1
  51. package/dist/components/builder/run-inspector.js +10 -6
  52. package/dist/components/builder/run-inspector.js.map +1 -1
  53. package/dist/components/cms/asset-field.d.ts +5 -2
  54. package/dist/components/cms/asset-field.js +34 -17
  55. package/dist/components/cms/asset-field.js.map +1 -1
  56. package/dist/components/cms/asset-picker.js +3 -1
  57. package/dist/components/cms/asset-picker.js.map +1 -1
  58. package/dist/components/cms/canvas-commit.d.ts +56 -0
  59. package/dist/components/cms/canvas-commit.js +35 -0
  60. package/dist/components/cms/canvas-commit.js.map +1 -0
  61. package/dist/components/cms/collection-table.js +23 -7
  62. package/dist/components/cms/collection-table.js.map +1 -1
  63. package/dist/components/cms/commit-policy.d.ts +6 -2
  64. package/dist/components/cms/commit-policy.js +1 -1
  65. package/dist/components/cms/commit-policy.js.map +1 -1
  66. package/dist/components/cms/detail-editor.js +11 -1
  67. package/dist/components/cms/detail-editor.js.map +1 -1
  68. package/dist/components/cms/use-document-draft.d.ts +62 -4
  69. package/dist/components/cms/use-document-draft.js +102 -32
  70. package/dist/components/cms/use-document-draft.js.map +1 -1
  71. package/dist/components/cms/use-live-url.d.ts +6 -1
  72. package/dist/components/cms/use-live-url.js +12 -9
  73. package/dist/components/cms/use-live-url.js.map +1 -1
  74. package/dist/components/collection-view/cms-collection-view-source.js +9 -5
  75. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  76. package/dist/components/deployment-setup-gate.js +10 -1
  77. package/dist/components/deployment-setup-gate.js.map +1 -1
  78. package/dist/components/records/record-lens.js +3 -8
  79. package/dist/components/records/record-lens.js.map +1 -1
  80. package/dist/lib/assets/upload.d.ts +25 -7
  81. package/dist/lib/assets/upload.js +30 -21
  82. package/dist/lib/assets/upload.js.map +1 -1
  83. package/dist/lib/cms/published-documents.d.ts +24 -6
  84. package/dist/lib/cms/published-documents.js +39 -6
  85. package/dist/lib/cms/published-documents.js.map +1 -1
  86. package/dist/lib/cms-views/view-model.d.ts +14 -101
  87. package/dist/lib/cms-views/view-model.js +20 -101
  88. package/dist/lib/cms-views/view-model.js.map +1 -1
  89. package/dist/lib/collection-view/collection-view-source.d.ts +6 -1
  90. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  91. package/dist/lib/engine-version.d.ts +1 -1
  92. package/dist/lib/engine-version.js +1 -1
  93. package/dist/lib/engine-version.js.map +1 -1
  94. package/dist/lib/i18n/messages.de.js +7 -3
  95. package/dist/lib/i18n/messages.de.js.map +1 -1
  96. package/dist/lib/i18n/messages.en.d.ts +5 -1
  97. package/dist/lib/i18n/messages.en.js +8 -4
  98. package/dist/lib/i18n/messages.en.js.map +1 -1
  99. package/dist/lib/operations/config-manifest-lookup.d.ts +1 -1
  100. package/dist/lib/operations/config-manifest-lookup.js +3 -3
  101. package/dist/lib/operations/config-manifest-lookup.js.map +1 -1
  102. package/dist/lib/persistence/deployment-readiness.js +14 -11
  103. package/dist/lib/persistence/deployment-readiness.js.map +1 -1
  104. package/dist/lib/persona/labels.js +1 -0
  105. package/dist/lib/persona/labels.js.map +1 -1
  106. package/dist/lib/query/index.d.ts +2 -2
  107. package/dist/lib/query/index.js +1 -1
  108. package/dist/lib/query/index.js.map +1 -1
  109. package/dist/lib/query/operations-query.d.ts +26 -0
  110. package/dist/lib/query/operations-query.js +12 -0
  111. package/dist/lib/query/operations-query.js.map +1 -1
  112. package/dist/lib/registry/index.js +2 -2
  113. package/dist/lib/registry/index.js.map +1 -1
  114. package/dist/lib/registry/preview.d.ts +10 -0
  115. package/dist/lib/registry/preview.js +13 -4
  116. package/dist/lib/registry/preview.js.map +1 -1
  117. package/dist/lib/routes/route-issues.js +6 -3
  118. package/dist/lib/routes/route-issues.js.map +1 -1
  119. package/dist/lib/workspace/workspace-source.js +6 -7
  120. package/dist/lib/workspace/workspace-source.js.map +1 -1
  121. package/dist/previews/entry-node.d.ts +14 -0
  122. package/dist/previews/entry-node.js +22 -0
  123. package/dist/previews/entry-node.js.map +1 -0
  124. package/dist/previews/harness.js +2 -16
  125. package/dist/previews/harness.js.map +1 -1
  126. 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
@@ -0,0 +1,167 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import { makeFunctionReference } from 'convex/server'
4
+ import type { FunctionReference } from 'convex/server'
5
+ import type { ConvexHttpClient } from 'convex/browser'
6
+ // The host's two readers, from their own modules — the package index also loads the React
7
+ // delivery kit, which the edge-runtime environment cannot evaluate.
8
+ import { assembleLiveSource } from '../../next/src/host-content'
9
+ import { createContentSnapshot } from '../../next/src/snapshot'
10
+ import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
11
+ import schema from './schema'
12
+
13
+ /**
14
+ * The delivery snapshot's redirect strand, end to end: rows stored in the deployment's
15
+ * `records` table (kind `redirect`) → the REAL `delivery:snapshot` query (convex-test) →
16
+ * the REAL host loader (`assembleLiveSource`, `@elytracms/next`).
17
+ *
18
+ * EC-628 (Codex F04, `tasks/codex-review-2026-10-27/backend.convex.test.ts` "F04 upstream"):
19
+ * the record store's open data bag accepts drift the normal CRUD refuses (a dashboard edit,
20
+ * an older writer). Before, an enabled row with `to: 42` was SERVED by the snapshot and
21
+ * vanished in the host with zero issues, and a switch that is not a boolean was decided by
22
+ * truthiness (`'false'` served, a missing switch silently paused). Now the snapshot reads
23
+ * every stored redirect through the one rule (`readLiveRedirects`): what it cannot read is
24
+ * withheld and reported on the snapshot's `redirectIssues` strand, which the host carries
25
+ * into its `sourceIssues` and onto the 404 at the redirect's `from`.
26
+ */
27
+
28
+ const modules = {
29
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
30
+ './_generated/api.ts': async () => ({}),
31
+ }
32
+ const snapshot = makeFunctionReference<'query'>('delivery:snapshot')
33
+
34
+ const AT = '2026-10-27T12:00:00.000Z'
35
+ const LOCALES = { default: 'en', locales: ['en'] }
36
+
37
+ /** Insert redirect rows as the record store holds them — raw, as drift reaches it. */
38
+ async function deploymentWith(rows: ReadonlyArray<{ id: string; data: Record<string, unknown> }>) {
39
+ const t = convexTest(schema, modules)
40
+ await t.run(async (ctx) => {
41
+ for (const row of rows) {
42
+ await ctx.db.insert('records', { kind: 'redirect', recordId: row.id, data: row.data, at: AT, createdAt: AT, updatedAt: AT })
43
+ }
44
+ })
45
+ return t
46
+ }
47
+
48
+ const redirect = (id: string, data: Record<string, unknown> = {}) => ({
49
+ id,
50
+ data: { from: `/${id}`, to: '/target', permanent: true, enabled: true, ...data },
51
+ })
52
+
53
+ describe('EC-628 — the snapshot names every stored redirect it cannot serve', () => {
54
+ it('drift is withheld and reported upstream, then located by the host (F04 + disabled-state drift)', async () => {
55
+ const t = await deploymentWith([
56
+ redirect('good'),
57
+ redirect('bad', { to: 42 }), // F04's row
58
+ redirect('paused', { enabled: false }), // a paused redirect: not live, not a finding
59
+ redirect('string-off', { enabled: 'false' }), // truthy: was SERVED before
60
+ { id: 'no-switch', data: { from: '/no-switch', to: '/target', permanent: true } }, // falsy: was silently paused
61
+ ])
62
+ const payload = (await t.query(snapshot, { projectId: 'site' })) as {
63
+ redirects: unknown[]
64
+ redirectIssues: Array<{ code: string; path: unknown[] }>
65
+ }
66
+ expect(payload.redirects).toEqual([{ id: 'good', from: '/good', to: '/target', permanent: true }])
67
+ expect(payload.redirectIssues.map((issue) => [issue.code, issue.path])).toEqual([
68
+ ['invalid-redirect', ['redirects', 'bad']],
69
+ ['invalid-redirect', ['redirects', 'no-switch']],
70
+ ['invalid-redirect', ['redirects', 'string-off']],
71
+ ])
72
+
73
+ const client = assembleLiveSource(payload as never, { locales: LOCALES, collections: [], routes: [] }, 'published').client({
74
+ perspective: 'published',
75
+ })
76
+ // Control: the readable redirect traverses the whole pipeline, with no issue.
77
+ expect(client.resolvePage('/good').status).toBe('redirect')
78
+ expect(client.resolvePage('/good').issues).toEqual([])
79
+ expect(client.sourceIssues.map((issue) => issue.path[1])).toEqual(['bad', 'no-switch', 'string-off'])
80
+ for (const path of ['/bad', '/string-off', '/no-switch']) {
81
+ const result = client.resolvePage(path)
82
+ expect(result.status).toBe('notFound')
83
+ expect(result.issues.map((issue) => issue.code)).toEqual(['invalid-redirect'])
84
+ }
85
+ // The paused redirect is a 404 with nothing to say — pausing is not drift.
86
+ expect(client.resolvePage('/paused').status).toBe('notFound')
87
+ expect(client.resolvePage('/paused').issues).toEqual([])
88
+ })
89
+ })
90
+
91
+ /**
92
+ * EC-627 — "which redirects are live" is decided by ONE reader on every side. The same
93
+ * stored set is read (1) by the Convex delivery snapshot (`delivery:snapshot`, through
94
+ * `readLiveRedirects`) and assembled by the host's live loader, and (2) by the host's OTHER
95
+ * reader, `createContentSnapshot` (`next/src/snapshot.ts`, `readLiveRedirects` over
96
+ * `adapter.records(redirectRecordKind).list`) — through the REAL `ConvexPersistenceAdapter`
97
+ * over the same convex-test deployment, so both sides read the very same stored rows.
98
+ *
99
+ * The set holds what a hand-built copy of the rule gets wrong: a paused redirect (a copy that
100
+ * forgets the switch serves it), a switch that is not a boolean (a truthy copy serves
101
+ * `'false'`), a duplicate `from` (`/old` and `/old/` — the lower id applies on both sides)
102
+ * and an editorial `meta` bag (never on the routing record). Measured: replacing either
103
+ * side's call with a hand-built `.filter((r) => r.enabled)` projection turns this red.
104
+ */
105
+ describe('EC-627 — the Convex snapshot and the host adapter snapshot serve one live set', () => {
106
+ const STORED = [
107
+ redirect('a', { meta: { note: 'editorial only' } }),
108
+ redirect('paused', { enabled: false }),
109
+ redirect('string-off', { enabled: 'false' }),
110
+ { id: 'dup-1', data: { from: '/old', to: '/one', permanent: true, enabled: true } },
111
+ { id: 'dup-2', data: { from: '/old/', to: '/two', permanent: false, enabled: true } },
112
+ ]
113
+ const PATHS = ['/a', '/paused', '/string-off', '/old', '/old/']
114
+
115
+ /** The decision a reader makes per URL: what a visitor gets. */
116
+ const outcomes = (client: { resolvePage(url: string): { status: string; redirect: unknown } }) =>
117
+ PATHS.map((path) => {
118
+ const result = client.resolvePage(path)
119
+ return { path, status: result.status, redirect: result.redirect }
120
+ })
121
+
122
+ it('the same stored set yields the same live set through both readers', async () => {
123
+ const t = await deploymentWith(STORED)
124
+ await t.run(async (ctx) => {
125
+ await ctx.db.insert('members', { userId: 'admin', role: 'admin', createdAt: AT, updatedAt: AT })
126
+ })
127
+
128
+ // (1) the deployment's public snapshot → the host's live loader
129
+ const payload = await t.query(snapshot, { projectId: 'site' })
130
+ const viaSnapshot = assembleLiveSource(payload as never, { locales: LOCALES, collections: [], routes: [] }, 'published')
131
+ .client({ perspective: 'published' })
132
+
133
+ // (2) the host's adapter reader over the same deployment
134
+ const asAdmin = t.withIdentity({ subject: 'admin', issuer: 'https://convex.test' })
135
+ const adapter = new ConvexPersistenceAdapter(
136
+ {
137
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin.query(ref, args ?? {}),
138
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => asAdmin.mutation(ref, args ?? {}),
139
+ } as unknown as ConvexHttpClient,
140
+ 'site',
141
+ )
142
+ const viaAdapter = (await createContentSnapshot(adapter, 'site', { locales: LOCALES, collections: [], routes: [] }))
143
+ .client({ perspective: 'published' })
144
+
145
+ // The instrument can tell outcomes apart — pinned explicitly, not only compared.
146
+ expect(outcomes(viaSnapshot)).toEqual([
147
+ { path: '/a', status: 'redirect', redirect: { target: '/target', permanent: true } },
148
+ { path: '/paused', status: 'notFound', redirect: null },
149
+ { path: '/string-off', status: 'notFound', redirect: null },
150
+ { path: '/old', status: 'redirect', redirect: { target: '/one', permanent: true } },
151
+ { path: '/old/', status: 'redirect', redirect: { target: '/one', permanent: true } },
152
+ ])
153
+ expect(outcomes(viaAdapter)).toEqual(outcomes(viaSnapshot))
154
+ // The duplicate is one issue, the same on both sides (the router's one redirect set).
155
+ const duplicates = (issues: readonly { code: string; path: readonly unknown[] }[]) =>
156
+ issues.filter((issue) => issue.code === 'invalid-redirect').map((issue) => issue.path)
157
+ expect(duplicates(viaSnapshot.routerIssues)).toEqual([['redirects', 'dup-2']])
158
+ expect(duplicates(viaAdapter.routerIssues)).toEqual(duplicates(viaSnapshot.routerIssues))
159
+
160
+ // EC-628 (the adapter snapshot): the drifted row is NAMED on both sides, not only withheld —
161
+ // on the source and on the 404 at its `from`. (Before, the adapter path withheld it silently.)
162
+ const sourcePaths = (client: typeof viaSnapshot) => client.sourceIssues.map((issue) => [issue.code, issue.path])
163
+ expect(sourcePaths(viaSnapshot)).toEqual([['invalid-redirect', ['redirects', 'string-off']]])
164
+ expect(sourcePaths(viaAdapter)).toEqual(sourcePaths(viaSnapshot))
165
+ expect(viaAdapter.resolvePage('/string-off').issues.map((issue) => issue.code)).toEqual(['invalid-redirect'])
166
+ })
167
+ })
@@ -1,5 +1,5 @@
1
1
  import { v } from 'convex/values'
2
- import { DRAFT_PERSPECTIVE, DRAFT_VARIANT, liveRedirectRecords, unfoldValue, variantOf } from '@elytracms/core/cms-core'
2
+ import { DRAFT_PERSPECTIVE, DRAFT_VARIANT, readLiveRedirects, unfoldValue, variantOf } from '@elytracms/core/cms-core'
3
3
  import type { Perspective } from '@elytracms/core/cms-core'
4
4
  import { assetRecordFromRow, deliveryAssetRecord } from '@elytracms/core/persistence'
5
5
  import { constantTimeEquals } from '@elytracms/core/revalidation'
@@ -77,6 +77,12 @@ interface DeliverySnapshot {
77
77
  * infra redirects stay native (`next.config`/`vercel`), evaluated before these.
78
78
  */
79
79
  redirects: unknown[]
80
+ /**
81
+ * EC-628 — one `invalid-redirect` issue per stored redirect the live rule withheld because
82
+ * it could not read it (id, `from` and the kind of fault — never the target or a value).
83
+ * Paused redirects are not here: pausing is not drift.
84
+ */
85
+ redirectIssues: unknown[]
80
86
  }
81
87
 
82
88
  /**
@@ -161,16 +167,20 @@ export async function loadSnapshot(
161
167
  // Editorial redirects (EC-303): only ENABLED entries serve — a disabled redirect is
162
168
  // stored but never live, in either perspective. EC-616: read through the redirect
163
169
  // kind's one server-side reader (records + the marked legacy tolerant read) and
164
- // projected by the ONE enabled-redirect reader, `liveRedirectRecords` (EC-590 / EC-627 —
165
- // this was the last hand-built copy on Convex).
166
- const redirects = liveRedirectRecords(await loadRedirectEntries(ctx, projectId))
170
+ // decided by the ONE live-redirect rule, `readLiveRedirects` (EC-590 / EC-627 — this
171
+ // was the last hand-built copy on Convex). EC-628: a stored redirect the rule cannot
172
+ // read (a non-boolean switch, a non-string `to`) is withheld AND reported — the host
173
+ // carries `redirectIssues` into its source issues — instead of being served or dropped
174
+ // by truthiness. The record kind's own read, not the project schema (EC-585 1e).
175
+ const live = readLiveRedirects(await loadRedirectEntries(ctx, projectId))
167
176
 
168
177
  return {
169
178
  projectId,
170
179
  perspective,
171
180
  documents,
172
181
  assets,
173
- redirects,
182
+ redirects: live.redirects,
183
+ redirectIssues: live.issues,
174
184
  }
175
185
  }
176
186
 
@@ -70,19 +70,19 @@ async function seedHero(adapter: PersistenceAdapter) {
70
70
 
71
71
  /**
72
72
  * "Reset to centre", then clear alt — the two connected clears that never persisted.
73
- * EC-587: the alt is cleared the way the options dialog clears it, by writing the bag
74
- * without it (`meta.alt` is its one home).
73
+ * EC-587: the alt is cleared the way the options dialog clears it — since EC-634 the
74
+ * per-key patch removing `meta.alt` (its one home).
75
75
  */
76
76
  async function metadataClears(adapter: PersistenceAdapter) {
77
77
  await seedHero(adapter)
78
78
  const assets = adapter.assets
79
79
  const reset = await updateAssetMetadata({ assets }, PROJECT_ID, 'asset_hero', { focalPoint: null })
80
- const clearedAlt = await updateAssetMetadata({ assets }, PROJECT_ID, 'asset_hero', { meta: { legacyKey: 'kept' } })
80
+ const clearedAlt = await updateAssetMetadata({ assets }, PROJECT_ID, 'asset_hero', { metaPatch: { alt: null } })
81
81
  expect([reset.ok, clearedAlt.ok]).toEqual([true, true])
82
82
  const after = await adapter.assets.getAsset(PROJECT_ID, 'asset_hero')
83
83
  return {
84
84
  focalPoint: after.focalPoint ?? null,
85
- alt: assetAlt(after),
85
+ alt: assetAlt(after, null),
86
86
  title: after.title ?? null,
87
87
  thumb: after.thumb ?? null,
88
88
  lqip: after.lqip ?? null,
@@ -131,7 +131,7 @@ describe('EC-577 — the asset writers mean the same thing on both adapters', ()
131
131
  async get() {
132
132
  // An editor saves alt while the backfill is fetching this original — the
133
133
  // backfill's write lands after it, from a listing taken before it.
134
- await updateAssetMetadata({ assets: adapter.assets }, PROJECT_ID, 'asset_big', { meta: { alt: 'Mid-run alt' } })
134
+ await updateAssetMetadata({ assets: adapter.assets }, PROJECT_ID, 'asset_big', { metaPatch: { alt: 'Mid-run alt' } })
135
135
  return new Uint8Array([1])
136
136
  },
137
137
  }
@@ -145,7 +145,7 @@ describe('EC-577 — the asset writers mean the same thing on both adapters', ()
145
145
  )
146
146
  expect(progress.generated).toBe(1)
147
147
  const after = await adapter.assets.getAsset(PROJECT_ID, 'asset_big')
148
- expect([assetAlt(after), after.thumb, after.lqip]).toEqual(['Mid-run alt', THUMB, LQIP])
148
+ expect([assetAlt(after, null), after.thumb, after.lqip]).toEqual(['Mid-run alt', THUMB, LQIP])
149
149
  }
150
150
  })
151
151
  })