@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
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,
@@ -39,22 +41,30 @@ import { findRecordDoc, listRecordWire, saveRecord } from './record-functions'
39
41
  * analytics addon must fail visibly (EC-618's counsel gate relies on it);
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
- * claims the stored file with {@link claimIngestUpload}, which checks it against what the
43
- * upload request declared.
44
+ * claims the stored file with {@link claimIngestUpload}, which checks that the deployment
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.
44
48
  *
45
49
  * The engine never imports an addon: the handler is an internal-mutation REFERENCE the
46
50
  * consumer passes in its one `http.ts` line (`defineStudioHttp({ auth, ingest: {
47
51
  * submission: formsIngest } })`), exported by the addon's `./convex` entry.
48
52
  */
49
53
 
50
- /** What an ingest handler receives: the verified, parsed payload and the receipt time. */
51
- 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()) }
52
62
 
53
63
  /** An addon's ingest handler: an INTERNAL mutation (never client-callable). */
54
64
  export type IngestHandlerRef = FunctionReference<
55
65
  'mutation',
56
66
  'internal',
57
- { payload: IngestPayload; receivedAt: string },
67
+ { payload: IngestPayload; receivedAt: string; attempt?: number },
58
68
  IngestHandlerResult
59
69
  >
60
70
 
@@ -96,6 +106,32 @@ function utf8Length(text: string): number {
96
106
  return new TextEncoder().encode(text).byteLength
97
107
  }
98
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
+
99
135
  /** The receiver `httpAction` (see the module header). */
100
136
  export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction {
101
137
  return httpActionGeneric(async (ctx, request) => {
@@ -126,7 +162,7 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
126
162
  handled: false,
127
163
  })
128
164
  }
129
- const result = await ctx.runMutation(handler, { payload: body, receivedAt: new Date().toISOString() })
165
+ const result = await runIngestHandler(ctx, handler, body, new Date().toISOString())
130
166
  if (!result.ok) {
131
167
  return json(result.status, { error: result.message, ...(result.issues ? { issues: result.issues } : {}) })
132
168
  }
@@ -147,10 +183,24 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
147
183
  // intent (`recordIngestUpload`); a submission CLAIMS it (`claimIngestUpload`), which checks the
148
184
  // stored file against the intent. Abuse is bounded by: the route's limiter and bot gate on every
149
185
  // 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
186
+ // (an over-size or mistyped file is refused, never attached), and the unclaimed intents an addon can
151
187
  // list (`unclaimedIngestUploads`) and surface. Convex returns the storage id to the uploader
152
188
  // only, so the bytes of an upload no submission ever claims cannot be located by the engine —
153
189
  // that is the documented residue, bounded by the limits above.
190
+ //
191
+ // EC-671 — ownership. The storage id a claim names is the VISITOR's word: nothing at intent
192
+ // time can record it (`generateUploadUrl` takes no argument; the id reaches the uploader
193
+ // alone), and an asset's storage id is public on the delivery snapshot. So a claim binds a
194
+ // file only when the deployment's own facts say it is this upload's ({@link claimIngestUpload}),
195
+ // it never deletes, and ingest bytes are deleted only through {@link releaseIngestUploads},
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.
154
204
 
155
205
  /** The upload intent — an engine-internal record kind (no public functions). */
156
206
  export const ingestUploadRecordKind = defineRecordKind({
@@ -163,12 +213,33 @@ export const ingestUploadRecordKind = defineRecordKind({
163
213
  field: z.string().min(1),
164
214
  contentType: z.string().min(1),
165
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),
166
218
  state: z.enum(['issued', 'claimed', 'rejected']),
167
219
  issuedAt: z.string().min(1),
220
+ /** The id the intent's claim NAMED (claimed or rejected) — what was asked, not what is owned. */
168
221
  storageId: z.string().optional(),
169
222
  },
170
223
  })
171
224
 
