@elytracms/studio 0.0.95 → 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 (104) hide show
  1. package/convex/cms.ts +272 -16
  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/convex/strict-reference-race.test.ts +139 -0
  15. package/dist/components/ai/ai-chat-panel.js +15 -2
  16. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  17. package/dist/components/ai/translate-document-action.d.ts +6 -0
  18. package/dist/components/ai/translate-document-action.js.map +1 -1
  19. package/dist/components/ai/translate-document-dialog.d.ts +1 -1
  20. package/dist/components/ai/translate-document-dialog.js +5 -5
  21. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  22. package/dist/components/assets/asset-detail.js +5 -2
  23. package/dist/components/assets/asset-detail.js.map +1 -1
  24. package/dist/components/assets/asset-locale.d.ts +7 -0
  25. package/dist/components/assets/asset-locale.js +16 -2
  26. package/dist/components/assets/asset-locale.js.map +1 -1
  27. package/dist/components/assets/asset-meta-drift-notice.d.ts +13 -0
  28. package/dist/components/assets/asset-meta-drift-notice.js +22 -0
  29. package/dist/components/assets/asset-meta-drift-notice.js.map +1 -0
  30. package/dist/components/assets/asset-meta-label.d.ts +2 -1
  31. package/dist/components/assets/asset-meta-label.js +4 -2
  32. package/dist/components/assets/asset-meta-label.js.map +1 -1
  33. package/dist/components/assets/asset-metadata.d.ts +33 -13
  34. package/dist/components/assets/asset-metadata.js +46 -22
  35. package/dist/components/assets/asset-metadata.js.map +1 -1
  36. package/dist/components/assets/asset-search.d.ts +6 -2
  37. package/dist/components/assets/asset-search.js +15 -5
  38. package/dist/components/assets/asset-search.js.map +1 -1
  39. package/dist/components/assets/assets-collection-view-source.d.ts +5 -3
  40. package/dist/components/assets/assets-collection-view-source.js +7 -5
  41. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  42. package/dist/components/builder/canvas-rebase.d.ts +36 -12
  43. package/dist/components/builder/canvas-rebase.js +13 -3
  44. package/dist/components/builder/canvas-rebase.js.map +1 -1
  45. package/dist/components/builder/canvas-render-context.js +5 -4
  46. package/dist/components/builder/canvas-render-context.js.map +1 -1
  47. package/dist/components/builder/composition-field-editor.d.ts +12 -6
  48. package/dist/components/builder/composition-field-editor.js +6 -5
  49. package/dist/components/builder/composition-field-editor.js.map +1 -1
  50. package/dist/components/cms/asset-picker-core.d.ts +2 -2
  51. package/dist/components/cms/asset-picker-core.js +2 -2
  52. package/dist/components/cms/asset-picker-core.js.map +1 -1
  53. package/dist/components/cms/asset-picker.js +3 -1
  54. package/dist/components/cms/asset-picker.js.map +1 -1
  55. package/dist/components/cms/canvas-commit.d.ts +6 -0
  56. package/dist/components/cms/canvas-commit.js +5 -1
  57. package/dist/components/cms/canvas-commit.js.map +1 -1
  58. package/dist/components/cms/cms-store.d.ts +24 -8
  59. package/dist/components/cms/cms-store.js +21 -14
  60. package/dist/components/cms/cms-store.js.map +1 -1
  61. package/dist/components/cms/collection-table.js +4 -11
  62. package/dist/components/cms/collection-table.js.map +1 -1
  63. package/dist/components/cms/detail-editor.js +2 -2
  64. package/dist/components/cms/detail-editor.js.map +1 -1
  65. package/dist/components/cms/draft-commit.d.ts +16 -3
  66. package/dist/components/cms/draft-commit.js +3 -2
  67. package/dist/components/cms/draft-commit.js.map +1 -1
  68. package/dist/components/cms/use-document-draft.d.ts +107 -10
  69. package/dist/components/cms/use-document-draft.js +157 -42
  70. package/dist/components/cms/use-document-draft.js.map +1 -1
  71. package/dist/components/collection-view/cms-collection-view-source.js +6 -4
  72. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  73. package/dist/components/components/transfer-dialog.js +6 -4
  74. package/dist/components/components/transfer-dialog.js.map +1 -1
  75. package/dist/lib/ai/chat-state.d.ts +7 -0
  76. package/dist/lib/ai/chat-state.js.map +1 -1
  77. package/dist/lib/ai/tools.d.ts +8 -0
  78. package/dist/lib/ai/tools.js +4 -0
  79. package/dist/lib/ai/tools.js.map +1 -1
  80. package/dist/lib/cms-views/view-model.d.ts +15 -8
  81. package/dist/lib/cms-views/view-model.js +19 -5
  82. package/dist/lib/cms-views/view-model.js.map +1 -1
  83. package/dist/lib/collection-view/__fixtures__/document-writes.js +1 -0
  84. package/dist/lib/collection-view/__fixtures__/document-writes.js.map +1 -1
  85. package/dist/lib/collection-view/collection-view-source.d.ts +18 -7
  86. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  87. package/dist/lib/data/collection-content.js +5 -0
  88. package/dist/lib/data/collection-content.js.map +1 -1
  89. package/dist/lib/engine-version.d.ts +1 -1
  90. package/dist/lib/engine-version.js +1 -1
  91. package/dist/lib/engine-version.js.map +1 -1
  92. package/dist/lib/i18n/messages.de.js +15 -1
  93. package/dist/lib/i18n/messages.de.js.map +1 -1
  94. package/dist/lib/i18n/messages.en.d.ts +13 -0
  95. package/dist/lib/i18n/messages.en.js +16 -1
  96. package/dist/lib/i18n/messages.en.js.map +1 -1
  97. package/dist/lib/operations/cms-persistence.d.ts +119 -35
  98. package/dist/lib/operations/cms-persistence.js +441 -91
  99. package/dist/lib/operations/cms-persistence.js.map +1 -1
  100. package/dist/lib/persistence/convex-adapter.js +80 -8
  101. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  102. package/dist/lib/persona/labels.js +2 -0
  103. package/dist/lib/persona/labels.js.map +1 -1
  104. package/package.json +4 -3
