@elytracms/studio 0.0.96 → 0.0.98
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/assets.ts +16 -2
- package/convex/cms.ts +153 -13
- package/convex/delivery-folded-drift.test.ts +104 -0
- package/convex/delivery.ts +24 -8
- package/convex/depth-codec-lookalike.test.ts +192 -0
- package/convex/fixture-accounts.test.ts +305 -0
- package/convex/fixtureAccounts.ts +298 -0
- package/convex/incarnation.test.ts +149 -0
- package/convex/ingest-attempt.test.ts +161 -0
- package/convex/ingest.test.ts +327 -9
- package/convex/ingest.ts +242 -24
- package/convex/prototype-key.test.ts +92 -0
- package/convex/receipt-races.test.ts +184 -0
- package/convex/records-zod-copy.test.ts +76 -0
- package/convex/records-zod-refinements.convex.test.ts +94 -0
- package/convex/schema.ts +20 -1
- package/convex/strict-delete.test.ts +129 -0
- package/dist/components/cms/cms-store.d.ts +6 -5
- package/dist/components/cms/cms-store.js +14 -34
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/field-control.d.ts +12 -1
- package/dist/components/cms/field-control.js +16 -1
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/import-batch.d.ts +23 -0
- package/dist/components/cms/import-batch.js +43 -0
- package/dist/components/cms/import-batch.js.map +1 -0
- package/dist/components/cms/rich-text/editor.js +13 -10
- package/dist/components/cms/rich-text/editor.js.map +1 -1
- package/dist/components/cms/rich-text/value.d.ts +9 -0
- package/dist/components/cms/rich-text/value.js +13 -0
- package/dist/components/cms/rich-text/value.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +76 -4
- package/dist/components/cms/use-document-draft.js +94 -25
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/records/record-lens.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +5 -2
- 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 +5 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +5 -0
- package/dist/lib/i18n/messages.en.js +5 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +49 -5
- package/dist/lib/operations/cms-persistence.js +237 -47
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +27 -1
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persona/labels.js +2 -0
- package/dist/lib/persona/labels.js.map +1 -1
- package/package.json +5 -3
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import { makeFunctionReference } from 'convex/server'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { z as newerZod } from 'zod-copy-newer'
|
|
6
|
+
import { defineRecordKind } from '@elytracms/core/persistence'
|
|
7
|
+
import type { AnyRecordKind } from '@elytracms/core/persistence'
|
|
8
|
+
import schema from './schema'
|
|
9
|
+
import { defineRecordFunctions } from './record-functions'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* EC-639 — the Convex write path (`defineRecordFunctions(kind).save` → `saveRecord` →
|
|
13
|
+
* `resolveRecordSave`) for a kind declared with ANOTHER zod copy than the engine's: an addon
|
|
14
|
+
* or a consumer's studio declares its kind with its own zod, and its Convex module validates
|
|
15
|
+
* the write in-transaction with that kind. Measured before: a `.default()` column of zod 4.6
|
|
16
|
+
* inside the engine's object (an older copy) refused every create on BOTH adapters
|
|
17
|
+
* (`expected nonoptional, received undefined`). `zod-copy-newer` is a real second copy (an
|
|
18
|
+
* aliased devDependency), not a mock.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const sameCopyKind = defineRecordKind({
|
|
22
|
+
kind: 'sameTicket',
|
|
23
|
+
module: 'sameTickets',
|
|
24
|
+
fields: { title: z.string().min(1), done: z.boolean().default(false), note: z.string().optional() },
|
|
25
|
+
})
|
|
26
|
+
const newerCopyKind = defineRecordKind({
|
|
27
|
+
kind: 'newerTicket',
|
|
28
|
+
module: 'newerTickets',
|
|
29
|
+
fields: { title: newerZod.string().min(1), done: newerZod.boolean().default(false), note: newerZod.string().optional() },
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const modules = {
|
|
33
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
34
|
+
'./_generated/api.ts': async () => ({}),
|
|
35
|
+
[`./${sameCopyKind.module}.ts`]: async () => defineRecordFunctions(sameCopyKind),
|
|
36
|
+
[`./${newerCopyKind.module}.ts`]: async () => defineRecordFunctions(newerCopyKind),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const STAMP = '2026-10-28T00:00:00.000Z'
|
|
40
|
+
|
|
41
|
+
async function editorBackend() {
|
|
42
|
+
const t = convexTest(schema, modules)
|
|
43
|
+
await t.run(async (ctx) => {
|
|
44
|
+
await ctx.db.insert('members', { userId: 'editor', role: 'editor', createdAt: STAMP, updatedAt: STAMP })
|
|
45
|
+
})
|
|
46
|
+
return { t, editor: t.withIdentity({ subject: 'editor', issuer: 'https://convex.test' }) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe.each([
|
|
50
|
+
['a NEWER zod copy (refused every create before)', newerCopyKind],
|
|
51
|
+
['the engine’s own copy (control — unchanged)', sameCopyKind],
|
|
52
|
+
] as const)('EC-639 — Convex record save with %s', (_label, kind: AnyRecordKind) => {
|
|
53
|
+
const save = makeFunctionReference<'mutation'>(`${kind.module}:save`)
|
|
54
|
+
const get = makeFunctionReference<'query'>(`${kind.module}:get`)
|
|
55
|
+
const idArg = (id: string) => ({ [kind.idKey]: id })
|
|
56
|
+
|
|
57
|
+
it('creates with the default applied, edits keep it, a clear removes the optional column', async () => {
|
|
58
|
+
const { editor } = await editorBackend()
|
|
59
|
+
await editor.mutation(save, { ...idArg('t1'), title: 'First', note: 'n', createdAt: STAMP, updatedAt: STAMP })
|
|
60
|
+
expect(await editor.query(get, idArg('t1'))).toMatchObject({ title: 'First', done: false, note: 'n' })
|
|
61
|
+
await editor.mutation(save, { ...idArg('t1'), done: true, createdAt: STAMP, updatedAt: STAMP })
|
|
62
|
+
await editor.mutation(save, { ...idArg('t1'), title: 'Renamed', note: null, createdAt: STAMP, updatedAt: STAMP })
|
|
63
|
+
const edited = (await editor.query(get, idArg('t1'))) as Record<string, unknown>
|
|
64
|
+
expect(edited).toMatchObject({ title: 'Renamed', done: true })
|
|
65
|
+
expect(edited.note).toBeUndefined()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('refuses an invalid column with the column’s own message, in the transaction', async () => {
|
|
69
|
+
const { t, editor } = await editorBackend()
|
|
70
|
+
await expect(
|
|
71
|
+
editor.mutation(save, { ...idArg('bad'), title: '', createdAt: STAMP, updatedAt: STAMP }),
|
|
72
|
+
).rejects.toThrow(/title: Too small/)
|
|
73
|
+
const stored = await t.run(async (ctx) => ctx.db.query('records').collect())
|
|
74
|
+
expect(stored).toEqual([])
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import { makeFunctionReference } from 'convex/server'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { z as newerZod } from 'zod-copy-newer'
|
|
6
|
+
import { z as olderZod } from 'zod-copy-older'
|
|
7
|
+
import { defineRecordKind } from '@elytracms/core/persistence'
|
|
8
|
+
import type { AnyRecordKind, RecordColumnSchema } from '@elytracms/core/persistence'
|
|
9
|
+
import schema from './schema'
|
|
10
|
+
import { defineRecordFunctions } from './record-functions'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* EC-682 (Codex round 4 F24, a regression of 0.0.97) — the generated Convex record functions
|
|
14
|
+
* (`defineRecordFunctions(kind).save` → `saveRecord` → `resolveRecordSave`, in the transaction)
|
|
15
|
+
* for a kind whose column carries a refinement on its `.optional()` / `.default()` WRAPPER and
|
|
16
|
+
* comes from another zod release than the engine's. Measured before: the adoption judged only
|
|
17
|
+
* the wrapper's `innerType`, so `'forbidden'` — refused by the column's own `safeParse` — was
|
|
18
|
+
* STORED (create and edit). Now a supplied value is judged by the whole column; the engine's own
|
|
19
|
+
* copy is the control row (composed as it is, refused before too). `zod-copy-older` (4.1.13) and
|
|
20
|
+
* `zod-copy-newer` (4.6.7) are real second copies (aliased devDependencies), not mocks.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const MESSAGE = 'forbidden value'
|
|
24
|
+
const notForbidden = (value: string | undefined) => value !== 'forbidden'
|
|
25
|
+
|
|
26
|
+
// The copies' builders differ only in their TYPES (EC-639 owns those); the runtime is the subject here.
|
|
27
|
+
type Vendor = typeof z
|
|
28
|
+
const COPIES = [
|
|
29
|
+
['own', 'the engine’s own copy (the control)', z],
|
|
30
|
+
['older', 'an OLDER zod copy (4.1.13)', olderZod as unknown as Vendor],
|
|
31
|
+
['newer', 'a NEWER zod copy (4.6.7)', newerZod as unknown as Vendor],
|
|
32
|
+
] as const
|
|
33
|
+
|
|
34
|
+
const SHAPES = [
|
|
35
|
+
// The defect: the refinement is the WRAPPER's own check.
|
|
36
|
+
['OptionalRefine', 'optional().refine()', (v: Vendor) => v.string().optional().refine(notForbidden, MESSAGE)],
|
|
37
|
+
['DefaultRefine', 'default().refine()', (v: Vendor) => v.string().default('safe').refine(notForbidden, MESSAGE)],
|
|
38
|
+
// The neighbours: the refinement sits on the inner type (judged before this change too).
|
|
39
|
+
['RefineOptional', 'refine().optional()', (v: Vendor) => v.string().refine(notForbidden, MESSAGE).optional()],
|
|
40
|
+
['RefineDefault', 'refine().default()', (v: Vendor) => v.string().refine(notForbidden, MESSAGE).default('safe')],
|
|
41
|
+
] as const
|
|
42
|
+
|
|
43
|
+
const CASES = COPIES.flatMap(([copy, copyLabel, vendor]) =>
|
|
44
|
+
SHAPES.map(([shape, shapeLabel, build]) => {
|
|
45
|
+
const kind = defineRecordKind({
|
|
46
|
+
kind: `${copy}${shape}`,
|
|
47
|
+
module: `${copy}${shape}Notes`,
|
|
48
|
+
fields: { code: build(vendor) as unknown as RecordColumnSchema },
|
|
49
|
+
})
|
|
50
|
+
return [`${copyLabel}, \`z.string().${shapeLabel}\``, { kind, defaulted: shape.includes('Default') }] as const
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const modules = {
|
|
55
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
56
|
+
'./_generated/api.ts': async () => ({}),
|
|
57
|
+
...Object.fromEntries(CASES.map(([, { kind }]) => [`./${kind.module}.ts`, async () => defineRecordFunctions(kind)])),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const STAMP = '2026-10-28T00:00:00.000Z'
|
|
61
|
+
|
|
62
|
+
async function editorBackend() {
|
|
63
|
+
const t = convexTest(schema, modules)
|
|
64
|
+
await t.run(async (ctx) => {
|
|
65
|
+
await ctx.db.insert('members', { userId: 'editor', role: 'editor', createdAt: STAMP, updatedAt: STAMP })
|
|
66
|
+
})
|
|
67
|
+
return { t, editor: t.withIdentity({ subject: 'editor', issuer: 'https://convex.test' }) }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe.each(CASES)('EC-682 — Convex record save, %s', (_label, { kind, defaulted }: { kind: AnyRecordKind; defaulted: boolean }) => {
|
|
71
|
+
const save = makeFunctionReference<'mutation'>(`${kind.module}:save`)
|
|
72
|
+
const get = makeFunctionReference<'query'>(`${kind.module}:get`)
|
|
73
|
+
const write = (id: string, columns: Record<string, unknown> = {}) => ({ [kind.idKey]: id, ...columns, createdAt: STAMP, updatedAt: STAMP })
|
|
74
|
+
|
|
75
|
+
it('refuses `forbidden` on create with the column’s own message, in the transaction — nothing stored', async () => {
|
|
76
|
+
const { t, editor } = await editorBackend()
|
|
77
|
+
await expect(editor.mutation(save, write('created', { code: 'forbidden' }))).rejects.toThrow(/code: forbidden value/)
|
|
78
|
+
expect(await t.run(async (ctx) => ctx.db.query('records').collect())).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('saves `safe`, refuses an edit to `forbidden`, keeps the stored value', async () => {
|
|
82
|
+
const { editor } = await editorBackend()
|
|
83
|
+
expect(await editor.mutation(save, write('edited', { code: 'safe' }))).toMatchObject({ code: 'safe' })
|
|
84
|
+
await expect(editor.mutation(save, write('edited', { code: 'forbidden' }))).rejects.toThrow(/code: forbidden value/)
|
|
85
|
+
expect(await editor.query(get, { [kind.idKey]: 'edited' })).toMatchObject({ code: 'safe' })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('an absent key is the wrapper’s: the optional stays absent, the default is the column’s own (`safe`)', async () => {
|
|
89
|
+
const { editor } = await editorBackend()
|
|
90
|
+
const created = (await editor.mutation(save, write('absent'))) as Record<string, unknown>
|
|
91
|
+
if (defaulted) expect(created.code).toBe('safe')
|
|
92
|
+
else expect('code' in created).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
})
|
package/convex/schema.ts
CHANGED
|
@@ -207,6 +207,21 @@ export default defineSchema({
|
|
|
207
207
|
.index('by_doc', ['collection', 'docId', 'variant'])
|
|
208
208
|
.index('by_doc_session', ['collection', 'docId', 'variant', 'sessionId']),
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* EC-669 — the FLOOR of a removed identity: the highest `revision` (of its rows) and
|
|
212
|
+
* `version` (of its history) any deleted or cleared lifetime of `collection:docId` reached.
|
|
213
|
+
* Written by `cms:deleteDocument` and `cms:clearDocuments` (raised, never lowered, never
|
|
214
|
+
* deleted — one row per identity ever removed); read by a create and by a lifetime's first
|
|
215
|
+
* version, which start above it — so a revision or version token names one state of ONE
|
|
216
|
+
* lifetime and a stale writer of a removed lifetime is refused. Never on the delivery wire.
|
|
217
|
+
*/
|
|
218
|
+
documentFloors: defineTable({
|
|
219
|
+
collection: v.string(),
|
|
220
|
+
docId: v.string(),
|
|
221
|
+
revision: v.number(),
|
|
222
|
+
version: v.number(),
|
|
223
|
+
}).index('by_doc', ['collection', 'docId']),
|
|
224
|
+
|
|
210
225
|
/**
|
|
211
226
|
* CMS document version snapshots — append-only (EC-224). One row per recorded
|
|
212
227
|
* version (created on publish or a pre-restore snapshot, never on a plain draft
|
|
@@ -285,7 +300,11 @@ export default defineSchema({
|
|
|
285
300
|
localizedMeta: v.optional(v.any()),
|
|
286
301
|
createdAt: v.string(),
|
|
287
302
|
updatedAt: v.string(),
|
|
288
|
-
})
|
|
303
|
+
})
|
|
304
|
+
.index('by_asset', ['assetId'])
|
|
305
|
+
// EC-671: an ingest claim refuses a storage id an asset row references (`claimIngestUpload`).
|
|
306
|
+
.index('by_storage', ['storageKey'])
|
|
307
|
+
.index('by_thumb_storage', ['thumb.storageKey']),
|
|
289
308
|
|
|
290
309
|
/**
|
|
291
310
|
* Editorial redirects (EC-303) — 301/302 forwards an editor owns, stored as
|
|
@@ -0,0 +1,129 @@
|
|
|
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 { getFunctionName } from 'convex/server'
|
|
5
|
+
import type { FunctionReference } from 'convex/server'
|
|
6
|
+
import { fieldPath } from '@elytracms/core/cms-core'
|
|
7
|
+
import { createOperations } from '@elytracms/core/operations'
|
|
8
|
+
import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
|
|
9
|
+
import schema from './schema'
|
|
10
|
+
|
|
11
|
+
// convex-test fires a `runAfter(0)` on a REAL timer (the publish trigger's revalidation);
|
|
12
|
+
// fake timers keep it queued.
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.useFakeTimers()
|
|
15
|
+
vi.setSystemTime('2026-10-28T00:00:00.000Z')
|
|
16
|
+
})
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.useRealTimers()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* EC-668 — the Codex review's F08-residual counterexample on Convex (round 3,
|
|
23
|
+
* `races.convex.test.ts`), copied and INVERTED: the client HOLDS `cms:deleteDocument` after the
|
|
24
|
+
* operation's guard, a colleague saves a strict draft reference to the target, the held
|
|
25
|
+
* mutation is sent — and it REFUSES, because its transaction reads the draft reference index
|
|
26
|
+
* (`referenceEntries` `by_target`, variant `draft`) and finds a referrer the guard did not judge
|
|
27
|
+
* at its current revision. The probe's positive detection control (a fresh guard refuses a
|
|
28
|
+
* second delete) is kept as written. convex-test serialises mutations: this proves the sink's
|
|
29
|
+
* data check and its refusal, not Convex's OCC retry.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const modules = {
|
|
33
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
34
|
+
'./_generated/api.ts': async () => ({}),
|
|
35
|
+
}
|
|
36
|
+
const projectId = 'strict-delete'
|
|
37
|
+
const ref = { collection: 'post', id: 'p' }
|
|
38
|
+
const author = { collection: 'author', id: 'a' }
|
|
39
|
+
|
|
40
|
+
async function world() {
|
|
41
|
+
const t = convexTest(schema, modules)
|
|
42
|
+
const stamp = '2026-10-28T00:00:00Z'
|
|
43
|
+
await t.run(async (ctx) => {
|
|
44
|
+
await ctx.db.insert('members', { userId: 'user_admin', role: 'admin', createdAt: stamp, updatedAt: stamp })
|
|
45
|
+
})
|
|
46
|
+
const admin = t.withIdentity({ subject: 'user_admin', issuer: 'https://convex.test' })
|
|
47
|
+
const held = new Map<string, { reached(): void; gate: Promise<void> }>()
|
|
48
|
+
const client = {
|
|
49
|
+
query: (fn: FunctionReference<'query'>, args?: Record<string, unknown>) => admin.query(fn, args ?? {}),
|
|
50
|
+
mutation: async (fn: FunctionReference<'mutation'>, args?: Record<string, unknown>) => {
|
|
51
|
+
const key = `${getFunctionName(fn)}#${String(args?.docId)}`
|
|
52
|
+
const pause = held.get(key)
|
|
53
|
+
if (pause) {
|
|
54
|
+
held.delete(key)
|
|
55
|
+
pause.reached()
|
|
56
|
+
await pause.gate
|
|
57
|
+
}
|
|
58
|
+
return admin.mutation(fn, args ?? {})
|
|
59
|
+
},
|
|
60
|
+
} as unknown as ConvexHttpClient
|
|
61
|
+
const adapter = new ConvexPersistenceAdapter(client, projectId)
|
|
62
|
+
const collections = () => [
|
|
63
|
+
{ id: 'author', kind: 'document' as const, fields: [{ name: 'name', type: 'text' as const }] },
|
|
64
|
+
{ id: 'post', kind: 'document' as const, fields: [
|
|
65
|
+
{ name: 'author', type: 'relation' as const, target: 'author', multiple: false, strict: true },
|
|
66
|
+
{ name: 'soft', type: 'relation' as const, target: 'author', multiple: false },
|
|
67
|
+
] },
|
|
68
|
+
]
|
|
69
|
+
const ops = createOperations({ adapter, collections, validateDocument: () => [] })
|
|
70
|
+
const hold = (verb: string, id: string) => {
|
|
71
|
+
let reached!: () => void
|
|
72
|
+
let resume!: () => void
|
|
73
|
+
const at = new Promise<void>((r) => { reached = r })
|
|
74
|
+
const gate = new Promise<void>((r) => { resume = r })
|
|
75
|
+
held.set(`cms:${verb}#${id}`, { reached, gate })
|
|
76
|
+
return { at, resume }
|
|
77
|
+
}
|
|
78
|
+
const put = async (document: { collection: string; id: string; values: Record<string, unknown> }) => {
|
|
79
|
+
expect((await ops.documents.replace({ projectId, document, precondition: { ifAbsent: true } })).ok).toBe(true)
|
|
80
|
+
}
|
|
81
|
+
return { adapter, ops, hold, put }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
it('F08 residual Convex: a strict draft saved before the delete mutation protects its target', async () => {
|
|
85
|
+
const { adapter, ops, hold, put } = await world()
|
|
86
|
+
await put({ ...author, values: { name: 'Ada' } })
|
|
87
|
+
await put({ ...ref, values: {} })
|
|
88
|
+
const pause = hold('deleteDocument', author.id)
|
|
89
|
+
const deleting = ops.documents.delete({ projectId, ref: author })
|
|
90
|
+
await pause.at
|
|
91
|
+
expect((await ops.documents.patch({ projectId, ref, baseRevision: 1, patches: [{ op: 'set', path: fieldPath('author'), value: { type: 'reference', ...author } }] })).ok).toBe(true)
|
|
92
|
+
// Positive detection control (kept as written): the guard already sees the now-saved draft and blocks a second delete.
|
|
93
|
+
expect((await ops.documents.delete({ projectId, ref: author })).ok).toBe(false)
|
|
94
|
+
pause.resume()
|
|
95
|
+
// Inverted: the held delete refuses in its transaction; the target survives.
|
|
96
|
+
const deleted = await deleting
|
|
97
|
+
expect(deleted.ok).toBe(false)
|
|
98
|
+
if (!deleted.ok) expect(deleted.error.code).toBe('conflict')
|
|
99
|
+
expect(await adapter.cms.findDocument(projectId, author)).toBeDefined()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('EC-668 Convex — the delete mutation checks the draft referrers the guard judged', () => {
|
|
103
|
+
it('a judged SOFT draft referrer edited to a strict one while the delete is held refuses (its revision moved)', async () => {
|
|
104
|
+
const { adapter, ops, hold, put } = await world()
|
|
105
|
+
await put({ ...author, values: { name: 'Ada' } })
|
|
106
|
+
await put({ ...ref, values: { soft: { type: 'reference', ...author } } })
|
|
107
|
+
const pause = hold('deleteDocument', author.id)
|
|
108
|
+
const deleting = ops.documents.delete({ projectId, ref: author })
|
|
109
|
+
await pause.at
|
|
110
|
+
expect((await ops.documents.patch({ projectId, ref, baseRevision: 1, patches: [{ op: 'set', path: fieldPath('author'), value: { type: 'reference', ...author } }] })).ok).toBe(true)
|
|
111
|
+
pause.resume()
|
|
112
|
+
expect((await deleting).ok).toBe(false)
|
|
113
|
+
expect(await adapter.cms.findDocument(projectId, author)).toBeDefined()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('control: the held delete with nothing racing it lands, a judged soft referrer and all', async () => {
|
|
117
|
+
const { adapter, ops, hold, put } = await world()
|
|
118
|
+
await put({ ...author, values: { name: 'Ada' } })
|
|
119
|
+
await put({ ...ref, values: { soft: { type: 'reference', ...author } } })
|
|
120
|
+
const pause = hold('deleteDocument', author.id)
|
|
121
|
+
const deleting = ops.documents.delete({ projectId, ref: author })
|
|
122
|
+
await pause.at
|
|
123
|
+
// An unrelated write in the window is not a referrer.
|
|
124
|
+
await put({ collection: 'author', id: 'b', values: { name: 'Bea' } })
|
|
125
|
+
pause.resume()
|
|
126
|
+
expect((await deleting).ok).toBe(true)
|
|
127
|
+
expect(await adapter.cms.findDocument(projectId, author)).toBeUndefined()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import type { CmsDocument, CmsValidationIssue, CollectionDef, CollectionRegistry, DocumentPatch, DocumentRef, FieldDef, ValuePath, Locale, LocaleConfig, LocalizedString, RestoreScope, ValidateDocumentOptions } from '@elytracms/core/cms-core';
|
|
3
3
|
import type { CmsDocumentRecord, DocumentVersionRecord, IdFactory, PresenceEntry } from '@elytracms/core/persistence';
|
|
4
|
+
import type { ImportRejection } from './import-batch.js';
|
|
4
5
|
import type { CommitOutcome, DocumentConflict, OperationError, OperationsClient } from '../../lib/operations/index.js';
|
|
5
6
|
import type { TableViewState } from '../../lib/cms-views/index.js';
|
|
6
7
|
import type { CellCommitInput, CellCommitOutcome } from './cell-edit.js';
|
|
@@ -24,10 +25,7 @@ export interface SavedView {
|
|
|
24
25
|
}
|
|
25
26
|
export interface ImportReport {
|
|
26
27
|
added: number;
|
|
27
|
-
rejected:
|
|
28
|
-
id: string;
|
|
29
|
-
issues: CmsValidationIssue[];
|
|
30
|
-
}[];
|
|
28
|
+
rejected: ImportRejection[];
|
|
31
29
|
}
|
|
32
30
|
/**
|
|
33
31
|
* A document's recorded version history plus its live pin (EC-224), as the
|
|
@@ -73,8 +71,11 @@ export interface CmsStore {
|
|
|
73
71
|
* editor's `doc`): the value and its revision travel together, so a writer authoring
|
|
74
72
|
* from what it displayed names that revision. `undefined` for a value the session never
|
|
75
73
|
* showed (a fixture session, a copy) or a provisional one (a create in flight).
|
|
74
|
+
* EC-667: with `path`, the revision the content at that position was authored against
|
|
75
|
+
* (a view showing a pending write is a composite); without it, the EARLIEST of any
|
|
76
|
+
* position — the whole-document writer's token.
|
|
76
77
|
*/
|
|
77
|
-
revisionOfView(view: CmsDocument): number | undefined;
|
|
78
|
+
revisionOfView(view: CmsDocument, path?: ValuePath): number | undefined;
|
|
78
79
|
/**
|
|
79
80
|
* EC-595 A2 / EC-578 — create a document from a whole value (duplicate, import of a
|
|
80
81
|
* new row, an AI create, a transfer, a reuse promote): insert-if-absent
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
-
import { applyDocumentPatches, createCollectionRegistry, createDocumentStore, documentKey, documentReachabilityIssues,
|
|
3
|
+
import { applyDocumentPatches, createCollectionRegistry, createDocumentStore, documentKey, documentReachabilityIssues, resolveDocumentTitle, seedDefaults, titleFieldOf, touchedPaths, validateDocument, valuesInLocale, writeField, } from '@elytracms/core/cms-core';
|
|
4
4
|
import { createUuidIdFactory } from '@elytracms/core/persistence';
|
|
5
5
|
import { translate } from '../../lib/i18n/index.js';
|
|
6
6
|
import { configureStudioLocales, useStudioLocale } from '../../lib/locale/index.js';
|
|
@@ -9,8 +9,9 @@ import { resolveDeploymentCollections, resolveDeploymentLocaleConfig, resolveDep
|
|
|
9
9
|
import { Heading, Panel, Stack, Text } from '@elytracms/studio/ui';
|
|
10
10
|
import { assetLibraryOracle, gateValidateOptions } from './gate-validation.js';
|
|
11
11
|
import { matchesRef, removeDoc, replaceDoc, upsertDoc } from './document-ref-utils.js';
|
|
12
|
+
import { parseImportBatch } from './import-batch.js';
|
|
12
13
|
import { createRequiredContext } from '../../lib/required-context.js';
|
|
13
|
-
import { createCmsPersister, getStudioActor, getStudioOperations } from '../../lib/operations/index.js';
|
|
14
|
+
import { createCmsPersister, getStudioActor, getStudioOperations, importWrite } from '../../lib/operations/index.js';
|
|
14
15
|
import { publishInSession, publishWithRevision as publishRevision, } from '../../lib/operations/publish-with-revision.js';
|
|
15
16
|
import { operationErrorMessage } from '../../lib/persona/labels.js';
|
|
16
17
|
import { fieldWritePatch } from '../../lib/cms/field-patch.js';
|
|
@@ -430,10 +431,11 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
|
|
|
430
431
|
? {
|
|
431
432
|
// EC-595 A2: ONE field patch through the persister's queue — at the
|
|
432
433
|
// revision the row was at — never a whole-document side channel.
|
|
433
|
-
// EC-647: the row the table SHOWED names that revision
|
|
434
|
+
// EC-647: the row the table SHOWED names that revision — EC-667: at the
|
|
435
|
+
// cell's position (the row may show a pending write elsewhere).
|
|
434
436
|
dispatch: async (next) => {
|
|
435
437
|
const patch = fieldWritePatch(input.collection, next, input.field.name, input.locale);
|
|
436
|
-
const outcome = await persister.commit(ref, [patch], [persister.revisionOfView(input.doc)], input.doc);
|
|
438
|
+
const outcome = await persister.commit(ref, [patch], [persister.revisionOfView(input.doc, touchedPaths(patch)[0])], input.doc);
|
|
437
439
|
if (outcome.status === 'applied')
|
|
438
440
|
return { ok: true, value: outcome.record };
|
|
439
441
|
if (outcome.status === 'failed')
|
|
@@ -683,28 +685,9 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
|
|
|
683
685
|
// transfer dialog) can no longer change a verdict. `validate` above stays the
|
|
684
686
|
// session's DISPLAY validation (the editor's issues, cell edit, import, AI).
|
|
685
687
|
const importDocuments = useCallback((collectionId, raw) => {
|
|
686
|
-
const report = { added: 0, rejected: [] };
|
|
687
688
|
// Pre-parse so intra-batch relation/asset targets resolve during validation.
|
|
688
|
-
const parsedBatch =
|
|
689
|
-
const
|
|
690
|
-
for (const item of raw) {
|
|
691
|
-
const parsed = documentSchema.safeParse({ ...item, collection: collectionId });
|
|
692
|
-
if (parsed.success)
|
|
693
|
-
parsedBatch.push(parsed.data);
|
|
694
|
-
else
|
|
695
|
-
schemaFails.push({ item, error: parsed });
|
|
696
|
-
}
|
|
697
|
-
for (const { item, error } of schemaFails) {
|
|
698
|
-
report.rejected.push({
|
|
699
|
-
id: item?.id ?? translate('cms.list.import.noId'),
|
|
700
|
-
issues: (error.success ? [] : error.error.issues).map((e) => ({
|
|
701
|
-
code: 'invalid-field-value',
|
|
702
|
-
severity: 'error',
|
|
703
|
-
message: e.message,
|
|
704
|
-
path: e.path.map((p) => (typeof p === 'symbol' ? String(p) : p)),
|
|
705
|
-
})),
|
|
706
|
-
});
|
|
707
|
-
}
|
|
689
|
+
const { parsed: parsedBatch, rejected } = parseImportBatch(collectionId, raw);
|
|
690
|
+
const report = { added: 0, rejected: [...rejected] };
|
|
708
691
|
// Validate each against existing docs PLUS the rest of the batch.
|
|
709
692
|
const batchStore = createDocumentStore([...documents, ...parsedBatch]);
|
|
710
693
|
const accepted = [];
|
|
@@ -739,12 +722,7 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
|
|
|
739
722
|
for (const doc of accepted) {
|
|
740
723
|
if (!persister)
|
|
741
724
|
continue;
|
|
742
|
-
|
|
743
|
-
if (persister.revisionOf(doc) !== undefined) {
|
|
744
|
-
persister.replace(doc, shown ? persister.revisionOfView(shown) : undefined);
|
|
745
|
-
}
|
|
746
|
-
else
|
|
747
|
-
persister.create(doc);
|
|
725
|
+
importWrite(persister, doc, documents.find((d) => matchesRef(d, doc)));
|
|
748
726
|
}
|
|
749
727
|
// EC-224: imports land as the working DRAFT. Publish-ness is the version
|
|
750
728
|
// pointer (`publishedKeys`), set by publishing — never carried in the
|
|
@@ -873,8 +851,10 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
|
|
|
873
851
|
}
|
|
874
852
|
const written = setFieldDoc(current);
|
|
875
853
|
// EC-647: authored from the row the session holds — its revision, never the
|
|
876
|
-
// tracked one (a colleague's change the user never saw is refused, visibly)
|
|
877
|
-
|
|
854
|
+
// tracked one (a colleague's change the user never saw is refused, visibly);
|
|
855
|
+
// EC-667: the revision of the field's position in that row.
|
|
856
|
+
const patch = fieldWritePatch(collection, written, setField.field, activeLocale);
|
|
857
|
+
const outcome = await persister.commit(ref, [patch], [persister.revisionOfView(current, touchedPaths(patch)[0])], current);
|
|
878
858
|
if (outcome.status === 'applied')
|
|
879
859
|
return { ok: true, value: outcome.record };
|
|
880
860
|
if (outcome.status === 'failed')
|
|
@@ -1011,7 +991,7 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
|
|
|
1011
991
|
titleOf,
|
|
1012
992
|
commitPatches,
|
|
1013
993
|
revisionOf: (ref) => persisterRef.current?.revisionOf(ref),
|
|
1014
|
-
revisionOfView: (view) => persisterRef.current?.revisionOfView(view),
|
|
994
|
+
revisionOfView: (view, path) => persisterRef.current?.revisionOfView(view, path),
|
|
1015
995
|
insertDocument,
|
|
1016
996
|
newDocumentId: newId,
|
|
1017
997
|
commitFieldEdit,
|