@elytracms/studio 0.0.97 → 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 (38) hide show
  1. package/convex/assets.ts +16 -2
  2. package/convex/ingest-attempt.test.ts +161 -0
  3. package/convex/ingest.test.ts +185 -9
  4. package/convex/ingest.ts +136 -24
  5. package/convex/prototype-key.test.ts +92 -0
  6. package/convex/records-zod-refinements.convex.test.ts +94 -0
  7. package/dist/components/cms/cms-store.d.ts +2 -4
  8. package/dist/components/cms/cms-store.js +4 -22
  9. package/dist/components/cms/cms-store.js.map +1 -1
  10. package/dist/components/cms/field-control.d.ts +12 -1
  11. package/dist/components/cms/field-control.js +16 -1
  12. package/dist/components/cms/field-control.js.map +1 -1
  13. package/dist/components/cms/import-batch.d.ts +23 -0
  14. package/dist/components/cms/import-batch.js +43 -0
  15. package/dist/components/cms/import-batch.js.map +1 -0
  16. package/dist/components/cms/rich-text/editor.js +13 -10
  17. package/dist/components/cms/rich-text/editor.js.map +1 -1
  18. package/dist/components/cms/rich-text/value.d.ts +9 -0
  19. package/dist/components/cms/rich-text/value.js +13 -0
  20. package/dist/components/cms/rich-text/value.js.map +1 -1
  21. package/dist/components/cms/use-document-draft.d.ts +35 -2
  22. package/dist/components/cms/use-document-draft.js +42 -15
  23. package/dist/components/cms/use-document-draft.js.map +1 -1
  24. package/dist/components/records/record-lens.js.map +1 -1
  25. package/dist/lib/engine-version.d.ts +1 -1
  26. package/dist/lib/engine-version.js +1 -1
  27. package/dist/lib/engine-version.js.map +1 -1
  28. package/dist/lib/i18n/messages.de.js +3 -0
  29. package/dist/lib/i18n/messages.de.js.map +1 -1
  30. package/dist/lib/i18n/messages.en.d.ts +3 -0
  31. package/dist/lib/i18n/messages.en.js +3 -0
  32. package/dist/lib/i18n/messages.en.js.map +1 -1
  33. package/dist/lib/operations/cms-persistence.d.ts +9 -4
  34. package/dist/lib/operations/cms-persistence.js +132 -29
  35. package/dist/lib/operations/cms-persistence.js.map +1 -1
  36. package/dist/lib/persona/labels.js +1 -0
  37. package/dist/lib/persona/labels.js.map +1 -1
  38. package/package.json +4 -3
package/convex/ingest.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { httpActionGeneric, makeFunctionReference } from 'convex/server'
2
- import type { FunctionReference, PublicHttpAction } from 'convex/server'
2
+ import type { FunctionReference, GenericActionCtx, GenericDataModel, PublicHttpAction } from 'convex/server'
3
3
  import { v } from 'convex/values'
4
4
  import type { GenericId } from 'convex/values'
5
5
  import { z } from 'zod'
