@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.
Files changed (53) hide show
  1. package/convex/assets.ts +16 -2
  2. package/convex/cms.ts +153 -13
  3. package/convex/delivery-folded-drift.test.ts +104 -0
  4. package/convex/delivery.ts +24 -8
  5. package/convex/depth-codec-lookalike.test.ts +192 -0
  6. package/convex/fixture-accounts.test.ts +305 -0
  7. package/convex/fixtureAccounts.ts +298 -0
  8. package/convex/incarnation.test.ts +149 -0
  9. package/convex/ingest-attempt.test.ts +161 -0
  10. package/convex/ingest.test.ts +327 -9
  11. package/convex/ingest.ts +242 -24
  12. package/convex/prototype-key.test.ts +92 -0
  13. package/convex/receipt-races.test.ts +184 -0
  14. package/convex/records-zod-copy.test.ts +76 -0
  15. package/convex/records-zod-refinements.convex.test.ts +94 -0
  16. package/convex/schema.ts +20 -1
  17. package/convex/strict-delete.test.ts +129 -0
  18. package/dist/components/cms/cms-store.d.ts +6 -5
  19. package/dist/components/cms/cms-store.js +14 -34
  20. package/dist/components/cms/cms-store.js.map +1 -1
  21. package/dist/components/cms/field-control.d.ts +12 -1
  22. package/dist/components/cms/field-control.js +16 -1
  23. package/dist/components/cms/field-control.js.map +1 -1
  24. package/dist/components/cms/import-batch.d.ts +23 -0
  25. package/dist/components/cms/import-batch.js +43 -0
  26. package/dist/components/cms/import-batch.js.map +1 -0
  27. package/dist/components/cms/rich-text/editor.js +13 -10
  28. package/dist/components/cms/rich-text/editor.js.map +1 -1
  29. package/dist/components/cms/rich-text/value.d.ts +9 -0
  30. package/dist/components/cms/rich-text/value.js +13 -0
  31. package/dist/components/cms/rich-text/value.js.map +1 -1
  32. package/dist/components/cms/use-document-draft.d.ts +76 -4
  33. package/dist/components/cms/use-document-draft.js +94 -25
  34. package/dist/components/cms/use-document-draft.js.map +1 -1
  35. package/dist/components/records/record-lens.js.map +1 -1
  36. package/dist/lib/collection-view/collection-view-source.d.ts +5 -2
  37. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  38. package/dist/lib/engine-version.d.ts +1 -1
  39. package/dist/lib/engine-version.js +1 -1
  40. package/dist/lib/engine-version.js.map +1 -1
  41. package/dist/lib/i18n/messages.de.js +5 -0
  42. package/dist/lib/i18n/messages.de.js.map +1 -1
  43. package/dist/lib/i18n/messages.en.d.ts +5 -0
  44. package/dist/lib/i18n/messages.en.js +5 -0
  45. package/dist/lib/i18n/messages.en.js.map +1 -1
  46. package/dist/lib/operations/cms-persistence.d.ts +49 -5
  47. package/dist/lib/operations/cms-persistence.js +237 -47
  48. package/dist/lib/operations/cms-persistence.js.map +1 -1
  49. package/dist/lib/persistence/convex-adapter.js +27 -1
  50. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  51. package/dist/lib/persona/labels.js +2 -0
  52. package/dist/lib/persona/labels.js.map +1 -1
  53. 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
- }).index('by_asset', ['assetId']),
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, documentSchema, resolveDocumentTitle, seedDefaults, titleFieldOf, validateDocument, valuesInLocale, writeField, } from '@elytracms/core/cms-core';
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 schemaFails = [];
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
- const shown = documents.find((d) => matchesRef(d, doc));
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
- const outcome = await persister.commit(ref, [fieldWritePatch(collection, written, setField.field, activeLocale)], [persister.revisionOfView(current)], current);
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,