package/convex/cms.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { v } from 'convex/values'
2
2
  import {
3
+ DOCUMENT_KEY_SEPARATOR,
3
4
  DRAFT_VARIANT,
4
5
  applyDocumentPatches,
6
+ documentKey,
5
7
  documentsSemanticEqual,
6
8
  foldDocument,
7
9
  foldPatchesForStorage,
@@ -26,10 +28,13 @@ import { assertWithinWriteLimits } from './write-limits'
26
28
  *
27
29
  * Owner, verbatim: "yes keep validation client-side. server-side validation enforces
28
30
  * migrations or could lead to invalid/lost data (the payload-sql-approach). schema should
29
- * be the lens and the mask to put on the data." So these handlers enforce exactly three
30
- * things, all data-only: **the revision** (every write and publish carries the one it was
31
- * made against; a moved document refuses with `conflict`), **pin integrity** (a pinned
32
- * version exists and belongs to this document) and **authority** (`requireCapability`).
31
+ * be the lens and the mask to put on the data." So these handlers enforce only data-only
32
+ * facts: **the revision** (every write and publish carries the one it was made against; a
33
+ * moved document refuses with `conflict`), **pin integrity** (a pinned version exists and
34
+ * belongs to this document), **authority** (`requireCapability`) and — EC-649 — the
35
+ * **strict-reference facts the operation's guard judged** (a publish's strict targets are
36
+ * still published; an unpublish / delete target has no published referrer the guard did not
37
+ * judge): the operation decides what is strict, these handlers only re-check that data.
33
38
  * They never validate a document against a schema and never canonicalise a value — the
34
39
  * client gate does both (a `content.write` member bypassing the studio can publish an
35
40
  * invalid document; delivery parse raises it visibly, AD-14).
@@ -136,6 +141,32 @@ async function logAfter(ctx: Ctx, collection: string, docId: string, afterRevisi
136
141
  }))
137
142
  }
138
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
+
139
170
  /** Delete every row of a document's patch log and presence (delete / clear cascade). */
