@elytracms/studio 0.0.96 → 0.0.97

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 (37) hide show
  1. package/convex/cms.ts +153 -13
  2. package/convex/delivery-folded-drift.test.ts +104 -0
  3. package/convex/delivery.ts +24 -8
  4. package/convex/depth-codec-lookalike.test.ts +192 -0
  5. package/convex/fixture-accounts.test.ts +305 -0
  6. package/convex/fixtureAccounts.ts +298 -0
  7. package/convex/incarnation.test.ts +149 -0
  8. package/convex/ingest.test.ts +145 -3
  9. package/convex/ingest.ts +123 -17
  10. package/convex/receipt-races.test.ts +184 -0
  11. package/convex/records-zod-copy.test.ts +76 -0
  12. package/convex/schema.ts +20 -1
  13. package/convex/strict-delete.test.ts +129 -0
  14. package/dist/components/cms/cms-store.d.ts +4 -1
  15. package/dist/components/cms/cms-store.js +11 -13
  16. package/dist/components/cms/cms-store.js.map +1 -1
  17. package/dist/components/cms/use-document-draft.d.ts +41 -2
  18. package/dist/components/cms/use-document-draft.js +60 -18
  19. package/dist/components/cms/use-document-draft.js.map +1 -1
  20. package/dist/lib/collection-view/collection-view-source.d.ts +5 -2
  21. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  22. package/dist/lib/engine-version.d.ts +1 -1
  23. package/dist/lib/engine-version.js +1 -1
  24. package/dist/lib/engine-version.js.map +1 -1
  25. package/dist/lib/i18n/messages.de.js +2 -0
  26. package/dist/lib/i18n/messages.de.js.map +1 -1
  27. package/dist/lib/i18n/messages.en.d.ts +2 -0
  28. package/dist/lib/i18n/messages.en.js +2 -0
  29. package/dist/lib/i18n/messages.en.js.map +1 -1
  30. package/dist/lib/operations/cms-persistence.d.ts +41 -2
  31. package/dist/lib/operations/cms-persistence.js +111 -24
  32. package/dist/lib/operations/cms-persistence.js.map +1 -1
  33. package/dist/lib/persistence/convex-adapter.js +27 -1
  34. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  35. package/dist/lib/persona/labels.js +1 -0
  36. package/dist/lib/persona/labels.js.map +1 -1
  37. package/package.json +4 -3