@@ -7,7 +7,9 @@ import {
7
7
  INGEST_MAX_BODY_BYTES,
8
8
  INGEST_MAX_UPLOAD_BYTES,
9
9
  INGEST_SIGNATURE_HEADER,
10
+ INGEST_UPLOAD_SHA256,
10
11
  INGEST_UPLOAD_TTL_MS,
12
+ compareIngestUploadSha256,
11
13
  ingestPayloadSchema,
12
14
  readIngestReceiverBody,
13
15
  verifyIngestSignature,
@@ -40,22 +42,29 @@ import { findRecordDoc, listRecordWire, saveRecord } from './record-functions'
40
42
  * 7. an accepted `upload` gets ONE short-lived storage URL (`ctx.storage.generateUploadUrl`)
41
43
  * — the bytes go browser → storage, cross-origin by design; the submission that follows
42
44
  * 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.
45
+ * stored it for THIS intent (EC-671) — its bytes the ones the upload request declared by
46
+ * SHA-256 (EC-685) — and that it matches the declared size and type, and binds it;
47
+ * {@link releaseIngestUploads} is the only way such bytes are deleted.
45
48
  *
46
49
  * The engine never imports an addon: the handler is an internal-mutation REFERENCE the
47
50
  * consumer passes in its one `http.ts` line (`defineStudioHttp({ auth, ingest: {
48
51
  * submission: formsIngest } })`), exported by the addon's `./convex` entry.
49
52
  */
50
53
 
51
- /** What an ingest handler receives: the verified, parsed payload and the receipt time. */
52
- export const ingestHandlerArgs = { payload: v.any(), receivedAt: v.string() }
54
+ /**
55
+ * What an ingest handler receives: the verified, parsed payload, the receipt time, and — only
56
+ * when the receiver RETRIES the request (EC-618) — which attempt this is. `attempt` is absent on
57
+ * the first run (so a handler declared with the first two arguments alone runs unchanged) and
58
+ * `2…INGEST_HANDLER_ATTEMPTS` on a retry; the payload and `receivedAt` are the same on every
59
+ * attempt. A handler that does not care ignores it.
60
+ */
61
+ export const ingestHandlerArgs = { payload: v.any(), receivedAt: v.string(), attempt: v.optional(v.number()) }
53
62
 
54
63
  /** An addon's ingest handler: an INTERNAL mutation (never client-callable). */
55
64
  export type IngestHandlerRef = FunctionReference<
56
65
  'mutation',
57
66
  'internal',
58
- { payload: IngestPayload; receivedAt: string },
67
+ { payload: IngestPayload; receivedAt: string; attempt?: number },
59
68
  IngestHandlerResult
60
69
  >
61
70
 
@@ -97,6 +106,32 @@ function utf8Length(text: string): number {
97
106
  return new TextEncoder().encode(text).byteLength
98
107
  }
99
108
 
109
+ /**
110
+ * How many times the receiver runs a handler whose mutation THREW before the failure surfaces
111
+ * (EC-618). A Convex mutation cannot observe its own optimistic-concurrency conflict — Convex
112
+ * re-executes it and reports exhaustion only to the caller, this `runMutation` — so the receiver
113
+ * is where a request gets another attempt (`attempt: 2, 3` — see {@link ingestHandlerArgs}).
114
+ * A thrown mutation committed nothing, so a retry never counts twice. A handler's REFUSAL
115
+ * (`{ ok: false }`) is an answer, never retried.
116
+ */
117
+ export const INGEST_HANDLER_ATTEMPTS = 3
118
+
119
+ async function runIngestHandler(
120
+ ctx: Pick<GenericActionCtx<GenericDataModel>, 'runMutation'>,
121
+ handler: IngestHandlerRef,
122
+ payload: IngestPayload,
123
+ receivedAt: string,
124
+ ): Promise<IngestHandlerResult> {
125
+ for (let attempt = 1; ; attempt += 1) {
126
+ try {
127
+ // Attempt 1 passes exactly the two arguments every handler has always received.
128
+ return await ctx.runMutation(handler, attempt === 1 ? { payload, receivedAt } : { payload, receivedAt, attempt })
129
+ } catch (error) {
130
+ if (attempt >= INGEST_HANDLER_ATTEMPTS) throw error
131
+ }
132
+ }
133
+ }
134
+
100
135
  /** The receiver `httpAction` (see the module header). */
101
136
  export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction {
102
137
  return httpActionGeneric(async (ctx, request) => {
@@ -127,7 +162,7 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
127
162
  handled: false,
128
163
  })
129
164
  }
130
- const result = await ctx.runMutation(handler, { payload: body, receivedAt: new Date().toISOString() })
165
+ const result = await runIngestHandler(ctx, handler, body, new Date().toISOString())
131
166
  if (!result.ok) {
132
167
  return json(result.status, { error: result.message, ...(result.issues ? { issues: result.issues } : {}) })
133
168
  }
@@ -159,6 +194,13 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
159
194
  // file only when the deployment's own facts say it is this upload's ({@link claimIngestUpload}),
160
195
  // it never deletes, and ingest bytes are deleted only through {@link releaseIngestUploads},
161
196
  // which deletes only what a claim bound.
197
+ //
198
+ // EC-685 — causation. Eligibility (stored after the intent, unbound, no asset's) is not
199
+ // ownership: with intents A and B open, bytes stored through B's URL were eligible for A. So
200
+ // the intent DECLARES the bytes' SHA-256 before they exist, and the claim requires the digest
201
+ // the deployment computed at store time (`_storage.sha256`) to equal it — A can bind B's bytes
202
+ // only by predicting them. Ownership is also kept true when a delete spends it: release keeps
203
+ // a bound file an asset references now, and `assets:deleteBlob` keeps bytes a binding holds.
162
204
 
163
205
  /** The upload intent — an engine-internal record kind (no public functions). */
164
206
  export const ingestUploadRecordKind = defineRecordKind({
@@ -171,6 +213,8 @@ export const ingestUploadRecordKind = defineRecordKind({
171
213
  field: z.string().min(1),
172
214
  contentType: z.string().min(1),
173
215
  size: z.number().int().positive(),
216
+ /** EC-685: the SHA-256 the upload declared for its bytes (lowercase hex) — what makes the claim causal. */
217
+ sha256: z.string().regex(INGEST_UPLOAD_SHA256),
174
218
  state: z.enum(['issued', 'claimed', 'rejected']),
175
219
  issuedAt: z.string().min(1),
176
220
  /** The id the intent's claim NAMED (claimed or rejected) — what was asked, not what is owned. */
@@ -240,7 +284,13 @@ export async function recordIngestUpload(
240
284
  }
241
285
 
242
286
  function payloadColumns(payload: Extract<IngestPayload, { kind: 'upload' }>) {
243
- return { target: payload.target, field: payload.field, contentType: payload.contentType, size: payload.size }
287
+ return {
288
+ target: payload.target,
289
+ field: payload.field,
290
+ contentType: payload.contentType,
291
+ size: payload.size,
292
+ sha256: payload.sha256,
293
+ }
244
294
  }
245
295
 
246
296
  /** What a claim yields: the stored file's facts, or a refusal for the handler to return. */
@@ -259,18 +309,30 @@ export type IngestUploadClaimResult =
259
309
  * before the intent would compare equal);
260
310
  * 2. no claim bound it already ({@link ingestBindingRecordKind});
261
311
  * 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).
312
+ * public on the delivery snapshot the moment its row exists);
313
+ * 4. EC-685: its bytes are the ones the intent DECLARED — the digest the deployment computed
314
+ * when it stored them (`_storage.sha256`) equals the intent's `sha256`, through the ONE
315
+ * comparator (`compareIngestUploadSha256`, which reads the stored digest by its form). Checks
316
+ * 1–3 are eligibility; this one is causation: with intents A and B open, bytes stored
317
+ * through B's URL pass 1–3 for A too, and fail 4 unless A predicted them.
318
+ *
319
+ * A stored digest in neither form (`unreadable`) is its own answer — a deployment fault, the
320
+ * same for every file, refused visibly and never read as a match. It is read first, so it
321
+ * says nothing about which ownership check a named file would fail.
263
322
  *
264
323
  * Hiding `storageKey` would not be ownership; these are.
265
324
  */
266
325
  async function claimableFile(
267
326
  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))
327
+ file: { _id: GenericId<'_storage'>; _creationTime: number; sha256: string },
328
+ intent: { createdAt: number; sha256: string },
329
+ ): Promise<'claimable' | 'not-this-intent' | 'digest-unreadable'> {
330
+ const digest = compareIngestUploadSha256(intent.sha256, file.sha256)
331
+ if (digest === 'unreadable') return 'digest-unreadable'
332
+ if (file._creationTime < intent.createdAt) return 'not-this-intent'
333
+ if (await findRecordDoc(ctx, ingestBindingRecordKind, file._id)) return 'not-this-intent'
334
+ if (await assetReferencesStorage(ctx, file._id)) return 'not-this-intent'
335
+ return digest === 'same' ? 'claimable' : 'not-this-intent'
274
336
  }