225
+ /**
226
+ * EC-671 — the engine's ONE ownership fact for ingest bytes: a record per stored file a claim
227
+ * bound to its intent, keyed BY the storage id (`recordId`), so a file is bound at most once
228
+ * and "is it bound?" is one indexed read. Written only by {@link claimIngestUpload}, in the
229
+ * claim's transaction; read by the claim (a file another claim bound is refused) and by
230
+ * {@link releaseIngestUploads}. Engine-internal (no public functions), like the intent.
231
+ */
232
+ export const ingestBindingRecordKind = defineRecordKind({
233
+ kind: 'ingestBinding',
234
+ module: 'ingest',
235
+ time: 'boundAt',
236
+ fields: {
237
+ uploadId: z.string().min(1),
238
+ target: z.string().min(1),
239
+ boundAt: z.string().min(1),
240
+ },
241
+ })
242
+
172
243
  let mintUploadUuid: (() => string) | undefined
173
244
 
174
245
  export interface IngestUploadPolicy {
@@ -213,7 +284,13 @@ export async function recordIngestUpload(
213
284
  }
214
285
 
215
286
  function payloadColumns(payload: Extract<IngestPayload, { kind: 'upload' }>) {
216
- 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
+ }
217
294
  }
218
295
 
219
296
  /** What a claim yields: the stored file's facts, or a refusal for the handler to return. */
@@ -221,11 +298,69 @@ export type IngestUploadClaimResult =
221
298
  | { ok: true; storageId: string; field: string; contentType: string; size: number }
222
299
  | { ok: false; status: 409 | 413 | 415 | 422; message: string }
223
300
 
301
+ /**
302
+ * EC-671 — whether the deployment's own facts say a stored file is this intent's to claim. The
303
+ * storage id cannot be recorded at intent time (it reaches the uploader alone), so ownership
304
+ * is read from `_storage` facts and engine rows; a file is claimable only when ALL hold:
305
+ *
306
+ * 1. it was stored AFTER the intent was issued — compared in ONE clock: the intent row's
307
+ * `_creationTime` against the file's, both stamped by the deployment at insert (`issuedAt`
308
+ * is the HTTP action's clock, at millisecond grain: a file stored in the same millisecond
309
+ * before the intent would compare equal);
310
+ * 2. no claim bound it already ({@link ingestBindingRecordKind});
311
+ * 3. no asset row references it — `storageKey` or `thumb.storageKey` (an asset's storage id is
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.
322
+ *
323
+ * Hiding `storageKey` would not be ownership; these are.
324
+ */
325
+ async function claimableFile(
326
+ ctx: MutationCtx,
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'
336
+ }
337
+
338
+ /** Check (3): does any asset row reference these bytes, as its file or its preview (`thumb`)? */
339
+ async function assetReferencesStorage(ctx: MutationCtx, storageId: string): Promise<boolean> {
340
+ const asFile = await ctx.db
341
+ .query('assets')
342
+ .withIndex('by_storage', (q) => q.eq('storageKey', storageId))
343
+ .first()
344
+ if (asFile) return true
345
+ const asThumb = await ctx.db
346
+ .query('assets')
347
+ .withIndex('by_thumb_storage', (q) => q.eq('thumb.storageKey', storageId))
348
+ .first()
349
+ return asThumb !== null
350
+ }
351
+
224
352
  /**
225
353
  * 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.
354
+ * belong to `target`, be younger than `INGEST_UPLOAD_TTL_MS`; the stored file must be this
355
+ * intent's ({@link claimableFile}, EC-671 + EC-685: its bytes the declared ones) and match it
356
+ * (size ≤ declared, same content type).
357
+ * A claimed file is BOUND to the intent ({@link ingestBindingRecordKind}) — the fact every
358
+ * deletion reads ({@link releaseIngestUploads}).
359
+ *
360
+ * A refusal marks the intent `rejected` (recording the id it named) and deletes NOTHING
361
+ * (EC-671): the named id is the visitor's word, and even a file that passed every ownership
362
+ * check may belong to someone the engine cannot see. A refused upload's bytes stay where the
363
+ * uploader put them — the same residue as an upload never claimed.
229
364
  */