140
171
  async function deleteBookkeeping(ctx: MutationCtx, collection: string, docId: string): Promise<void> {
141
172
  const log = await ctx.db
@@ -156,6 +187,110 @@ const preconditionValidator = v.union(
156
187
  v.object({ ifCollectionEmpty: v.literal(true) }),
157
188
  )
158
189
 
190
+ // --- EC-649: strict-reference integrity as data preconditions (Codex F08) -------------
191
+ //
192
+ // The operation judges strictness (it holds the schema); these handlers hold none (EC-565
193
+ // 1e). They re-check, inside their own transaction, only the DATA the operation's guard
194
+ // judged: a publish / pin's strict targets are still published; an unpublish / delete's
195
+ // target has no published referrer the guard did not judge at its current pin. Convex OCC
196
+ // makes the check and the write one serializable step: a concurrent publish of a referrer
197
+ // writes the very `referenceEntries` range (by_target) and `cmsDocuments` rows read here.
198
+
199
+ const documentRefValidator = v.object({ collection: v.string(), id: v.string() })
200
+ const publishedReferrerValidator = v.object({
201
+ collection: v.string(),
202
+ id: v.string(),
203
+ publishedVersion: v.number(),
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
+ })
211
+
212
+ interface WireRef {
213
+ collection: string
214
+ id: string
215
+ }
216
+
217
+ /** The strict targets that are gone or carry no pin (a publish / pin refuses on any). */
218
+ async function unpublishedStrictTargets(ctx: MutationCtx, targets: readonly WireRef[] | undefined): Promise<WireRef[]> {
219
+ const unpublished: WireRef[] = []
220
+ for (const target of targets ?? []) {
221
+ const row = await draftRow(ctx, target.collection, target.id)
222
+ if (row?.publishedVersion === undefined) unpublished.push({ collection: target.collection, id: target.id })
223
+ }
224
+ return unpublished
225
+ }
226
+
227
+ /**
228
+ * The published referrers of `collection:docId` the guard did not judge at their current pin —
229
+ * read from the reference index's PUBLISHED variant (`by_target`, written from the pinned
230
+ * snapshot in the same transaction as every pin move — EC-286 / EC-589), so a referrer published
231
+ * after the guard read is seen here. `undefined` judged = not checked (a caller without the
232
+ * precondition).
233
+ */
234
+ async function unjudgedPublishedReferrers(
235
+ ctx: MutationCtx,
236
+ collection: string,
237
+ docId: string,
238
+ judged: readonly (WireRef & { publishedVersion: number })[] | undefined,
239
+ ): Promise<WireRef[]> {
240
+ if (!judged) return []
241
+ const targetKey = documentKey({ collection, id: docId })
242
+ const pins = new Map(judged.map((referrer) => [documentKey(referrer), referrer.publishedVersion]))
243
+ const entries = await ctx.db
244
+ .query('referenceEntries')
245
+ .withIndex('by_target', (q) => q.eq('targetType', 'document').eq('targetId', targetKey).eq('variant', 'published'))
246
+ .collect()
247
+ const unjudged: WireRef[] = []
248
+ for (const sourceId of new Set(entries.map((entry) => entry.sourceId))) {
249
+ if (sourceId === targetKey) continue
250
+ // A collection id never contains the separator (EC-578 F10), so the first one splits.
251
+ const at = sourceId.indexOf(DOCUMENT_KEY_SEPARATOR)
252
+ const source = { collection: sourceId.slice(0, at), id: sourceId.slice(at + 1) }
253
+ const row = await draftRow(ctx, source.collection, source.id)
254
+ if (row?.publishedVersion === undefined) continue
255
+ if (pins.get(sourceId) !== row.publishedVersion) unjudged.push(source)
256
+ }
257
+ return unjudged
258
+ }
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
+
159
294
  /**
160
295
  * The whole-document write (EC-595 binding point 2, EC-578): create (`ifAbsent`), a
161
296
  * singleton's first document (`ifCollectionEmpty`), or a replace at an exact revision —
@@ -191,7 +326,8 @@ export const replaceDocument = mutation({
191
326
  }
192
327
  }
193
328
  const draft = args.document as CmsDocument
194
- 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
195
331
  // EC-260: fold for storage, strictly before `ctx.db` (the trigger unfolds before
196
332
  // extracting references, so folding never hides one from the walker).
197
333
  const stored = foldDocument(draft)
@@ -421,21 +557,61 @@ export const listDocumentPatches = query({
421
557
  },
422
558
  })
423
559
 
560
+ /**
561
+ * Delete a document with its version history, patch log and presence. EC-648: an optional
562
+ * `precondition: { revision }` — the delete happens only while the draft is at exactly that
563
+ * revision, checked in this transaction (a changeset reverting its own create carries the
564
+ * revision the create recorded, so a colleague's later edit refuses it). Data-only (1e).
565
+ * Returns `{ status: 'conflict', current }` when the precondition does not hold. EC-649: the
566
+ * precondition's `publishedReferrers` (the ones the operation's strict guard judged) — a
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.
569
+ */
424
570
  export const deleteDocument = mutation({
425
- args: { collection: v.string(), docId: v.string() },
571
+ args: {
572
+ collection: v.string(),
573
+ docId: v.string(),
574
+ precondition: v.optional(
575
+ v.object({
576
+ revision: v.optional(v.number()),
577
+ publishedReferrers: v.optional(v.array(publishedReferrerValidator)),
578
+ // EC-668: and the draft referrers it judged.
579
+ draftReferrers: v.optional(v.array(draftReferrerValidator)),
580
+ }),
581
+ ),
582
+ },
426
583
  handler: async (ctx, args) => {
427
584
  await requireCapability(ctx, 'content.write') // EC-150
428
585
  const existing = await draftRow(ctx, args.collection, args.docId)
429
- if (existing) await ctx.db.delete(existing._id)
586
+ const revision = args.precondition?.revision
587
+ if (revision !== undefined && existing?.revision !== revision) {
588
+ return { status: 'conflict' as const, current: existing ? withUnfoldedDocument(existing) : null }
589
+ }
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
+ ]
596
+ if (unjudged.length > 0) return { status: 'unjudged-referrers' as const, refs: unjudged }
430
597
  // Cascade the version history (EC-224), the patch log and presence (EC-595).
431
598
  const versions = await ctx.db
432
599
  .query('documentVersions')
433
600
  .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
434
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)
435
610
  for (const row of versions) await ctx.db.delete(row._id)
436
611
  await deleteBookkeeping(ctx, args.collection, args.docId)
437
612
  // EC-286: the write-chokepoint trigger drops this document's index rows (and
438
613
  // revalidates if it was published) when the `cmsDocuments` delete commits.
614
+ return { status: 'ok' as const }
439
615
  },
440
616
  })