275
337
 
276
338
  /** Check (3): does any asset row reference these bytes, as its file or its preview (`thumb`)? */
@@ -290,7 +352,8 @@ async function assetReferencesStorage(ctx: MutationCtx, storageId: string): Prom
290
352
  /**
291
353
  * Claim one upload for a submission to `target`: the intent must exist, be unclaimed,
292
354
  * 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).
355
+ * intent's ({@link claimableFile}, EC-671 + EC-685: its bytes the declared ones) and match it
356
+ * (size ≤ declared, same content type).
294
357
  * A claimed file is BOUND to the intent ({@link ingestBindingRecordKind}) — the fact every
295
358
  * deletion reads ({@link releaseIngestUploads}).
296
359
  *
@@ -307,7 +370,7 @@ export async function claimIngestUpload(
307
370
  ): Promise<IngestUploadClaimResult> {
308
371
  const intent = await findRecordDoc(ctx, ingestUploadRecordKind, claim.uploadId)
309
372
  const columns = intent?.data as
310
- | { target: string; field: string; contentType: string; size: number; state: string; issuedAt: string }
373
+ | { target: string; field: string; contentType: string; size: number; sha256?: string; state: string; issuedAt: string }
311
374
  | undefined
312
375
  if (!intent || !columns || columns.target !== target) {
313
376
  return { ok: false, status: 422, message: `Unknown upload “${claim.uploadId}”.` }
@@ -316,6 +379,22 @@ export async function claimIngestUpload(
316
379
  if (Date.parse(receivedAt) - Date.parse(columns.issuedAt) > INGEST_UPLOAD_TTL_MS) {
317
380
  return { ok: false, status: 422, message: `Upload “${claim.uploadId}” expired.` }
318
381
  }
382
+ // LEGACY (marked, EC-685): an intent issued before uploads declared their digest cannot be
383
+ // checked for causation. It is refused with its own reason, BEFORE any write: its row lacks
384
+ // the column the kind now requires, and whether the record rule accepts a write onto such a
385
+ // row is the record store's business (measured on 6310b7b4: it does; EC-682 rewrites that
386
+ // validator) — this branch never asks. Without it the claim would still be refused (the
387
+ // digest compares `different`), under the generic answer. Such intents are claimable for at
388
+ // most INGEST_UPLOAD_TTL_MS after the deploy (the expiry above refuses them first
389
+ // afterwards); delete this branch one release after EC-685 shipped.
390
+ const declared = columns.sha256
391
+ if (typeof declared !== 'string') {
392
+ return {
393
+ ok: false,
394
+ status: 422,
395
+ message: `Upload “${claim.uploadId}” was issued before uploads declared their SHA-256 — upload the file again.`,
396
+ }
397
+ }
319
398
  const storageId = claim.storageId as GenericId<'_storage'>
320
399
  const file = await ctx.db.system.get(storageId).catch(() => null)
321
400
  const stamps = { createdAt: receivedAt, updatedAt: receivedAt }
@@ -325,8 +404,17 @@ export async function claimIngestUpload(
325
404
  return { ok: false, ...refusal }
326
405
  }
327
406
  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.
407
+ const verdict = intentRow
408
+ ? await claimableFile(ctx, file, { createdAt: intentRow._creationTime, sha256: declared })
409
+ : 'not-this-intent'
410
+ if (verdict === 'digest-unreadable') {
411
+ return reject({
412
+ status: 422,
413
+ message: `The deployment's stored digest of “${claim.storageId}” is in neither form the claim reads (64 hex or 44 base64 characters), so the upload cannot be verified.`,
414
+ })
415
+ }
416
+ if (verdict !== 'claimable') {
417
+ // One answer for all four checks: which one failed is not the visitor's to learn.
330
418
  return reject({ status: 422, message: `Upload “${claim.uploadId}” did not store the file “${claim.storageId}”.` })
331
419
  }
332
420
  if (file.size > columns.size) return reject({ status: 413, message: 'The stored file is larger than the upload declared.' })
@@ -342,10 +430,21 @@ export async function claimIngestUpload(
342
430
 
343
431
  /**
344
432
  * 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.
433
+ * only files a claim BOUND that no asset references NOW: the bytes and their binding go
434
+ * together. Any other id is KEPT and returned in `kept`:
435
+ *
436
+ * - an id with no binding — an asset's, an id a write put into a row by hand, a file claimed
437
+ * before bindings existed — because ingest cannot show it owns them;
438
+ * - EC-685: a bound file an asset row references now (`storageKey` or `thumb.storageKey` — an
439
+ * editor promoted the upload to an asset with `assets:upsert`, a legitimate edit). Ownership
440
+ * has passed to the asset: the binding is spent (deleted) and the bytes stay, so the
441
+ * asset's own delete removes them later. Read in this transaction, so no asset can acquire
442
+ * the file between the check and the delete.
443
+ *
444
+ * Call it for a refused submission's already-claimed files and when removing a row that holds
445
+ * claimed files. The mirror is `assets:deleteBlob`, which keeps bytes a binding holds
446
+ * ({@link ingestBindingHolds}): each delete path re-checks the OTHER owner in its own
447
+ * transaction.
349
448
  */
