@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/cms.ts CHANGED
@@ -141,6 +141,32 @@ async function logAfter(ctx: Ctx, collection: string, docId: string, afterRevisi
141
141
  }))
142
142
  }
143
143
 
144
+ /** EC-669 — the floor a removed identity left (`null`: none of its lifetimes was ever removed). */
145
+ function floorRow(ctx: Ctx, collection: string, docId: string) {
146
+ return ctx.db
147
+ .query('documentFloors')
148
+ .withIndex('by_doc', (q) => q.eq('collection', collection).eq('docId', docId))
149
+ .unique()
150
+ }
151
+
152
+ /**
153
+ * EC-669 — INVARIANT: a revision or version number names one state of ONE lifetime. Both
154
+ * removal mutations raise the identity's floor to what the removed lifetime reached, in the same
155
+ * transaction as the removal, so a later create of the same `collection:docId` starts above
156
+ * every token it minted. `existing` lets `clearDocuments` pass the row it already read.
157
+ */
158
+ async function raiseFloor(
159
+ ctx: MutationCtx,
160
+ identity: { collection: string; docId: string; revision: number; version: number },
161
+ existing?: Awaited<ReturnType<typeof floorRow>>,
162
+ ): Promise<void> {
163
+ const floor = existing === undefined ? await floorRow(ctx, identity.collection, identity.docId) : existing
164
+ const revision = Math.max(floor?.revision ?? 0, identity.revision)
165
+ const version = Math.max(floor?.version ?? 0, identity.version)
166
+ if (!floor) await ctx.db.insert('documentFloors', { ...identity, revision, version })
167
+ else if (revision !== floor.revision || version !== floor.version) await ctx.db.patch(floor._id, { revision, version })
168
+ }
169
+
144
170
  /** Delete every row of a document's patch log and presence (delete / clear cascade). */