441
617
 
@@ -443,14 +619,31 @@ export const deleteDocument = mutation({
443
619
  * EC-576: drop every document, version snapshot, patch-log row and presence heartbeat of
444
620
  * the deployment — the explicit drop a seeder runs before writing a known world. One
445
621
  * transaction (Convex bounds it at 8192 written documents — far above any v1
446
- * 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.
447
626
  */
448
627
  export const clearDocuments = mutation({
449
628
  args: {},
450
629
  handler: async (ctx) => {
451
630
  await requireCapability(ctx, 'project.administer')
452
- for (const row of await ctx.db.query('cmsDocuments').collect()) await ctx.db.delete(row._id)
453
- 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)
454
647
  for (const row of await ctx.db.query('documentPatches').collect()) await ctx.db.delete(row._id)
455
648
  for (const row of await ctx.db.query('documentPresence').collect()) await ctx.db.delete(row._id)
456
649
  },
@@ -473,7 +666,8 @@ async function insertVersion(
473
666
  .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
474
667
  .order('desc')
475
668
  .first()
476
- 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
477
671
  const id = await ctx.db.insert('documentVersions', {
478
672
  collection: args.collection,
479
673
  docId: args.docId,
@@ -518,6 +712,11 @@ export const saveDocumentVersion = mutation({
518
712
  * bump the revision and log the publish — all in this transaction; otherwise refuse with
519
713
  * `conflict` and the current row. The `cmsDocuments` trigger sees the pin move and
520
714
  * reindexes + revalidates (EC-286 / EC-146) — the version row it reads exists already.
715
+ * EC-649: `strictTargets` (the ones the operation's guard judged published) must still be
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.
521
720
  */
522
721
  export const publishDocument = mutation({
523
722
  args: {
@@ -527,15 +726,30 @@ export const publishDocument = mutation({
527
726
  label: v.optional(v.string()),
528
727
  origin: v.optional(v.string()),
529
728
  at: v.string(),
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()),
530
732
  },
531
733
  handler: async (ctx, args) => {
532
734
  const actor = await requireCapability(ctx, 'content.write') // EC-150
735
+ if (args.patches !== undefined) assertWithinWriteLimits(args.patches, 'patches') // EC-304 B-4
533
736
  const existing = await draftRow(ctx, args.collection, args.docId)
534
737
  if (!existing) return { status: 'missing' as const }
535
738
  if (existing.revision !== args.expectedRevision) {
536
739
  return { status: 'conflict' as const, current: withUnfoldedDocument(existing) }
537
740
  }
538
- const draft = unfoldDocument(existing.document as CmsDocument)
741
+ const unpublished = await unpublishedStrictTargets(ctx, args.strictTargets)
742
+ if (unpublished.length > 0) return { status: 'strict-targets' as const, refs: unpublished }
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)
539
753
  const version = await insertVersion(ctx, {
540
754
  collection: args.collection,
541
755
  docId: args.docId,
@@ -544,7 +758,8 @@ export const publishDocument = mutation({
544
758
  ...(args.origin ? { origin: args.origin } : {}),
545
759
  createdAt: args.at,
546
760
  }, 'publish')
547
- 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)
548
763
  // EC-595 retention: compact the log behind the PREVIOUS publish (one cycle kept).
549
764
  const previousPublish = (await logAfter(ctx, args.collection, args.docId, 0))
550
765
  .reverse()
@@ -565,6 +780,8 @@ export const publishDocument = mutation({
565
780
  patchLogFloor = Math.max(patchLogFloor ?? 0, previousPublish.revision)
566
781
  }
567
782
  const patch = {
783
+ // EC-260: the cleared draft is structured (unfolded above), folded strictly before `ctx.db`.
784
+ ...(clear ? { document: foldDocument(clear.document) } : {}),
568
785
  revision,
569
786
  publishedVersion: version.version,
570
787
  // EC-447: SET-ONCE first-publish instant (server-side, in-transaction).
@@ -574,6 +791,13 @@ export const publishDocument = mutation({
574
791
  updatedAt: args.at,
575
792
  }
576
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
+ }
577
801
  await appendLog(ctx, { collection: args.collection, docId: args.docId, revision, kind: 'publish', actor, at: args.at })
578
802
  return {
579
803
  status: 'ok' as const,
@@ -652,8 +876,11 @@ export const listAllDocumentVersions = query({
652
876
  * draft reads `true` — Codex P05's sibling), the revision bumps, a `pin` row is logged.
653
877
  *
654
878
  * Server-side trust (EC-565 1e, owner-ratified): the checks here are DATA-ONLY — revision,
655
- * pin integrity, authority. Schema validation stays the client gate (`documents.publish`
656
- * runs it, with the hidden-clear, before `publishDocument` records the row this pins).
879
+ * pin integrity, authority, and (EC-649) the strict targets the operation judged are still
880
+ * published (`{ status: 'strict-targets', refs }` otherwise) and (EC-663) the caller's
881
+ * `expectedRevision` (`{ status: 'conflict', current }` otherwise). Schema validation stays the
882
+ * client gate (`documents.publish` runs it, with the hidden-clear, before `publishDocument`
883
+ * records the row this pins).
657
884
  */
658
885
  export const publishDocumentVersion = mutation({
659
886
  args: {
@@ -663,14 +890,24 @@ export const publishDocumentVersion = mutation({
663
890
  updatedAt: v.string(),
664
891
  // EC-447: the candidate first-publish instant, written SET-ONCE below.
665
892
  publishedAt: v.string(),
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()),
666
896
  },
667
897
  handler: async (ctx, args) => {
668
898
  const actor = await requireCapability(ctx, 'content.write') // EC-150
669
899
  const doc = await draftRow(ctx, args.collection, args.docId)
670
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
+ }
671
906
  const target = await versionRow(ctx, args.collection, args.docId, args.version)
672
907
  if (!target) return { status: 'missing-version' as const }
673
908
  if (!isPinnableVersion(target)) return { status: 'not-pinnable' as const }
909
+ const unpublished = await unpublishedStrictTargets(ctx, args.strictTargets)
910
+ if (unpublished.length > 0) return { status: 'strict-targets' as const, refs: unpublished }
674
911
  const revision = doc.revision + 1
675
912
  const patch = {
676
913
  revision,
@@ -692,12 +929,31 @@ export const publishDocumentVersion = mutation({
692
929
  },
693
930
  })
694
931
 
932
+ /**
933
+ * Clear the live pin (EC-224). EC-649: `publishedReferrers` — the published referrers the
934
+ * operation's strict guard judged; a published referrer of this document not among them (at
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.
937
+ */
695
938
  export const unpublishDocument = mutation({
696
- args: { collection: v.string(), docId: v.string(), updatedAt: v.string() },
939
+ args: {
940
+ collection: v.string(),
941
+ docId: v.string(),
942
+ updatedAt: v.string(),
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()),
946
+ },
697
947
  handler: async (ctx, args) => {
698
948
  const actor = await requireCapability(ctx, 'content.write') // EC-150
699
949
  const doc = await draftRow(ctx, args.collection, args.docId)
700
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
+ }
955
+ const unjudged = await unjudgedPublishedReferrers(ctx, args.collection, args.docId, args.publishedReferrers)
956
+ if (unjudged.length > 0) return { status: 'unjudged-referrers' as const, refs: unjudged }
701
957
  const revision = doc.revision + 1
702
958
  // Convex patch removes a field set to `undefined` — clears the live pin. EC-286: the
703
959
  // trigger drops the published index rows and revalidates. EC-447/EC-449: the
@@ -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,