350
449
  export async function releaseIngestUploads(
351
450
  ctx: MutationCtx,
@@ -359,14 +458,27 @@ export async function releaseIngestUploads(
359
458
  kept.push(storageId)
360
459
  continue
361
460
  }
461
+ await ctx.db.delete(binding._id)
462
+ if (await assetReferencesStorage(ctx, storageId)) {
463
+ kept.push(storageId) // the asset owns the bytes now (EC-685)
464
+ continue
465
+ }
362
466
  const id = storageId as GenericId<'_storage'>
363
467
  if (await ctx.db.system.get(id).catch(() => null)) await ctx.storage.delete(id)
364
- await ctx.db.delete(binding._id)
365
468
  deleted.push(storageId)
366
469
  }
367
470
  return { deleted, kept }
368
471
  }
369
472
 
473
+ /**
474
+ * EC-685 — does an ingest binding hold these bytes (a claimed upload a submission still owns)?
475
+ * The one reader `assets:deleteBlob` asks before it deletes stored bytes, so an asset an
476
+ * editor created over a submission's file never takes the submission's bytes with it.
477
+ */
478
+ export async function ingestBindingHolds(ctx: QueryCtx | MutationCtx, storageId: string): Promise<boolean> {
479
+ return (await findRecordDoc(ctx, ingestBindingRecordKind, storageId)) !== null
480
+ }
481
+
370
482
  /** Upload intents of `target` still unclaimed past their life — the visible orphan finding. */