package/convex/ingest.ts CHANGED
@@ -39,8 +39,9 @@ import { findRecordDoc, listRecordWire, saveRecord } from './record-functions'
39
39
  * analytics addon must fail visibly (EC-618's counsel gate relies on it);
40
40
  * 7. an accepted `upload` gets ONE short-lived storage URL (`ctx.storage.generateUploadUrl`)
41
41
  * — the bytes go browser → storage, cross-origin by design; the submission that follows
42
- * claims the stored file with {@link claimIngestUpload}, which checks it against what the
43
- * upload request declared.
42
+ * claims the stored file with {@link claimIngestUpload}, which checks that the deployment
43
+ * stored it for THIS intent (EC-671) and that it matches what the upload request declared,
44
+ * and binds it; {@link releaseIngestUploads} is the only way such bytes are deleted.
44
45
  *
45
46
  * The engine never imports an addon: the handler is an internal-mutation REFERENCE the
46
47
  * consumer passes in its one `http.ts` line (`defineStudioHttp({ auth, ingest: {
@@ -147,10 +148,17 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
147
148
  // intent (`recordIngestUpload`); a submission CLAIMS it (`claimIngestUpload`), which checks the
148
149
  // stored file against the intent. Abuse is bounded by: the route's limiter and bot gate on every
149
150
  // URL issued, `INGEST_MAX_UPLOAD_BYTES` per file, the URL's one-hour life, the post-upload check
150
- // (an over-size or mistyped file is deleted, never kept), and the unclaimed intents an addon can
151
+ // (an over-size or mistyped file is refused, never attached), and the unclaimed intents an addon can
151
152
  // list (`unclaimedIngestUploads`) and surface. Convex returns the storage id to the uploader
152
153
  // only, so the bytes of an upload no submission ever claims cannot be located by the engine —
153
154
  // that is the documented residue, bounded by the limits above.
155
+ //
156
+ // EC-671 — ownership. The storage id a claim names is the VISITOR's word: nothing at intent
157
+ // time can record it (`generateUploadUrl` takes no argument; the id reaches the uploader
158
+ // alone), and an asset's storage id is public on the delivery snapshot. So a claim binds a
159
+ // file only when the deployment's own facts say it is this upload's ({@link claimIngestUpload}),
160
+ // it never deletes, and ingest bytes are deleted only through {@link releaseIngestUploads},
161
+ // which deletes only what a claim bound.
154
162
 
155
163
  /** The upload intent — an engine-internal record kind (no public functions). */
156
164
  export const ingestUploadRecordKind = defineRecordKind({
@@ -165,10 +173,29 @@ export const ingestUploadRecordKind = defineRecordKind({
165
173
  size: z.number().int().positive(),
166
174
  state: z.enum(['issued', 'claimed', 'rejected']),
167
175
  issuedAt: z.string().min(1),
176
+ /** The id the intent's claim NAMED (claimed or rejected) — what was asked, not what is owned. */
168
177
  storageId: z.string().optional(),
169
178
  },
170
179
  })
171
180
 
181
+ /**
182
+ * EC-671 — the engine's ONE ownership fact for ingest bytes: a record per stored file a claim
183
+ * bound to its intent, keyed BY the storage id (`recordId`), so a file is bound at most once
184
+ * and "is it bound?" is one indexed read. Written only by {@link claimIngestUpload}, in the
185
+ * claim's transaction; read by the claim (a file another claim bound is refused) and by
186
+ * {@link releaseIngestUploads}. Engine-internal (no public functions), like the intent.
187
+ */
188
+ export const ingestBindingRecordKind = defineRecordKind({
189
+ kind: 'ingestBinding',
190
+ module: 'ingest',
191
+ time: 'boundAt',
192
+ fields: {
193
+ uploadId: z.string().min(1),
194
+ target: z.string().min(1),
195
+ boundAt: z.string().min(1),
196
+ },
197
+ })
198
+
172
199
  let mintUploadUuid: (() => string) | undefined
173
200
 
174
201
  export interface IngestUploadPolicy {
@@ -221,11 +248,56 @@ export type IngestUploadClaimResult =
221
248
  | { ok: true; storageId: string; field: string; contentType: string; size: number }
222
249
  | { ok: false; status: 409 | 413 | 415 | 422; message: string }
223
250
 
251
+ /**
252
+ * EC-671 — whether the deployment's own facts say a stored file is this intent's to claim. The
253
+ * storage id cannot be recorded at intent time (it reaches the uploader alone), so ownership
254
+ * is read from `_storage` facts and engine rows; a file is claimable only when ALL hold:
255
+ *
256
+ * 1. it was stored AFTER the intent was issued — compared in ONE clock: the intent row's
257
+ * `_creationTime` against the file's, both stamped by the deployment at insert (`issuedAt`
258
+ * is the HTTP action's clock, at millisecond grain: a file stored in the same millisecond
259
+ * before the intent would compare equal);
260
+ * 2. no claim bound it already ({@link ingestBindingRecordKind});
261
+ * 3. no asset row references it — `storageKey` or `thumb.storageKey` (an asset's storage id is
262
+ * public on the delivery snapshot the moment its row exists).
263
+ *
264
+ * Hiding `storageKey` would not be ownership; these are.
265
+ */
266
+ async function claimableFile(
267
+ ctx: MutationCtx,
268
+ file: { _id: GenericId<'_storage'>; _creationTime: number },
269
+ intentCreatedAt: number,
270
+ ): Promise<boolean> {
271
+ if (file._creationTime < intentCreatedAt) return false
272
+ if (await findRecordDoc(ctx, ingestBindingRecordKind, file._id)) return false
273
+ return !(await assetReferencesStorage(ctx, file._id))
274
+ }
275
+
276
+ /** Check (3): does any asset row reference these bytes, as its file or its preview (`thumb`)? */
277
+ async function assetReferencesStorage(ctx: MutationCtx, storageId: string): Promise<boolean> {
278
+ const asFile = await ctx.db
279
+ .query('assets')
280
+ .withIndex('by_storage', (q) => q.eq('storageKey', storageId))
281
+ .first()
282
+ if (asFile) return true
283
+ const asThumb = await ctx.db
284
+ .query('assets')
285
+ .withIndex('by_thumb_storage', (q) => q.eq('thumb.storageKey', storageId))
286
+ .first()
287
+ return asThumb !== null
288
+ }
289
+
224
290
  /**
225
291
  * Claim one upload for a submission to `target`: the intent must exist, be unclaimed,
226
- * belong to `target`, be younger than `INGEST_UPLOAD_TTL_MS`, and the STORED file must match
227
- * it (size ≤ declared, same content type). A mismatched file is deleted and the intent
228
- * marked `rejected` — never kept.
292
+ * belong to `target`, be younger than `INGEST_UPLOAD_TTL_MS`; the stored file must be this
293
+ * intent's ({@link claimableFile}, EC-671) and match it (size ≤ declared, same content type).
294
+ * A claimed file is BOUND to the intent ({@link ingestBindingRecordKind}) — the fact every
295
+ * deletion reads ({@link releaseIngestUploads}).
296
+ *
297
+ * A refusal marks the intent `rejected` (recording the id it named) and deletes NOTHING
298
+ * (EC-671): the named id is the visitor's word, and even a file that passed every ownership
299
+ * check may belong to someone the engine cannot see. A refused upload's bytes stay where the
300
+ * uploader put them — the same residue as an upload never claimed.
229
301
  */
230
302
  export async function claimIngestUpload(
231
303
  ctx: MutationCtx,
@@ -237,7 +309,9 @@ export async function claimIngestUpload(
237
309
  const columns = intent?.data as
238
310
  | { target: string; field: string; contentType: string; size: number; state: string; issuedAt: string }
239
311
  | undefined
240
- if (!columns || columns.target !== target) return { ok: false, status: 422, message: `Unknown upload “${claim.uploadId}”.` }
312
+ if (!intent || !columns || columns.target !== target) {
313
+ return { ok: false, status: 422, message: `Unknown upload “${claim.uploadId}”.` }
314
+ }
241
315
  if (columns.state !== 'issued') return { ok: false, status: 409, message: `Upload “${claim.uploadId}” was already claimed.` }
242
316
  if (Date.parse(receivedAt) - Date.parse(columns.issuedAt) > INGEST_UPLOAD_TTL_MS) {
243
317
  return { ok: false, status: 422, message: `Upload “${claim.uploadId}” expired.` }
@@ -246,19 +320,51 @@ export async function claimIngestUpload(
246
320
  const file = await ctx.db.system.get(storageId).catch(() => null)
247
321
  const stamps = { createdAt: receivedAt, updatedAt: receivedAt }
248
322
  if (!file) return { ok: false, status: 422, message: `No stored file “${claim.storageId}”.` }
249
- const refused =
250
- file.size > columns.size
251
- ? ({ status: 413, message: 'The stored file is larger than the upload declared.' } as const)
252
- : (file.contentType ?? '') !== columns.contentType
253
- ? ({ status: 415, message: 'The stored file is not of the declared type.' } as const)
254
- : undefined
255
- if (refused) {
256
- await ctx.storage.delete(storageId)
323
+ const reject = async (refusal: { status: 413 | 415 | 422; message: string }): Promise<IngestUploadClaimResult> => {
257
324
  await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'rejected', storageId: claim.storageId }, stamps)
258
- return { ok: false, ...refused }
325
+ return { ok: false, ...refusal }
326
+ }
327
+ const intentRow = await ctx.db.get(intent._id)
328
+ if (!intentRow || !(await claimableFile(ctx, file, intentRow._creationTime))) {
329
+ // One answer for all three checks: which one failed is not the visitor's to learn.
330
+ return reject({ status: 422, message: `Upload “${claim.uploadId}” did not store the file “${claim.storageId}”.` })
331
+ }
332
+ if (file.size > columns.size) return reject({ status: 413, message: 'The stored file is larger than the upload declared.' })
333
+ if ((file.contentType ?? '') !== columns.contentType) {
334
+ return reject({ status: 415, message: 'The stored file is not of the declared type.' })
259
335
  }
336
+ // Bound — and handed back — under the deployment's own id for the file (`file._id`), the
337
+ // spelling checks (2) and (3) read, never the string the visitor sent.
338
+ await saveRecord(ctx, ingestBindingRecordKind, file._id, { uploadId: claim.uploadId, target, boundAt: receivedAt }, stamps)
260
339
  await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'claimed', storageId: claim.storageId }, stamps)
261
- return { ok: true, storageId: claim.storageId, field: columns.field, contentType: columns.contentType, size: file.size }
340
+ return { ok: true, storageId: file._id, field: columns.field, contentType: columns.contentType, size: file.size }
341
+ }
342
+
343
+ /**
344
+ * Delete ingest bytes an addon holds (EC-671) — the ONE way they are deleted, and it deletes
345
+ * only files a claim BOUND: the bytes and their binding go together. Any other id is KEPT and
346
+ * returned in `kept` — an asset's, an id a write put into a row by hand, a file claimed before
347
+ * bindings existed — because ingest cannot show it owns them. Call it for a refused
348
+ * submission's already-claimed files and when removing a row that holds claimed files.
349
+ */
350
+ export async function releaseIngestUploads(
351
+ ctx: MutationCtx,
352
+ storageIds: readonly string[],
353
+ ): Promise<{ deleted: string[]; kept: string[] }> {
354
+ const deleted: string[] = []
355
+ const kept: string[] = []
356
+ for (const storageId of new Set(storageIds)) {
357
+ const binding = await findRecordDoc(ctx, ingestBindingRecordKind, storageId)
358
+ if (!binding) {
359
+ kept.push(storageId)
360
+ continue
361
+ }
362
+ const id = storageId as GenericId<'_storage'>
363
+ if (await ctx.db.system.get(id).catch(() => null)) await ctx.storage.delete(id)
364
+ await ctx.db.delete(binding._id)
365
+ deleted.push(storageId)
366
+ }
367
+ return { deleted, kept }
262
368
  }
263
369
 
264
370
  /** Upload intents of `target` still unclaimed past their life — the visible orphan finding. */
@@ -0,0 +1,184 @@
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, makeFunctionReference } from 'convex/server'
5
+ import type { FunctionReference } from 'convex/server'
6
+ import { measureMaxDepth } from '@elytracms/core/cms-core'
7
+ import { createChangesets, 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-663 — the Codex review's F16 and F17 counterexamples on Convex (round 3,
23
+ * `races.convex.test.ts`), copied and INVERTED: the real `cms:*` handlers, schema and triggers
24
+ * under convex-test, driven by the production operations and changesets over the production
25
+ * `ConvexPersistenceAdapter`. Each race is an explicit interleaving — the client HOLDS one
26
+ * store mutation after every check, runs the colleague's operation, then sends the held
27
+ * mutation — and the mutation REFUSES inside its transaction. convex-test serialises
28
+ * mutations, so this proves the sink's check and its refusal, not Convex's OCC retry.
29
+ *
30
+ * F17's wiring changed on purpose (an adapted copy, as the preamble requires): the hidden-clear
31
+ * no longer lands BEFORE `cms:publishDocument` — it is part of it — so at the pause the draft
32
+ * still holds the hidden value (the probe asserted it was already gone).
33
+ */
34
+
35
+ const modules = {
36
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
37
+ './_generated/api.ts': async () => ({}),
38
+ }
39
+ const projectId = 'receipt-races'
40
+ const ref = { collection: 'post', id: 'p' }
41
+ const author = { collection: 'author', id: 'a' }
42
+
43
+ async function world() {
44
+ const t = convexTest(schema, modules)
45
+ const stamp = '2026-10-28T00:00:00Z'
46
+ await t.run(async (ctx) => {
47
+ await ctx.db.insert('members', { userId: 'user_admin', role: 'admin', createdAt: stamp, updatedAt: stamp })
48
+ })
49
+ const admin = t.withIdentity({ subject: 'user_admin', issuer: 'https://convex.test' })
50
+ const held = new Map<string, { reached(): void; gate: Promise<void> }>()
51
+ const client = {
52
+ query: (fn: FunctionReference<'query'>, args?: Record<string, unknown>) => admin.query(fn, args ?? {}),
53
+ mutation: async (fn: FunctionReference<'mutation'>, args?: Record<string, unknown>) => {
54
+ const key = `${getFunctionName(fn)}#${String(args?.docId)}`
55
+ const pause = held.get(key)
56
+ if (pause) {
57
+ held.delete(key)
58
+ pause.reached()
59
+ await pause.gate
60
+ }
61
+ return admin.mutation(fn, args ?? {})
62
+ },
63
+ } as unknown as ConvexHttpClient
64
+ const adapter = new ConvexPersistenceAdapter(client, projectId)
65
+ const collections = () => [
66
+ { id: 'author', kind: 'document' as const, fields: [{ name: 'name', type: 'text' as const }] },
67
+ { id: 'post', kind: 'document' as const, fields: [
68
+ { name: 'title', type: 'text' as const },
69
+ { name: 'hidden', type: 'text' as const, form: { hidden: () => true } },
70
+ { name: 'author', type: 'relation' as const, target: 'author', multiple: false, strict: true },
71
+ ] },
72
+ ]
73
+ const options = { adapter, collections, validateDocument: () => [] }
74
+ const ops = createOperations(options)
75
+ const changesets = createChangesets(options)
76
+ /** Hold the next `cms:<verb>` mutation for document `id` at the client. */
77
+ const hold = (verb: string, id: string) => {
78
+ let reached!: () => void
79
+ let resume!: () => void
80
+ const at = new Promise<void>((r) => { reached = r })
81
+ const gate = new Promise<void>((r) => { resume = r })
82
+ held.set(`cms:${verb}#${id}`, { reached, gate })
83
+ return { at, resume }
84
+ }
85
+ const put = async (document: { collection: string; id: string; values: Record<string, unknown> }) => {
86
+ expect((await ops.documents.replace({ projectId, document, precondition: { ifAbsent: true } })).ok).toBe(true)
87
+ }
88
+ return { t, admin, adapter, ops, changesets, hold, put }
89
+ }
90
+
91
+ for (const inverse of ['pin', 'unpublish'] as const) {
92
+ it(`F16 Convex: a ${inverse} inverse refuses in its mutation when a colleague published after its checks`, async () => {
93
+ const { adapter, ops, changesets, hold, put } = await world()
94
+ await put({ ...ref, values: { title: 'one' } })
95
+ if (inverse === 'pin') expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true)
96
+ const applied = await changesets.apply(changesets.create({ title: 'publish', steps: [{ operation: 'documents.publish', input: { projectId, ref } }] }))
97
+ expect(applied.ok).toBe(true)
98
+ if (!applied.ok) throw new Error(applied.error.message)
99
+ const pause = hold(inverse === 'pin' ? 'publishDocumentVersion' : 'unpublishDocument', ref.id)
100
+ const reverting = changesets.revert(applied.value)
101
+ await pause.at
102
+ expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true)
103
+ const b = await adapter.cms.getDocument(projectId, ref)
104
+ expect(b.publishedVersion).toBe(inverse === 'pin' ? 3 : 2)
105
+ pause.resume()
106
+ // Inverted: the revert refuses and the colleague's publication stays live.
107
+ const reverted = await reverting
108
+ expect(reverted.ok).toBe(false)
109
+ if (!reverted.ok) expect(reverted.error.code).toBe('conflict')
110
+ expect((await adapter.cms.getDocument(projectId, ref)).publishedVersion).toBe(b.publishedVersion)
111
+ })
112
+ }
113
+
114
+ it('F16 Convex control: a revert with nothing racing it lands (the precondition is not a blanket refusal)', async () => {
115
+ const { adapter, ops, changesets, put } = await world()
116
+ await put({ ...ref, values: { title: 'one' } })
117
+ expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true) // v1
118
+ const applied = await changesets.apply(changesets.create({ title: 'publish', steps: [{ operation: 'documents.publish', input: { projectId, ref } }] }))
119
+ if (!applied.ok) throw new Error(applied.error.message)
120
+ expect((await adapter.cms.getDocument(projectId, ref)).publishedVersion).toBe(2)
121
+ expect((await changesets.revert(applied.value)).ok).toBe(true)
122
+ expect((await adapter.cms.getDocument(projectId, ref)).publishedVersion).toBe(1)
123
+ })
124
+
125
+ it('F17 Convex (adapted wiring): a publish its mutation refuses cleared nothing — compensation’s rollback claim is true', async () => {
126
+ const { adapter, ops, changesets, hold, put } = await world()
127
+ await put({ ...author, values: { name: 'Ada' } })
128
+ await put({ ...ref, values: { hidden: 'KEEP DRAFT', author: { type: 'reference', ...author } } })
129
+ expect((await ops.documents.publish({ projectId, ref: author })).ok).toBe(true)
130
+ const before = await adapter.cms.getDocument(projectId, ref)
131
+ const pause = hold('publishDocument', ref.id)
132
+ const applying = changesets.apply(changesets.create({ title: 'publish', steps: [{ operation: 'documents.publish', input: { projectId, ref } }] }))
133
+ await pause.at
134
+ // Changed wiring: the clear is part of the held mutation, so nothing is cleared yet.
135
+ expect((await adapter.cms.getDocument(projectId, ref)).document.values.hidden).toBe('KEEP DRAFT')
136
+ expect((await ops.documents.unpublish({ projectId, ref: author })).ok).toBe(true)
137
+ pause.resume()
138
+ const result = await applying
139
+ expect(result.ok).toBe(false)
140
+ if (result.ok) throw new Error('expected strict conflict')
141
+ expect(result.error.message).toContain('All previously applied steps were rolled back')
142
+ // Inverted: the refused publish wrote nothing.
143
+ const after = await adapter.cms.getDocument(projectId, ref)
144
+ expect(after.document.values.hidden).toBe('KEEP DRAFT')
145
+ expect(after.revision).toBe(before.revision)
146
+ expect(after.publishedVersion).toBeUndefined()
147
+ })
148
+
149
+ describe('EC-663 Convex — the mutations enforce the new preconditions themselves', () => {
150
+ const pinRef = makeFunctionReference<'mutation'>('cms:publishDocumentVersion')
151
+ const unpublishRef = makeFunctionReference<'mutation'>('cms:unpublishDocument')
152
+
153
+ it('cms:publishDocumentVersion / cms:unpublishDocument with a stale expectedRevision return conflict and write nothing', async () => {
154
+ const { admin, adapter, ops, put } = await world()
155
+ await put({ ...ref, values: { title: 'one' } })
156
+ expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true) // r2, v1
157
+ const at = '2026-10-28T00:00:01Z'
158
+ const pin = await admin.mutation(pinRef, { collection: 'post', docId: 'p', version: 1, updatedAt: at, publishedAt: at, expectedRevision: 1 })
159
+ expect(pin).toMatchObject({ status: 'conflict', current: { revision: 2 } })
160
+ const unpublish = await admin.mutation(unpublishRef, { collection: 'post', docId: 'p', updatedAt: at, expectedRevision: 1 })
161
+ expect(unpublish).toMatchObject({ status: 'conflict', current: { revision: 2 } })
162
+ const kept = await adapter.cms.getDocument(projectId, ref)
163
+ expect([kept.revision, kept.publishedVersion]).toEqual([2, 1])
164
+ // Control: at the current revision both land.
165
+ expect(await admin.mutation(unpublishRef, { collection: 'post', docId: 'p', updatedAt: at, expectedRevision: 2 })).toMatchObject({ revision: 3 })
166
+ expect(await admin.mutation(pinRef, { collection: 'post', docId: 'p', version: 1, updatedAt: at, publishedAt: at, expectedRevision: 3 })).toMatchObject({ status: 'ok' })
167
+ })
168
+
169
+ it('a publish with a hidden-clear is ONE mutation: patch row at +1, publish row at +2, the pinned snapshot cleared', async () => {
170
+ const { t, adapter, ops, put } = await world()
171
+ await put({ ...ref, values: { title: 'T', hidden: 'gone' } })
172
+ const published = await ops.documents.publish({ projectId, ref })
173
+ expect(published.ok).toBe(true)
174
+ const log = await adapter.cms.listDocumentPatches(projectId, ref)
175
+ expect(log.map((row) => `${row.revision}:${row.kind}`)).toEqual(['1:replace', '2:patch', '3:publish'])
176
+ const record = await adapter.cms.getDocument(projectId, ref)
177
+ expect(record.document.values).toEqual({ title: 'T' })
178
+ expect(record.hasUnpublishedChanges).toBe(false)
179
+ expect((await adapter.cms.getDocumentVersion(projectId, ref, 1)).snapshot.values).toEqual({ title: 'T' })
180
+ // The stored row stays inside Convex's 16-level cap.
181
+ const rows = await t.run((ctx) => ctx.db.query('cmsDocuments').collect())
182
+ for (const row of rows) expect(measureMaxDepth(row).depth).toBeLessThanOrEqual(16)
183
+ })
184
+ })
@@ -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
+ })
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
+ })
@@ -73,8 +73,11 @@ export interface CmsStore {
73
73
  * editor's `doc`): the value and its revision travel together, so a writer authoring
74
74
  * from what it displayed names that revision. `undefined` for a value the session never
75
75
  * showed (a fixture session, a copy) or a provisional one (a create in flight).
76
+ * EC-667: with `path`, the revision the content at that position was authored against
77
+ * (a view showing a pending write is a composite); without it, the EARLIEST of any
78
+ * position — the whole-document writer's token.
76
79
  */
77
- revisionOfView(view: CmsDocument): number | undefined;
80
+ revisionOfView(view: CmsDocument, path?: ValuePath): number | undefined;
78
81
  /**
79
82
  * EC-595 A2 / EC-578 — create a document from a whole value (duplicate, import of a
80
83
  * new row, an AI create, a transfer, a reuse promote): insert-if-absent