@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.
- package/convex/asset-alt-migration.test.ts +2 -2
- package/convex/asset-meta-patch.test.ts +218 -0
- package/convex/asset-replace-delete-race.test.ts +127 -0
- package/convex/assets.ts +27 -2
- package/convex/delivery-redirects.test.ts +167 -0
- package/convex/delivery.ts +15 -5
- package/convex/rail-write-parity.test.ts +6 -6
- package/convex/redirects.ts +1 -1
- package/convex/schema.ts +6 -0
- package/dist/addon.d.ts +1 -1
- package/dist/addon.js.map +1 -1
- package/dist/components/assets/asset-detail.js +26 -11
- package/dist/components/assets/asset-detail.js.map +1 -1
- package/dist/components/assets/asset-locale.d.ts +9 -0
- package/dist/components/assets/asset-locale.js +16 -0
- package/dist/components/assets/asset-locale.js.map +1 -0
- package/dist/components/assets/asset-meta-label.d.ts +12 -0
- package/dist/components/assets/asset-meta-label.js +15 -0
- package/dist/components/assets/asset-meta-label.js.map +1 -0
- package/dist/components/assets/asset-metadata.d.ts +69 -15
- package/dist/components/assets/asset-metadata.js +99 -11
- package/dist/components/assets/asset-metadata.js.map +1 -1
- package/dist/components/assets/asset-search.js +8 -2
- package/dist/components/assets/asset-search.js.map +1 -1
- package/dist/components/assets/asset-thumb.js +6 -2
- package/dist/components/assets/asset-thumb.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.d.ts +23 -9
- package/dist/components/assets/assets-collection-view-source.js +78 -26
- package/dist/components/assets/assets-collection-view-source.js.map +1 -1
- package/dist/components/assets/assets-store.js +17 -4
- package/dist/components/assets/assets-store.js.map +1 -1
- package/dist/components/builder/builder-store.d.ts +21 -0
- package/dist/components/builder/builder-store.js +22 -0
- package/dist/components/builder/builder-store.js.map +1 -1
- package/dist/components/builder/canvas-commit-boundary.d.ts +22 -0
- package/dist/components/builder/canvas-commit-boundary.js +27 -0
- package/dist/components/builder/canvas-commit-boundary.js.map +1 -0
- package/dist/components/builder/canvas-rebase.d.ts +77 -0
- package/dist/components/builder/canvas-rebase.js +169 -0
- package/dist/components/builder/canvas-rebase.js.map +1 -0
- package/dist/components/builder/canvas-render-context.d.ts +6 -3
- package/dist/components/builder/canvas-render-context.js +17 -9
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/builder/composition-field-editor.d.ts +20 -2
- package/dist/components/builder/composition-field-editor.js +45 -27
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/node-factory.js +15 -3
- package/dist/components/builder/node-factory.js.map +1 -1
- package/dist/components/builder/property-inspector.js +3 -1
- package/dist/components/builder/property-inspector.js.map +1 -1
- package/dist/components/builder/run-inspector.js +10 -6
- package/dist/components/builder/run-inspector.js.map +1 -1
- package/dist/components/cms/asset-field.d.ts +5 -2
- package/dist/components/cms/asset-field.js +34 -17
- package/dist/components/cms/asset-field.js.map +1 -1
- package/dist/components/cms/asset-picker.js +3 -1
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/canvas-commit.d.ts +56 -0
- package/dist/components/cms/canvas-commit.js +35 -0
- package/dist/components/cms/canvas-commit.js.map +1 -0
- package/dist/components/cms/collection-table.js +23 -7
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/commit-policy.d.ts +6 -2
- package/dist/components/cms/commit-policy.js +1 -1
- package/dist/components/cms/commit-policy.js.map +1 -1
- package/dist/components/cms/detail-editor.js +11 -1
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +62 -4
- package/dist/components/cms/use-document-draft.js +102 -32
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/cms/use-live-url.d.ts +6 -1
- package/dist/components/cms/use-live-url.js +12 -9
- package/dist/components/cms/use-live-url.js.map +1 -1
- package/dist/components/collection-view/cms-collection-view-source.js +9 -5
- package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
- package/dist/components/deployment-setup-gate.js +10 -1
- package/dist/components/deployment-setup-gate.js.map +1 -1
- package/dist/components/records/record-lens.js +3 -8
- package/dist/components/records/record-lens.js.map +1 -1
- package/dist/lib/assets/upload.d.ts +25 -7
- package/dist/lib/assets/upload.js +30 -21
- package/dist/lib/assets/upload.js.map +1 -1
- package/dist/lib/cms/published-documents.d.ts +24 -6
- package/dist/lib/cms/published-documents.js +39 -6
- package/dist/lib/cms/published-documents.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +14 -101
- package/dist/lib/cms-views/view-model.js +20 -101
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +6 -1
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/engine-version.d.ts +1 -1
- package/dist/lib/engine-version.js +1 -1
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +7 -3
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +5 -1
- package/dist/lib/i18n/messages.en.js +8 -4
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/config-manifest-lookup.d.ts +1 -1
- package/dist/lib/operations/config-manifest-lookup.js +3 -3
- package/dist/lib/operations/config-manifest-lookup.js.map +1 -1
- package/dist/lib/persistence/deployment-readiness.js +14 -11
- package/dist/lib/persistence/deployment-readiness.js.map +1 -1
- package/dist/lib/persona/labels.js +1 -0
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/lib/query/index.d.ts +2 -2
- package/dist/lib/query/index.js +1 -1
- package/dist/lib/query/index.js.map +1 -1
- package/dist/lib/query/operations-query.d.ts +26 -0
- package/dist/lib/query/operations-query.js +12 -0
- package/dist/lib/query/operations-query.js.map +1 -1
- package/dist/lib/registry/index.js +2 -2
- package/dist/lib/registry/index.js.map +1 -1
- package/dist/lib/registry/preview.d.ts +10 -0
- package/dist/lib/registry/preview.js +13 -4
- package/dist/lib/registry/preview.js.map +1 -1
- package/dist/lib/routes/route-issues.js +6 -3
- package/dist/lib/routes/route-issues.js.map +1 -1
- package/dist/lib/workspace/workspace-source.js +6 -7
- package/dist/lib/workspace/workspace-source.js.map +1 -1
- package/dist/previews/entry-node.d.ts +14 -0
- package/dist/previews/entry-node.js +22 -0
- package/dist/previews/entry-node.js.map +1 -0
- package/dist/previews/harness.js +2 -16
- package/dist/previews/harness.js.map +1 -1
- 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 —
|
|
29
|
-
* `alt` column can only be cleared (EC-587: an asset's alt is `meta.alt`)
|
|
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
|
+
})
|
package/convex/delivery.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { v } from 'convex/values'
|
|
2
|
-
import { DRAFT_PERSPECTIVE, DRAFT_VARIANT,
|
|
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
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
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
|
|
74
|
-
*
|
|
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', {
|
|
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', {
|
|
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
|
})
|