230
365
  export async function claimIngestUpload(
231
366
  ctx: MutationCtx,
@@ -235,30 +370,113 @@ export async function claimIngestUpload(
235
370
  ): Promise<IngestUploadClaimResult> {
236
371
  const intent = await findRecordDoc(ctx, ingestUploadRecordKind, claim.uploadId)
237
372
  const columns = intent?.data as
238
- | { 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 }
239
374
  | undefined
240
- if (!columns || columns.target !== target) return { ok: false, status: 422, message: `Unknown upload “${claim.uploadId}”.` }
375
+ if (!intent || !columns || columns.target !== target) {
376
+ return { ok: false, status: 422, message: `Unknown upload “${claim.uploadId}”.` }
377
+ }
241
378
  if (columns.state !== 'issued') return { ok: false, status: 409, message: `Upload “${claim.uploadId}” was already claimed.` }
242
379
  if (Date.parse(receivedAt) - Date.parse(columns.issuedAt) > INGEST_UPLOAD_TTL_MS) {
243
380
  return { ok: false, status: 422, message: `Upload “${claim.uploadId}” expired.` }
244
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
+ }
245
398
  const storageId = claim.storageId as GenericId<'_storage'>
246
399
  const file = await ctx.db.system.get(storageId).catch(() => null)
247
400
  const stamps = { createdAt: receivedAt, updatedAt: receivedAt }
248
401
  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)
402
+ const reject = async (refusal: { status: 413 | 415 | 422; message: string }): Promise<IngestUploadClaimResult> => {
257
403
  await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'rejected', storageId: claim.storageId }, stamps)
258
- return { ok: false, ...refused }
404
+ return { ok: false, ...refusal }
405
+ }
406
+ const intentRow = await ctx.db.get(intent._id)
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
+ })
259
415
  }
416
+ if (verdict !== 'claimable') {
417
+ // One answer for all four checks: which one failed is not the visitor's to learn.
418
+ return reject({ status: 422, message: `Upload “${claim.uploadId}” did not store the file “${claim.storageId}”.` })
419
+ }
420
+ if (file.size > columns.size) return reject({ status: 413, message: 'The stored file is larger than the upload declared.' })
421
+ if ((file.contentType ?? '') !== columns.contentType) {
422
+ return reject({ status: 415, message: 'The stored file is not of the declared type.' })
423
+ }
424
+ // Bound — and handed back — under the deployment's own id for the file (`file._id`), the
425
+ // spelling checks (2) and (3) read, never the string the visitor sent.
426
+ await saveRecord(ctx, ingestBindingRecordKind, file._id, { uploadId: claim.uploadId, target, boundAt: receivedAt }, stamps)
260
427
  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 }
428
+ return { ok: true, storageId: file._id, field: columns.field, contentType: columns.contentType, size: file.size }
429
+ }
430
+
431
+ /**
432
+ * Delete ingest bytes an addon holds (EC-671) — the ONE way they are deleted, and it deletes
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.
448
+ */
449
+ export async function releaseIngestUploads(
450
+ ctx: MutationCtx,
451
+ storageIds: readonly string[],
452
+ ): Promise<{ deleted: string[]; kept: string[] }> {
453
+ const deleted: string[] = []
454
+ const kept: string[] = []
455
+ for (const storageId of new Set(storageIds)) {
456
+ const binding = await findRecordDoc(ctx, ingestBindingRecordKind, storageId)
457
+ if (!binding) {
458
+ kept.push(storageId)
459
+ continue
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
+ }
466
+ const id = storageId as GenericId<'_storage'>
467
+ if (await ctx.db.system.get(id).catch(() => null)) await ctx.storage.delete(id)
468
+ deleted.push(storageId)
469
+ }
470
+ return { deleted, kept }
471
+ }
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
262
480
  }
263
481
 
264
482
  /** Upload intents of `target` still unclaimed past their life — the visible orphan finding. */
@@ -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,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
+ })