145
171
  async function deleteBookkeeping(ctx: MutationCtx, collection: string, docId: string): Promise<void> {
146
172
  const log = await ctx.db
@@ -176,6 +202,12 @@ const publishedReferrerValidator = v.object({
176
202
  id: v.string(),
177
203
  publishedVersion: v.number(),
178
204
  })
205
+ /** EC-668 — one draft referrer the delete's guard judged, at the revision it read. */
206
+ const draftReferrerValidator = v.object({
207
+ collection: v.string(),
208
+ id: v.string(),
209
+ revision: v.number(),
210
+ })
179
211
 
180
212
  interface WireRef {
181
213
  collection: string
@@ -225,6 +257,40 @@ async function unjudgedPublishedReferrers(
225
257
  return unjudged
226
258
  }
227
259
 
260
+ /**
261
+ * EC-668 (Codex F08 residual) — the documents whose working DRAFT references `collection:docId`
262
+ * that the delete's guard did not judge at their current revision — read from the reference
263
+ * index's DRAFT variant (`by_target`, written from the draft by the trigger in the same
264
+ * transaction as every `cmsDocuments` write), so a referrer saved after the guard read is seen
265
+ * here, and Convex OCC conflicts this read with that write. Schema-free (the index is), so the
266
+ * judged set includes soft referrers. `undefined` judged = not checked.
267
+ */
268
+ async function unjudgedDraftReferrers(
269
+ ctx: MutationCtx,
270
+ collection: string,
271
+ docId: string,
272
+ judged: readonly (WireRef & { revision: number })[] | undefined,
273
+ ): Promise<WireRef[]> {
274
+ if (!judged) return []
275
+ const targetKey = documentKey({ collection, id: docId })
276
+ const revisions = new Map(judged.map((referrer) => [documentKey(referrer), referrer.revision]))
277
+ const entries = await ctx.db
278
+ .query('referenceEntries')
279
+ .withIndex('by_target', (q) => q.eq('targetType', 'document').eq('targetId', targetKey).eq('variant', 'draft'))
280
+ .collect()
281
+ const unjudged: WireRef[] = []
282
+ for (const sourceId of new Set(entries.map((entry) => entry.sourceId))) {
283
+ if (sourceId === targetKey) continue
284
+ // A collection id never contains the separator (EC-578 F10), so the first one splits.
285
+ const at = sourceId.indexOf(DOCUMENT_KEY_SEPARATOR)
286
+ const source = { collection: sourceId.slice(0, at), id: sourceId.slice(at + 1) }
287
+ const row = await draftRow(ctx, source.collection, source.id)
288
+ if (!row) continue
289
+ if (revisions.get(sourceId) !== row.revision) unjudged.push(source)
290
+ }
291
+ return unjudged
292
+ }
293
+
228
294
  /**
229
295
  * The whole-document write (EC-595 binding point 2, EC-578): create (`ifAbsent`), a
230
296
  * singleton's first document (`ifCollectionEmpty`), or a replace at an exact revision —
@@ -260,7 +326,8 @@ export const replaceDocument = mutation({
260
326
  }
261
327
  }
262
328
  const draft = args.document as CmsDocument
263
- const revision = (existing?.revision ?? 0) + 1
329
+ // EC-669: a create starts above every revision a removed lifetime of this identity reached.
330
+ const revision = (existing?.revision ?? (await floorRow(ctx, args.collection, args.docId))?.revision ?? 0) + 1
264
331
  // EC-260: fold for storage, strictly before `ctx.db` (the trigger unfolds before
265
332
  // extracting references, so folding never hides one from the walker).
266
333
  const stored = foldDocument(draft)
@@ -497,7 +564,8 @@ export const listDocumentPatches = query({
497
564
  * revision the create recorded, so a colleague's later edit refuses it). Data-only (1e).
498
565
  * Returns `{ status: 'conflict', current }` when the precondition does not hold. EC-649: the
499
566
  * precondition's `publishedReferrers` (the ones the operation's strict guard judged) — a
500
- * published referrer not among them refuses the delete with `{ status: 'unjudged-referrers' }`.
567
+ * published referrer not among them refuses the delete with `{ status: 'unjudged-referrers' }`;
568
+ * EC-668: so does a draft referrer not among `draftReferrers` at its current revision.
501
569
  */
502
570
  export const deleteDocument = mutation({
503
571
  args: {
@@ -507,6 +575,8 @@ export const deleteDocument = mutation({
507
575
  v.object({
508
576
  revision: v.optional(v.number()),
509
577
  publishedReferrers: v.optional(v.array(publishedReferrerValidator)),
578
+ // EC-668: and the draft referrers it judged.
579
+ draftReferrers: v.optional(v.array(draftReferrerValidator)),
510
580
  }),
511
581
  ),
512
582
  },
@@ -517,14 +587,26 @@ export const deleteDocument = mutation({
517
587
  if (revision !== undefined && existing?.revision !== revision) {
518
588
  return { status: 'conflict' as const, current: existing ? withUnfoldedDocument(existing) : null }
519
589
  }
520
- const unjudged = await unjudgedPublishedReferrers(ctx, args.collection, args.docId, args.precondition?.publishedReferrers)
590
+ // EC-668 — INVARIANT: the delete's read set covers every referrer its guard judged — the
591
+ // published pins (EC-649) AND the drafts — in this transaction, before any write.
592
+ const unjudged = [
593
+ ...(await unjudgedPublishedReferrers(ctx, args.collection, args.docId, args.precondition?.publishedReferrers)),
594
+ ...(await unjudgedDraftReferrers(ctx, args.collection, args.docId, args.precondition?.draftReferrers)),
595
+ ]
521
596
  if (unjudged.length > 0) return { status: 'unjudged-referrers' as const, refs: unjudged }
522
- if (existing) await ctx.db.delete(existing._id)
523
597
  // Cascade the version history (EC-224), the patch log and presence (EC-595).
524
598
  const versions = await ctx.db
525
599
  .query('documentVersions')
526
600
  .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
527
601
  .collect()
602
+ // EC-669: the identity keeps the revision and version this lifetime reached.
603
+ await raiseFloor(ctx, {
604
+ collection: args.collection,
605
+ docId: args.docId,
606
+ revision: existing?.revision ?? 0,
607
+ version: Math.max(0, ...versions.map((row) => row.version)),
608
+ })
609
+ if (existing) await ctx.db.delete(existing._id)
528
610
  for (const row of versions) await ctx.db.delete(row._id)
529
611
  await deleteBookkeeping(ctx, args.collection, args.docId)
530
612
  // EC-286: the write-chokepoint trigger drops this document's index rows (and
@@ -537,14 +619,31 @@ export const deleteDocument = mutation({
537
619
  * EC-576: drop every document, version snapshot, patch-log row and presence heartbeat of
538
620
  * the deployment — the explicit drop a seeder runs before writing a known world. One
539
621
  * transaction (Convex bounds it at 8192 written documents — far above any v1
540
- * deployment; documented, not paginated). Requires `project.administer`.
622
+ * deployment; documented, not paginated). Requires `project.administer`. EC-669: every
623
+ * identity it removes keeps its floor (`documentFloors`, one row per identity — which this
624
+ * drop never clears), so a re-seeded document starts above every token its removed lifetime
625
+ * minted; the floor writes count toward the same bound.
541
626
  */
542
627
  export const clearDocuments = mutation({
543
628
  args: {},
544
629
  handler: async (ctx) => {
545
630
  await requireCapability(ctx, 'project.administer')
546
- for (const row of await ctx.db.query('cmsDocuments').collect()) await ctx.db.delete(row._id)
547
- for (const row of await ctx.db.query('documentVersions').collect()) await ctx.db.delete(row._id)
631
+ const documents = await ctx.db.query('cmsDocuments').collect()
632
+ const versions = await ctx.db.query('documentVersions').collect()
633
+ // EC-669: the highest revision and version each removed identity reached, raised in place.
634
+ const reached = new Map<string, { collection: string; docId: string; revision: number; version: number }>()
635
+ const reach = (row: { collection: string; docId: string }, field: 'revision' | 'version', value: number) => {
636
+ const key = documentKey({ collection: row.collection, id: row.docId })
637
+ const entry = reached.get(key) ?? { collection: row.collection, docId: row.docId, revision: 0, version: 0 }
638
+ entry[field] = Math.max(entry[field], value)
639
+ reached.set(key, entry)
640
+ }
641
+ for (const row of documents) reach(row, 'revision', row.revision)
642
+ for (const row of versions) reach(row, 'version', row.version)
643
+ const floors = new Map((await ctx.db.query('documentFloors').collect()).map((row) => [documentKey({ collection: row.collection, id: row.docId }), row]))
644
+ for (const [key, entry] of reached) await raiseFloor(ctx, entry, floors.get(key) ?? null)
645
+ for (const row of documents) await ctx.db.delete(row._id)
646
+ for (const row of versions) await ctx.db.delete(row._id)
548
647
  for (const row of await ctx.db.query('documentPatches').collect()) await ctx.db.delete(row._id)
549
648
  for (const row of await ctx.db.query('documentPresence').collect()) await ctx.db.delete(row._id)
550
649
  },
@@ -567,7 +666,8 @@ async function insertVersion(
567
666
  .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
568
667
  .order('desc')
569
668
  .first()
570
- const version = (latest?.version ?? 0) + 1
669
+ // EC-669: a lifetime's first version starts above every version a removed one recorded.
670
+ const version = (latest?.version ?? (await floorRow(ctx, args.collection, args.docId))?.version ?? 0) + 1
571
671
  const id = await ctx.db.insert('documentVersions', {
572
672
  collection: args.collection,
573
673
  docId: args.docId,
@@ -613,7 +713,10 @@ export const saveDocumentVersion = mutation({
613
713
  * `conflict` and the current row. The `cmsDocuments` trigger sees the pin move and
614
714
  * reindexes + revalidates (EC-286 / EC-146) — the version row it reads exists already.
615
715
  * EC-649: `strictTargets` (the ones the operation's guard judged published) must still be
616
- * published, else `{ status: 'strict-targets', refs }` and nothing is written.
716
+ * published, else `{ status: 'strict-targets', refs }` and nothing is written. EC-663: `patches`
717
+ * — the hidden-clear the operation computed — apply to the draft IN this transaction (a `patch`
718
+ * log row at +1, the publish at +2), so a refused publish clears nothing; a patch that does not
719
+ * apply refuses with `conflict`. The values are data: the store applies them, never judges them.
617
720
  */
618
721
  export const publishDocument = mutation({
619
722
  args: {
@@ -624,9 +727,12 @@ export const publishDocument = mutation({
624
727
  origin: v.optional(v.string()),
625
728
  at: v.string(),
626
729
  strictTargets: v.optional(v.array(documentRefValidator)),
730
+ // EC-663 (Codex F17): the hidden-clear the operation computed, applied in THIS transaction.
731
+ patches: v.optional(v.any()),
627
732
  },
628
733
  handler: async (ctx, args) => {
629
734
  const actor = await requireCapability(ctx, 'content.write') // EC-150
735
+ if (args.patches !== undefined) assertWithinWriteLimits(args.patches, 'patches') // EC-304 B-4
630
736
  const existing = await draftRow(ctx, args.collection, args.docId)
631
737
  if (!existing) return { status: 'missing' as const }
632
738
  if (existing.revision !== args.expectedRevision) {
@@ -634,7 +740,16 @@ export const publishDocument = mutation({
634
740
  }
635
741
  const unpublished = await unpublishedStrictTargets(ctx, args.strictTargets)
636
742
  if (unpublished.length > 0) return { status: 'strict-targets' as const, refs: unpublished }
637
- const draft = unfoldDocument(existing.document as CmsDocument)
743
+ // EC-663 (F17) — INVARIANT: a refused publish has no effect. The clear applies to the draft
744
+ // at the revision just checked (nothing since, so only an invalid patch can refuse — and then
745
+ // nothing is written); the version records the cleared draft, in one transaction.
746
+ const patches = (args.patches ?? []) as DocumentPatch[]
747
+ const clear =
748
+ patches.length > 0
749
+ ? applyDocumentPatches({ document: unfoldDocument(existing.document as CmsDocument), patches, since: [] })
750
+ : undefined
751
+ if (clear && clear.refused.length > 0) return { status: 'conflict' as const, current: withUnfoldedDocument(existing) }
752
+ const draft = clear ? clear.document : unfoldDocument(existing.document as CmsDocument)
638
753
  const version = await insertVersion(ctx, {
639
754
  collection: args.collection,
640
755
  docId: args.docId,
@@ -643,7 +758,8 @@ export const publishDocument = mutation({
643
758
  ...(args.origin ? { origin: args.origin } : {}),
644
759
  createdAt: args.at,
645
760
  }, 'publish')
646
- const revision = existing.revision + 1
761
+ // The clear is its own log row (+1, so per-path conflict detection sees it); the publish +2.
762
+ const revision = existing.revision + (clear ? 2 : 1)
647
763
  // EC-595 retention: compact the log behind the PREVIOUS publish (one cycle kept).
648
764
  const previousPublish = (await logAfter(ctx, args.collection, args.docId, 0))
649
765
  .reverse()
@@ -664,6 +780,8 @@ export const publishDocument = mutation({
664
780
  patchLogFloor = Math.max(patchLogFloor ?? 0, previousPublish.revision)
665
781
  }
666
782
  const patch = {
783
+ // EC-260: the cleared draft is structured (unfolded above), folded strictly before `ctx.db`.
784
+ ...(clear ? { document: foldDocument(clear.document) } : {}),
667
785
  revision,
668
786
  publishedVersion: version.version,
669
787
  // EC-447: SET-ONCE first-publish instant (server-side, in-transaction).
@@ -673,6 +791,13 @@ export const publishDocument = mutation({
673
791
  updatedAt: args.at,
674
792
  }
675
793
  await ctx.db.patch(existing._id, patch)
794
+ if (clear) {
795
+ await appendLog(
796
+ ctx,
797
+ { collection: args.collection, docId: args.docId, revision: existing.revision + 1, kind: 'patch', actor, at: args.at },
798
+ clear.applied,
799
+ )
800
+ }
676
801
  await appendLog(ctx, { collection: args.collection, docId: args.docId, revision, kind: 'publish', actor, at: args.at })
677
802
  return {
678
803
  status: 'ok' as const,
@@ -752,7 +877,8 @@ export const listAllDocumentVersions = query({
752
877
  *
753
878
  * Server-side trust (EC-565 1e, owner-ratified): the checks here are DATA-ONLY — revision,
754
879
  * pin integrity, authority, and (EC-649) the strict targets the operation judged are still
755
- * published (`{ status: 'strict-targets', refs }` otherwise). Schema validation stays the
880
+ * published (`{ status: 'strict-targets', refs }` otherwise) and (EC-663) the caller's
881
+ * `expectedRevision` (`{ status: 'conflict', current }` otherwise). Schema validation stays the
756
882
  * client gate (`documents.publish` runs it, with the hidden-clear, before `publishDocument`
757
883
  * records the row this pins).
758
884
  */
@@ -765,11 +891,18 @@ export const publishDocumentVersion = mutation({
765
891
  // EC-447: the candidate first-publish instant, written SET-ONCE below.
766
892
  publishedAt: v.string(),
767
893
  strictTargets: v.optional(v.array(documentRefValidator)),
894
+ // EC-663 (Codex F16): pin only at exactly this revision (a changeset's re-pin inverse).
895
+ expectedRevision: v.optional(v.number()),
768
896
  },
769
897
  handler: async (ctx, args) => {
770
898
  const actor = await requireCapability(ctx, 'content.write') // EC-150
771
899
  const doc = await draftRow(ctx, args.collection, args.docId)
772
900
  if (!doc) return { status: 'missing' as const }
901
+ // EC-663 — INVARIANT: a token is checked inside the write that spends it — a pin at a
902
+ // revision the document moved past (a colleague's publish since) refuses, nothing written.
903
+ if (args.expectedRevision !== undefined && doc.revision !== args.expectedRevision) {
904
+ return { status: 'conflict' as const, current: withUnfoldedDocument(doc) }
905
+ }
773
906
  const target = await versionRow(ctx, args.collection, args.docId, args.version)
774
907
  if (!target) return { status: 'missing-version' as const }
775
908
  if (!isPinnableVersion(target)) return { status: 'not-pinnable' as const }
@@ -799,7 +932,8 @@ export const publishDocumentVersion = mutation({
799
932
  /**
800
933
  * Clear the live pin (EC-224). EC-649: `publishedReferrers` — the published referrers the
801
934
  * operation's strict guard judged; a published referrer of this document not among them (at
802
- * its current pin) refuses the unpublish with `{ status: 'unjudged-referrers', refs }`.
935
+ * its current pin) refuses the unpublish with `{ status: 'unjudged-referrers', refs }`. EC-663:
936
+ * `expectedRevision` — refused with `{ status: 'conflict', current }` unless at exactly it.
803
937
  */
804
938
  export const unpublishDocument = mutation({
805
939
  args: {
@@ -807,11 +941,17 @@ export const unpublishDocument = mutation({
807
941
  docId: v.string(),
808
942
  updatedAt: v.string(),
809
943
  publishedReferrers: v.optional(v.array(publishedReferrerValidator)),
944
+ // EC-663 (Codex F16): unpublish only at exactly this revision (a changeset's inverse).
945
+ expectedRevision: v.optional(v.number()),
810
946
  },
811
947
  handler: async (ctx, args) => {
812
948
  const actor = await requireCapability(ctx, 'content.write') // EC-150
813
949
  const doc = await draftRow(ctx, args.collection, args.docId)
814
950
  if (!doc) return null
951
+ // EC-663 — INVARIANT: checked in this transaction, before the write, as the pin's.
952
+ if (args.expectedRevision !== undefined && doc.revision !== args.expectedRevision) {
953
+ return { status: 'conflict' as const, current: withUnfoldedDocument(doc) }
954
+ }
815
955
  const unjudged = await unjudgedPublishedReferrers(ctx, args.collection, args.docId, args.publishedReferrers)
816
956
  if (unjudged.length > 0) return { status: 'unjudged-referrers' as const, refs: unjudged }
817
957
  const revision = doc.revision + 1
@@ -0,0 +1,104 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import { makeFunctionReference } from 'convex/server'
4
+ import { foldEnvelope } from '@elytracms/core/cms-core'
5
+ import type { CollectionDef } from '@elytracms/core/cms-core'
6
+ // The host's reader, from its own module — the package index also loads the React delivery
7
+ // kit, which the edge-runtime environment cannot evaluate.
8
+ import { assembleLiveSource } from '../../next/src/host-content'
9
+ import schema from './schema'
10
+
11
+ /**
12
+ * EC-670 (Codex F19, `tasks/codex-review-2026-10-27-r3/backend.convex.test.ts` "F19") — a
13
+ * folded value whose stored payload does not parse, through the REAL `delivery:snapshot`
14
+ * (convex-test) and the REAL host loader (`assembleLiveSource`).
15
+ *
16
+ * Before: the codec handed the undecodable envelope back to its own walk, which recursed
17
+ * until the stack overflowed — the public snapshot REJECTED for every visitor (measured on
18
+ * the base: `Maximum call stack size exceeded`). Now the row is withheld with one located
19
+ * issue on the snapshot's `documentIssues` strand, which the host carries into its
20
+ * `sourceIssues` and onto the document's own read; every other row serves.
21
+ */
22
+
23
+ const modules = {
24
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
25
+ './_generated/api.ts': async () => ({}),
26
+ }
27
+ const snapshot = makeFunctionReference<'query'>('delivery:snapshot')
28
+ const draftSnapshot = makeFunctionReference<'query'>('delivery:draftSnapshot')
29
+
30
+ const AT = '2026-10-27T12:00:00.000Z'
31
+ const UNDECODABLE = { type: 'elytra/folded', v: 1, json: '{' }
32
+ const POST: CollectionDef = {
33
+ id: 'post',
34
+ kind: 'document',
35
+ fields: [
36
+ { name: 'title', type: 'text' },
37
+ { name: 'body', type: 'object', multiple: false, fields: [{ name: 'text', type: 'text' }] },
38
+ ],
39
+ }
40
+ const OPTIONS = { locales: { default: 'en', locales: ['en'] }, collections: [POST], routes: [] }
41
+
42
+ function env(): Record<string, string | undefined> {
43
+ return (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
44
+ }
45
+ afterEach(() => {
46
+ delete env()['DRAFT_PREVIEW_TOKEN']
47
+ })
48
+
49
+ /** Two published documents — `good` clean (its body folded, legitimately), `bad` holding the drift. */
50
+ async function deployment() {
51
+ const t = convexTest(schema, modules)
52
+ await t.run(async (ctx) => {
53
+ const publish = async (docId: string, values: Record<string, unknown>) => {
54
+ await ctx.db.insert('cmsDocuments', { collection: 'post', docId, variant: 'draft', revision: 1, document: { collection: 'post', id: docId, values }, publishedVersion: 1, createdAt: AT, updatedAt: AT })
55
+ await ctx.db.insert('documentVersions', { collection: 'post', docId, version: 1, snapshot: { collection: 'post', id: docId, values }, kind: 'publish', createdAt: AT })
56
+ }
57
+ await publish('good', { title: 'GOOD', body: foldEnvelope({ text: 'deep enough to fold' }) })
58
+ await publish('bad', { title: 'BAD', body: UNDECODABLE })
59
+ })
60
+ return t
61
+ }
62
+
63
+ describe('EC-670 — one undecodable folded value in a pinned snapshot', () => {
64
+ it('withholds THAT row with a located issue and serves every other row (was: the whole snapshot rejected)', async () => {
65
+ const t = await deployment()
66
+ // Positive observation control: the drifted row really is stored and pinned.
67
+ const stored = await t.run((ctx) => ctx.db.query('documentVersions').collect())
68
+ expect(stored.map((row) => row.docId).sort()).toEqual(['bad', 'good'])
69
+
70
+ const payload = await t.query(snapshot, { projectId: 'site' })
71
+ // Control: the clean row serves, its legitimately folded body unfolded.
72
+ expect(payload.documents).toEqual([
73
+ { collection: 'post', id: 'good', values: { title: 'GOOD', body: { text: 'deep enough to fold' } } },
74
+ ])
75
+ // F19, inverted: was `rejects.toThrow(/call stack/)`.
76
+ expect(payload.documentIssues).toEqual([
77
+ expect.objectContaining({
78
+ code: 'invalid-stored-document',
79
+ path: ['documents', 'post:bad', 'values', 'body'],
80
+ collectionId: 'post',
81
+ documentId: 'bad',
82
+ }),
83
+ ])
84
+ // Public strand: identity and path, never a value of the withheld row.
85
+ expect(JSON.stringify(payload.documentIssues)).not.toContain('BAD')
86
+
87
+ // The host carries the strand into its source and onto the document's own read.
88
+ const client = assembleLiveSource(payload, OPTIONS, 'published').client({ perspective: 'published' })
89
+ expect(client.sourceIssues.map((issue) => [issue.code, issue.documentId])).toEqual([
90
+ ['invalid-stored-document', 'bad'],
91
+ ])
92
+ expect(client.getDocument('post', 'bad').document).toBeNull()
93
+ expect(client.getDocument('post', 'bad').issues.map((issue) => issue.code)).toEqual(['invalid-stored-document'])
94
+ expect(client.getDocument('post', 'good').document).not.toBeNull()
95
+ })
96
+
97
+ it('the draft snapshot withholds a draft row the same way', async () => {
98
+ const t = await deployment()
99
+ env()['DRAFT_PREVIEW_TOKEN'] = 'preview-token-670'
100
+ const payload = await t.query(draftSnapshot, { projectId: 'site', token: 'preview-token-670' })
101
+ expect(payload.documents.map((doc: { id: string }) => doc.id)).toEqual(['good'])
102
+ expect(payload.documentIssues.map((issue: { documentId: string }) => issue.documentId)).toEqual(['bad'])
103
+ })
104
+ })
@@ -1,6 +1,6 @@
1
1
  import { v } from 'convex/values'
2
- import { DRAFT_PERSPECTIVE, DRAFT_VARIANT, readLiveRedirects, unfoldValue, variantOf } from '@elytracms/core/cms-core'
3
- import type { Perspective } from '@elytracms/core/cms-core'
2
+ import { DRAFT_PERSPECTIVE, DRAFT_VARIANT, readLiveRedirects, unfoldForDelivery, variantOf } from '@elytracms/core/cms-core'
3
+ import type { CmsValidationIssue, Perspective } from '@elytracms/core/cms-core'
4
4
  import { assetRecordFromRow, deliveryAssetRecord } from '@elytracms/core/persistence'
5
5
  import { constantTimeEquals } from '@elytracms/core/revalidation'
6
6
  import type { AssetRecord } from '@elytracms/core/persistence'
@@ -67,6 +67,12 @@ interface DeliverySnapshot {
67
67
  * a variant — that variant's rows only.
68
68
  */
69
69
  documents: unknown[]
70
+ /**
71
+ * EC-670 — one `invalid-stored-document` issue per stored row the snapshot withheld because
72
+ * it holds a folded value whose payload does not parse (identity + field paths — never a
73
+ * value). Every other row still serves; the host carries these into its source issues.
74
+ */
75
+ documentIssues: unknown[]
70
76
  /** Asset metadata in the delivery `AssetRecord` shape (with serve URLs). */
71
77
  assets: unknown[]
72
78
  /**
@@ -123,8 +129,19 @@ export async function loadSnapshot(
123
129
  const documentRows = await ctx.db.query('cmsDocuments').collect()
124
130
  // EC-260: the stored `document`/`snapshot` blobs are folded; unfold every one
125
131
  // before it enters the delivery snapshot so the host receives STRUCTURED content
126
- // (no client ever sees an envelope). `unfoldValue` is identity on legacy data.
127
- const unfoldDraft = (row: { document: unknown }): unknown => unfoldValue(row.document)
132
+ // (no client ever sees an envelope). Unfolding is identity on legacy data.
133
+ //
134
+ // EC-670 (Codex F19): a blob holding a folded value whose payload does not parse is
135
+ // WITHHELD with a located issue on `documentIssues` — never served opaque as content,
136
+ // and never the whole snapshot (one such value used to overflow the stack and reject
137
+ // the query for every visitor of the site).
138
+ const documents: unknown[] = []
139
+ const documentIssues: CmsValidationIssue[] = []
140
+ const deliver = (stored: unknown, row: { collection: string; docId: string }): void => {
141
+ const read = unfoldForDelivery(stored, { collection: row.collection, id: row.docId })
142
+ if (read.ok) documents.push(read.document)
143
+ else documentIssues.push(read.issue)
144
+ }
128
145
  // THE published-content invariant (the trust boundary): the public snapshot
129
146
  // ships ONLY published content — never a draft. Under the EC-224 pointer model
130
147
  // a published row is served as its *pinned version snapshot* (decoupled from
@@ -138,11 +155,9 @@ export async function loadSnapshot(
138
155
  // draft variant's row — `cms.ts` `listPublishedDocuments` reads it the same way — so a
139
156
  // variant row copied with its pin can never serve the pinned snapshot twice.
140
157
  const variant = variantOf(perspective)
141
- let documents: unknown[]
142
158
  if (variant !== undefined) {
143
- documents = documentRows.filter((row) => row.variant === variant).map(unfoldDraft)
159
+ for (const row of documentRows) if (row.variant === variant) deliver(row.document, row)
144
160
  } else {
145
- documents = []
146
161
  for (const row of documentRows) {
147
162
  if (row.variant !== DRAFT_VARIANT || row.publishedVersion === undefined) continue
148
163
  // EC-308: read THIS document's pinned version directly via `by_doc_version`
@@ -158,7 +173,7 @@ export async function loadSnapshot(
158
173
  q.eq('collection', row.collection).eq('docId', row.docId).eq('version', version),
159
174
  )
160
175
  .unique()
161
- if (pinned) documents.push(unfoldValue(pinned.snapshot))
176
+ if (pinned) deliver(pinned.snapshot, row)
162
177
  }
163
178
  }
164
179
 
@@ -178,6 +193,7 @@ export async function loadSnapshot(
178
193
  projectId,
179
194
  perspective,
180
195
  documents,
196
+ documentIssues,
181
197
  assets,
182
198
  redirects: live.redirects,
183
199
  redirectIssues: live.issues,