371
483
  export async function unclaimedIngestUploads(
372
484
  ctx: QueryCtx | MutationCtx,
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import type { ConvexHttpClient } from 'convex/browser'
4
+ import type { FunctionReference } from 'convex/server'
5
+ import type { CmsDocument, CollectionDef } from '@elytracms/core/cms-core'
6
+ import { createOperations } from '@elytracms/core/operations'
7
+ import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
8
+ import schema from './schema'
9
+
10
+ /**
11
+ * EC-684 (Codex round 4, F27) — the `convex-parity` measurement the refusal rests on, and the refusal
12
+ * itself over the production `ConvexPersistenceAdapter` (its client a call-through onto convex-test's
13
+ * REAL `cms.ts` mutations — the wiring of `depth-codec-lookalike.test.ts`).
14
+ *
15
+ * 1. Re-measured (Codex measured it in `codec-own-keys.convex.test.ts`): the Convex value converters
16
+ * rebuild objects by assignment (`convex/src/values/value.ts` `jsonToConvex` / `convexToJsonInternal`),
17
+ * so an own `__proto__` key written straight through the adapter — past the operations layer — is
18
+ * lost before any sink, at the top level of `values` and nested. The known-positive control: an
19
+ * ordinary own key on the same path survives, so the instrument CAN see a key survive.
20
+ * 2. So the in-memory store keeping the key would be drift; the operations layer refuses it on the raw
21
+ * input, located, in this store too, and nothing is written.
22
+ */
23
+
24
+ const modules = {
25
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
26
+ './_generated/api.ts': async () => ({}),
27
+ }
28
+ const P = 'site'
29
+ const ADMIN = 'user_admin'
30
+ const AT = '2026-10-28T12:00:00.000Z'
31
+
32
+ const collections: CollectionDef[] = [
33
+ { id: 'post', kind: 'document', fields: [{ name: 'payload', type: 'object', multiple: false, fields: [] }] },
34
+ ]
35
+
36
+ interface Backend {
37
+ query: (ref: FunctionReference<'query'>, args: Record<string, unknown>) => Promise<unknown>
38
+ mutation: (ref: FunctionReference<'mutation'>, args: Record<string, unknown>) => Promise<unknown>
39
+ }
40
+
41
+ function asAdapterClient(backend: Backend): ConvexHttpClient {
42
+ return {
43
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => backend.query(ref, args ?? {}),
44
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => backend.mutation(ref, args ?? {}),
45
+ } as unknown as ConvexHttpClient
46
+ }
47
+
48
+ async function harness() {
49
+ const t = convexTest(schema, modules)
50
+ await t.run(async (ctx) => {
51
+ await ctx.db.insert('members', { userId: ADMIN, role: 'admin', createdAt: AT, updatedAt: AT })
52
+ })
53
+ const adapter = new ConvexPersistenceAdapter(asAdapterClient(t.withIdentity({ subject: ADMIN, issuer: 'https://convex.test' })), P)
54
+ const ops = createOperations({ adapter, collections: () => collections, validateDocument: () => [] })
55
+ const rows = async () => t.run(async (ctx) => (await ctx.db.query('cmsDocuments').collect()).map((row) => row.docId))
56
+ return { adapter, ops, rows }
57
+ }
58
+
59
+ const parse = <T = Record<string, unknown>>(json: string): T => JSON.parse(json) as T
60
+ const ownProto = (value: unknown) => typeof value === 'object' && value !== null && Object.hasOwn(value, '__proto__')
61
+
62
+ describe('EC-684 on Convex — an own `__proto__` key', () => {
63
+ it('re-measured: written straight through the adapter (past operations), Convex loses it before the sink — CONTROL: an ordinary own key survives', async () => {
64
+ const { adapter } = await harness()
65
+ const document = parse<CmsDocument>('{"collection":"post","id":"direct","values":{"__proto__":{"text":"TOP"},"payload":{"__proto__":{"text":"NESTED"},"ordinary":{"text":"KEEP"}}}}')
66
+ expect(ownProto(document.values)).toBe(true)
67
+ expect(ownProto(document.values.payload)).toBe(true)
68
+ await adapter.cms.replaceDocument(P, document, { ifAbsent: true })
69
+ const stored = (await adapter.cms.getDocument(P, { collection: 'post', id: 'direct' })).document
70
+ expect((stored.values.payload as Record<string, unknown>).ordinary).toEqual({ text: 'KEEP' })
71
+ expect(ownProto(stored.values)).toBe(false)
72
+ expect(ownProto(stored.values.payload)).toBe(false)
73
+ })
74
+
75
+ it('through the operations layer it is refused on the raw input, located — and nothing reaches the store', async () => {
76
+ const { ops, rows } = await harness()
77
+ const values = parse('{"__proto__":{"text":"TOP"},"payload":{"__proto__":{"text":"NESTED"}}}')
78
+ const result = await ops.documents.replace({ projectId: P, document: { collection: 'post', id: 'refused', values }, precondition: { ifAbsent: true } })
79
+ expect(result.ok).toBe(false)
80
+ if (result.ok) return
81
+ expect(result.error.code).toBe('validation')
82
+ expect(result.error.code === 'validation' ? result.error.issues.map((issue) => [issue.code, issue.path]) : []).toEqual([
83
+ ['prototype-key', ['document', 'values', '__proto__']],
84
+ ['prototype-key', ['document', 'values', 'payload', '__proto__']],
85
+ ])
86
+ expect(await rows()).toEqual([])
87
+ // CONTROL: the same write without the key goes through.
88
+ const plain = await ops.documents.replace({ projectId: P, document: { collection: 'post', id: 'plain', values: { payload: { text: 'x' } } }, precondition: { ifAbsent: true } })
89
+ expect(plain.ok).toBe(true)
90
+ expect(await rows()).toEqual(['plain'])
91
+ })
92
+ })
@@ -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
+ })
@@ -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
@@ -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, touchedPaths, 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,6 +9,7 @@ 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
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';
@@ -684,28 +685,9 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
684
685
  // transfer dialog) can no longer change a verdict. `validate` above stays the
