@elytracms/studio 0.0.94 → 0.0.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/cms.ts +124 -8
- 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/convex/strict-reference-race.test.ts +139 -0
- package/dist/addon.d.ts +1 -1
- package/dist/addon.js.map +1 -1
- package/dist/components/ai/ai-chat-panel.js +15 -2
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- package/dist/components/ai/translate-document-action.d.ts +6 -0
- package/dist/components/ai/translate-document-action.js.map +1 -1
- package/dist/components/ai/translate-document-dialog.d.ts +1 -1
- package/dist/components/ai/translate-document-dialog.js +5 -5
- package/dist/components/ai/translate-document-dialog.js.map +1 -1
- package/dist/components/assets/asset-detail.js +29 -11
- package/dist/components/assets/asset-detail.js.map +1 -1
- package/dist/components/assets/asset-locale.d.ts +16 -0
- package/dist/components/assets/asset-locale.js +30 -0
- package/dist/components/assets/asset-locale.js.map +1 -0
- package/dist/components/assets/asset-meta-drift-notice.d.ts +13 -0
- package/dist/components/assets/asset-meta-drift-notice.js +22 -0
- package/dist/components/assets/asset-meta-drift-notice.js.map +1 -0
- package/dist/components/assets/asset-meta-label.d.ts +13 -0
- package/dist/components/assets/asset-meta-label.js +17 -0
- package/dist/components/assets/asset-meta-label.js.map +1 -0
- package/dist/components/assets/asset-metadata.d.ts +89 -15
- package/dist/components/assets/asset-metadata.js +123 -11
- package/dist/components/assets/asset-metadata.js.map +1 -1
- package/dist/components/assets/asset-search.d.ts +6 -2
- package/dist/components/assets/asset-search.js +19 -3
- 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 +25 -9
- package/dist/components/assets/assets-collection-view-source.js +80 -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 +101 -0
- package/dist/components/builder/canvas-rebase.js +179 -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 -8
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/builder/composition-field-editor.d.ts +26 -2
- package/dist/components/builder/composition-field-editor.js +46 -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-core.d.ts +2 -2
- package/dist/components/cms/asset-picker-core.js +2 -2
- package/dist/components/cms/asset-picker-core.js.map +1 -1
- package/dist/components/cms/asset-picker.js +6 -2
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/canvas-commit.d.ts +62 -0
- package/dist/components/cms/canvas-commit.js +39 -0
- package/dist/components/cms/canvas-commit.js.map +1 -0
- package/dist/components/cms/cms-store.d.ts +21 -8
- package/dist/components/cms/cms-store.js +19 -10
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.js +27 -18
- 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 +12 -2
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/draft-commit.d.ts +16 -3
- package/dist/components/cms/draft-commit.js +3 -2
- package/dist/components/cms/draft-commit.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +121 -5
- package/dist/components/cms/use-document-draft.js +197 -54
- 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 +11 -5
- package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
- package/dist/components/components/transfer-dialog.js +6 -4
- package/dist/components/components/transfer-dialog.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/ai/chat-state.d.ts +7 -0
- package/dist/lib/ai/chat-state.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +8 -0
- package/dist/lib/ai/tools.js +4 -0
- package/dist/lib/ai/tools.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 +25 -105
- package/dist/lib/cms-views/view-model.js +36 -103
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/collection-view/__fixtures__/document-writes.js +1 -0
- package/dist/lib/collection-view/__fixtures__/document-writes.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +21 -8
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/data/collection-content.js +5 -0
- package/dist/lib/data/collection-content.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 +20 -4
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +16 -1
- package/dist/lib/i18n/messages.en.js +22 -5
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +80 -35
- package/dist/lib/operations/cms-persistence.js +348 -85
- package/dist/lib/operations/cms-persistence.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/convex-adapter.js +54 -8
- package/dist/lib/persistence/convex-adapter.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 +2 -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
|
package/convex/cms.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { v } from 'convex/values'
|
|
2
2
|
import {
|
|
3
|
+
DOCUMENT_KEY_SEPARATOR,
|
|
3
4
|
DRAFT_VARIANT,
|
|
4
5
|
applyDocumentPatches,
|
|
6
|
+
documentKey,
|
|
5
7
|
documentsSemanticEqual,
|
|
6
8
|
foldDocument,
|
|
7
9
|
foldPatchesForStorage,
|
|
@@ -26,10 +28,13 @@ import { assertWithinWriteLimits } from './write-limits'
|
|
|
26
28
|
*
|
|
27
29
|
* Owner, verbatim: "yes keep validation client-side. server-side validation enforces
|
|
28
30
|
* migrations or could lead to invalid/lost data (the payload-sql-approach). schema should
|
|
29
|
-
* be the lens and the mask to put on the data." So these handlers enforce
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
31
|
+
* be the lens and the mask to put on the data." So these handlers enforce only data-only
|
|
32
|
+
* facts: **the revision** (every write and publish carries the one it was made against; a
|
|
33
|
+
* moved document refuses with `conflict`), **pin integrity** (a pinned version exists and
|
|
34
|
+
* belongs to this document), **authority** (`requireCapability`) and — EC-649 — the
|
|
35
|
+
* **strict-reference facts the operation's guard judged** (a publish's strict targets are
|
|
36
|
+
* still published; an unpublish / delete target has no published referrer the guard did not
|
|
37
|
+
* judge): the operation decides what is strict, these handlers only re-check that data.
|
|
33
38
|
* They never validate a document against a schema and never canonicalise a value — the
|
|
34
39
|
* client gate does both (a `content.write` member bypassing the studio can publish an
|
|
35
40
|
* invalid document; delivery parse raises it visibly, AD-14).
|
|
@@ -156,6 +161,70 @@ const preconditionValidator = v.union(
|
|
|
156
161
|
v.object({ ifCollectionEmpty: v.literal(true) }),
|
|
157
162
|
)
|
|
158
163
|
|
|
164
|
+
// --- EC-649: strict-reference integrity as data preconditions (Codex F08) -------------
|
|
165
|
+
//
|
|
166
|
+
// The operation judges strictness (it holds the schema); these handlers hold none (EC-565
|
|
167
|
+
// 1e). They re-check, inside their own transaction, only the DATA the operation's guard
|
|
168
|
+
// judged: a publish / pin's strict targets are still published; an unpublish / delete's
|
|
169
|
+
// target has no published referrer the guard did not judge at its current pin. Convex OCC
|
|
170
|
+
// makes the check and the write one serializable step: a concurrent publish of a referrer
|
|
171
|
+
// writes the very `referenceEntries` range (by_target) and `cmsDocuments` rows read here.
|
|
172
|
+
|
|
173
|
+
const documentRefValidator = v.object({ collection: v.string(), id: v.string() })
|
|
174
|
+
const publishedReferrerValidator = v.object({
|
|
175
|
+
collection: v.string(),
|
|
176
|
+
id: v.string(),
|
|
177
|
+
publishedVersion: v.number(),
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
interface WireRef {
|
|
181
|
+
collection: string
|
|
182
|
+
id: string
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The strict targets that are gone or carry no pin (a publish / pin refuses on any). */
|
|
186
|
+
async function unpublishedStrictTargets(ctx: MutationCtx, targets: readonly WireRef[] | undefined): Promise<WireRef[]> {
|
|
187
|
+
const unpublished: WireRef[] = []
|
|
188
|
+
for (const target of targets ?? []) {
|
|
189
|
+
const row = await draftRow(ctx, target.collection, target.id)
|
|
190
|
+
if (row?.publishedVersion === undefined) unpublished.push({ collection: target.collection, id: target.id })
|
|
191
|
+
}
|
|
192
|
+
return unpublished
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The published referrers of `collection:docId` the guard did not judge at their current pin —
|
|
197
|
+
* read from the reference index's PUBLISHED variant (`by_target`, written from the pinned
|
|
198
|
+
* snapshot in the same transaction as every pin move — EC-286 / EC-589), so a referrer published
|
|
199
|
+
* after the guard read is seen here. `undefined` judged = not checked (a caller without the
|
|
200
|
+
* precondition).
|
|
201
|
+
*/
|
|
202
|
+
async function unjudgedPublishedReferrers(
|
|
203
|
+
ctx: MutationCtx,
|
|
204
|
+
collection: string,
|
|
205
|
+
docId: string,
|
|
206
|
+
judged: readonly (WireRef & { publishedVersion: number })[] | undefined,
|
|
207
|
+
): Promise<WireRef[]> {
|
|
208
|
+
if (!judged) return []
|
|
209
|
+
const targetKey = documentKey({ collection, id: docId })
|
|
210
|
+
const pins = new Map(judged.map((referrer) => [documentKey(referrer), referrer.publishedVersion]))
|
|
211
|
+
const entries = await ctx.db
|
|
212
|
+
.query('referenceEntries')
|
|
213
|
+
.withIndex('by_target', (q) => q.eq('targetType', 'document').eq('targetId', targetKey).eq('variant', 'published'))
|
|
214
|
+
.collect()
|
|
215
|
+
const unjudged: WireRef[] = []
|
|
216
|
+
for (const sourceId of new Set(entries.map((entry) => entry.sourceId))) {
|
|
217
|
+
if (sourceId === targetKey) continue
|
|
218
|
+
// A collection id never contains the separator (EC-578 F10), so the first one splits.
|
|
219
|
+
const at = sourceId.indexOf(DOCUMENT_KEY_SEPARATOR)
|
|
220
|
+
const source = { collection: sourceId.slice(0, at), id: sourceId.slice(at + 1) }
|
|
221
|
+
const row = await draftRow(ctx, source.collection, source.id)
|
|
222
|
+
if (row?.publishedVersion === undefined) continue
|
|
223
|
+
if (pins.get(sourceId) !== row.publishedVersion) unjudged.push(source)
|
|
224
|
+
}
|
|
225
|
+
return unjudged
|
|
226
|
+
}
|
|
227
|
+
|
|
159
228
|
/**
|
|
160
229
|
* The whole-document write (EC-595 binding point 2, EC-578): create (`ifAbsent`), a
|
|
161
230
|
* singleton's first document (`ifCollectionEmpty`), or a replace at an exact revision —
|
|
@@ -421,11 +490,35 @@ export const listDocumentPatches = query({
|
|
|
421
490
|
},
|
|
422
491
|
})
|
|
423
492
|
|
|
493
|
+
/**
|
|
494
|
+
* Delete a document with its version history, patch log and presence. EC-648: an optional
|
|
495
|
+
* `precondition: { revision }` — the delete happens only while the draft is at exactly that
|
|
496
|
+
* revision, checked in this transaction (a changeset reverting its own create carries the
|
|
497
|
+
* revision the create recorded, so a colleague's later edit refuses it). Data-only (1e).
|
|
498
|
+
* Returns `{ status: 'conflict', current }` when the precondition does not hold. EC-649: the
|
|
499
|
+
* precondition's `publishedReferrers` (the ones the operation's strict guard judged) — a
|
|
500
|
+
* published referrer not among them refuses the delete with `{ status: 'unjudged-referrers' }`.
|
|
501
|
+
*/
|
|
424
502
|
export const deleteDocument = mutation({
|
|
425
|
-
args: {
|
|
503
|
+
args: {
|
|
504
|
+
collection: v.string(),
|
|
505
|
+
docId: v.string(),
|
|
506
|
+
precondition: v.optional(
|
|
507
|
+
v.object({
|
|
508
|
+
revision: v.optional(v.number()),
|
|
509
|
+
publishedReferrers: v.optional(v.array(publishedReferrerValidator)),
|
|
510
|
+
}),
|
|
511
|
+
),
|
|
512
|
+
},
|
|
426
513
|
handler: async (ctx, args) => {
|
|
427
514
|
await requireCapability(ctx, 'content.write') // EC-150
|
|
428
515
|
const existing = await draftRow(ctx, args.collection, args.docId)
|
|
516
|
+
const revision = args.precondition?.revision
|
|
517
|
+
if (revision !== undefined && existing?.revision !== revision) {
|
|
518
|
+
return { status: 'conflict' as const, current: existing ? withUnfoldedDocument(existing) : null }
|
|
519
|
+
}
|
|
520
|
+
const unjudged = await unjudgedPublishedReferrers(ctx, args.collection, args.docId, args.precondition?.publishedReferrers)
|
|
521
|
+
if (unjudged.length > 0) return { status: 'unjudged-referrers' as const, refs: unjudged }
|
|
429
522
|
if (existing) await ctx.db.delete(existing._id)
|
|
430
523
|
// Cascade the version history (EC-224), the patch log and presence (EC-595).
|
|
431
524
|
const versions = await ctx.db
|
|
@@ -436,6 +529,7 @@ export const deleteDocument = mutation({
|
|
|
436
529
|
await deleteBookkeeping(ctx, args.collection, args.docId)
|
|
437
530
|
// EC-286: the write-chokepoint trigger drops this document's index rows (and
|
|
438
531
|
// revalidates if it was published) when the `cmsDocuments` delete commits.
|
|
532
|
+
return { status: 'ok' as const }
|
|
439
533
|
},
|
|
440
534
|
})
|
|
441
535
|
|
|
@@ -518,6 +612,8 @@ export const saveDocumentVersion = mutation({
|
|
|
518
612
|
* bump the revision and log the publish — all in this transaction; otherwise refuse with
|
|
519
613
|
* `conflict` and the current row. The `cmsDocuments` trigger sees the pin move and
|
|
520
614
|
* reindexes + revalidates (EC-286 / EC-146) — the version row it reads exists already.
|
|
615
|
+
* EC-649: `strictTargets` (the ones the operation's guard judged published) must still be
|
|
616
|
+
* published, else `{ status: 'strict-targets', refs }` and nothing is written.
|
|
521
617
|
*/
|
|
522
618
|
export const publishDocument = mutation({
|
|
523
619
|
args: {
|
|
@@ -527,6 +623,7 @@ export const publishDocument = mutation({
|
|
|
527
623
|
label: v.optional(v.string()),
|
|
528
624
|
origin: v.optional(v.string()),
|
|
529
625
|
at: v.string(),
|
|
626
|
+
strictTargets: v.optional(v.array(documentRefValidator)),
|
|
530
627
|
},
|
|
531
628
|
handler: async (ctx, args) => {
|
|
532
629
|
const actor = await requireCapability(ctx, 'content.write') // EC-150
|
|
@@ -535,6 +632,8 @@ export const publishDocument = mutation({
|
|
|
535
632
|
if (existing.revision !== args.expectedRevision) {
|
|
536
633
|
return { status: 'conflict' as const, current: withUnfoldedDocument(existing) }
|
|
537
634
|
}
|
|
635
|
+
const unpublished = await unpublishedStrictTargets(ctx, args.strictTargets)
|
|
636
|
+
if (unpublished.length > 0) return { status: 'strict-targets' as const, refs: unpublished }
|
|
538
637
|
const draft = unfoldDocument(existing.document as CmsDocument)
|
|
539
638
|
const version = await insertVersion(ctx, {
|
|
540
639
|
collection: args.collection,
|
|
@@ -652,8 +751,10 @@ export const listAllDocumentVersions = query({
|
|
|
652
751
|
* draft reads `true` — Codex P05's sibling), the revision bumps, a `pin` row is logged.
|
|
653
752
|
*
|
|
654
753
|
* Server-side trust (EC-565 1e, owner-ratified): the checks here are DATA-ONLY — revision,
|
|
655
|
-
* pin integrity, authority
|
|
656
|
-
*
|
|
754
|
+
* pin integrity, authority, and (EC-649) the strict targets the operation judged are still
|
|
755
|
+
* published (`{ status: 'strict-targets', refs }` otherwise). Schema validation stays the
|
|
756
|
+
* client gate (`documents.publish` runs it, with the hidden-clear, before `publishDocument`
|
|
757
|
+
* records the row this pins).
|
|
657
758
|
*/
|
|
658
759
|
export const publishDocumentVersion = mutation({
|
|
659
760
|
args: {
|
|
@@ -663,6 +764,7 @@ export const publishDocumentVersion = mutation({
|
|
|
663
764
|
updatedAt: v.string(),
|
|
664
765
|
// EC-447: the candidate first-publish instant, written SET-ONCE below.
|
|
665
766
|
publishedAt: v.string(),
|
|
767
|
+
strictTargets: v.optional(v.array(documentRefValidator)),
|
|
666
768
|
},
|
|
667
769
|
handler: async (ctx, args) => {
|
|
668
770
|
const actor = await requireCapability(ctx, 'content.write') // EC-150
|
|
@@ -671,6 +773,8 @@ export const publishDocumentVersion = mutation({
|
|
|
671
773
|
const target = await versionRow(ctx, args.collection, args.docId, args.version)
|
|
672
774
|
if (!target) return { status: 'missing-version' as const }
|
|
673
775
|
if (!isPinnableVersion(target)) return { status: 'not-pinnable' as const }
|
|
776
|
+
const unpublished = await unpublishedStrictTargets(ctx, args.strictTargets)
|
|
777
|
+
if (unpublished.length > 0) return { status: 'strict-targets' as const, refs: unpublished }
|
|
674
778
|
const revision = doc.revision + 1
|
|
675
779
|
const patch = {
|
|
676
780
|
revision,
|
|
@@ -692,12 +796,24 @@ export const publishDocumentVersion = mutation({
|
|
|
692
796
|
},
|
|
693
797
|
})
|
|
694
798
|
|
|
799
|
+
/**
|
|
800
|
+
* Clear the live pin (EC-224). EC-649: `publishedReferrers` — the published referrers the
|
|
801
|
+
* operation's strict guard judged; a published referrer of this document not among them (at
|
|
802
|
+
* its current pin) refuses the unpublish with `{ status: 'unjudged-referrers', refs }`.
|
|
803
|
+
*/
|
|
695
804
|
export const unpublishDocument = mutation({
|
|
696
|
-
args: {
|
|
805
|
+
args: {
|
|
806
|
+
collection: v.string(),
|
|
807
|
+
docId: v.string(),
|
|
808
|
+
updatedAt: v.string(),
|
|
809
|
+
publishedReferrers: v.optional(v.array(publishedReferrerValidator)),
|
|
810
|
+
},
|
|
697
811
|
handler: async (ctx, args) => {
|
|
698
812
|
const actor = await requireCapability(ctx, 'content.write') // EC-150
|
|
699
813
|
const doc = await draftRow(ctx, args.collection, args.docId)
|
|
700
814
|
if (!doc) return null
|
|
815
|
+
const unjudged = await unjudgedPublishedReferrers(ctx, args.collection, args.docId, args.publishedReferrers)
|
|
816
|
+
if (unjudged.length > 0) return { status: 'unjudged-referrers' as const, refs: unjudged }
|
|
701
817
|
const revision = doc.revision + 1
|
|
702
818
|
// Convex patch removes a field set to `undefined` — clears the live pin. EC-286: the
|
|
703
819
|
// trigger drops the published index rows and revalidates. EC-447/EC-449: the
|