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