685
686
  // session's DISPLAY validation (the editor's issues, cell edit, import, AI).
686
687
  const importDocuments = useCallback((collectionId, raw) => {
687
- const report = { added: 0, rejected: [] };
688
688
  // Pre-parse so intra-batch relation/asset targets resolve during validation.
689
- const parsedBatch = [];
690
- const schemaFails = [];
691
- for (const item of raw) {
692
- const parsed = documentSchema.safeParse({ ...item, collection: collectionId });
693
- if (parsed.success)
694
- parsedBatch.push(parsed.data);
695
- else
696
- schemaFails.push({ item, error: parsed });
697
- }
698
- for (const { item, error } of schemaFails) {
699
- report.rejected.push({
700
- id: item?.id ?? translate('cms.list.import.noId'),
701
- issues: (error.success ? [] : error.error.issues).map((e) => ({
702
- code: 'invalid-field-value',
703
- severity: 'error',
704
- message: e.message,
705
- path: e.path.map((p) => (typeof p === 'symbol' ? String(p) : p)),
706
- })),
707
- });
708
- }
689
+ const { parsed: parsedBatch, rejected } = parseImportBatch(collectionId, raw);
690
+ const report = { added: 0, rejected: [...rejected] };
709
691
  // Validate each against existing docs PLUS the rest of the batch.
710
692
  const batchStore = createDocumentStore([...documents, ...parsedBatch]);
711
693
  const accepted = [];