@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.
- package/convex/assets.ts +16 -2
- package/convex/cms.ts +153 -13
- package/convex/delivery-folded-drift.test.ts +104 -0
- package/convex/delivery.ts +24 -8
- package/convex/depth-codec-lookalike.test.ts +192 -0
- package/convex/fixture-accounts.test.ts +305 -0
- package/convex/fixtureAccounts.ts +298 -0
- package/convex/incarnation.test.ts +149 -0
- package/convex/ingest-attempt.test.ts +161 -0
- package/convex/ingest.test.ts +327 -9
- package/convex/ingest.ts +242 -24
- package/convex/prototype-key.test.ts +92 -0
- package/convex/receipt-races.test.ts +184 -0
- package/convex/records-zod-copy.test.ts +76 -0
- package/convex/records-zod-refinements.convex.test.ts +94 -0
- package/convex/schema.ts +20 -1
- package/convex/strict-delete.test.ts +129 -0
- package/dist/components/cms/cms-store.d.ts +6 -5
- package/dist/components/cms/cms-store.js +14 -34
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/field-control.d.ts +12 -1
- package/dist/components/cms/field-control.js +16 -1
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/import-batch.d.ts +23 -0
- package/dist/components/cms/import-batch.js +43 -0
- package/dist/components/cms/import-batch.js.map +1 -0
- package/dist/components/cms/rich-text/editor.js +13 -10
- package/dist/components/cms/rich-text/editor.js.map +1 -1
- package/dist/components/cms/rich-text/value.d.ts +9 -0
- package/dist/components/cms/rich-text/value.js +13 -0
- package/dist/components/cms/rich-text/value.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +76 -4
- package/dist/components/cms/use-document-draft.js +94 -25
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/records/record-lens.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +5 -2
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/engine-version.d.ts +1 -1
- package/dist/lib/engine-version.js +1 -1
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +5 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +5 -0
- package/dist/lib/i18n/messages.en.js +5 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +49 -5
- package/dist/lib/operations/cms-persistence.js +237 -47
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +27 -1
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persona/labels.js +2 -0
- package/dist/lib/persona/labels.js.map +1 -1
- package/package.json +5 -3
package/convex/assets.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { liftLegacyAlt as legacyAltLift, rejectedWriteMessage, resolveAssetWrite
|
|
|
5
5
|
import type { DataModel } from './functions'
|
|
6
6
|
import { internalMutation, mutation, query } from './functions'
|
|
7
7
|
import { requireCapability } from './guard'
|
|
8
|
+
import { ingestBindingHolds } from './ingest'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Asset metadata persistence (EC-044) + byte storage (EC-151). Bytes live in
|
|
@@ -210,15 +211,28 @@ export const getServeUrl = query({
|
|
|
210
211
|
},
|
|
211
212
|
})
|
|
212
213
|
|
|
213
|
-
/**
|
|
214
|
+
/**
|
|
215
|
+
* Delete stored bytes. Metadata removal is a separate `assets:remove` call.
|
|
216
|
+
*
|
|
217
|
+
* EC-685 — unless an ingest binding holds them: an editor may create an asset over a form
|
|
218
|
+
* submission's uploaded file (`upsert` allows it — promoting an upload is a legitimate edit),
|
|
219
|
+
* and deleting that asset must not take the submission's file with it. Such bytes are KEPT,
|
|
220
|
+
* read in this transaction, and the call returns normally (`{ deleted: false, heldBy:
|
|
221
|
+
* 'ingest' }`): the studio deletes an asset's bytes BEFORE its row (`deleteAsset`), so a
|
|
222
|
+
* refusal would leave the asset undeletable, while keeping them is the correct ownership —
|
|
223
|
+
* the asset row goes, the submission keeps its file, and removing the submission deletes it
|
|
224
|
+
* (`releaseIngestUploads`, the mirror check).
|
|
225
|
+
*/
|
|
214
226
|
export const deleteBlob = mutation({
|
|
215
227
|
args: { storageKey: v.string() },
|
|
216
|
-
handler: async (ctx, { storageKey }) => {
|
|
228
|
+
handler: async (ctx, { storageKey }): Promise<{ deleted: true } | { deleted: false; heldBy: 'ingest' }> => {
|
|
217
229
|
// EC-150 / EC-304: destructive — gate at `editor`, matching `assets:remove`.
|
|
218
230
|
// Under the old `requireUserId` an authenticated non-member could delete the
|
|
219
231
|
// bytes of any published asset (storageKeys are harvestable from the public
|
|
220
232
|
// `delivery:snapshot`), knocking every image off the live site.
|
|
221
233
|
await requireCapability(ctx, 'content.write')
|
|
234
|
+
if (await ingestBindingHolds(ctx, storageKey)) return { deleted: false, heldBy: 'ingest' }
|
|
222
235
|
await ctx.storage.delete(asStorageId(storageKey))
|
|
236
|
+
return { deleted: true }
|
|
223
237
|
},
|
|
224
238
|
})
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
+
})
|
package/convex/delivery.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { v } from 'convex/values'
|
|
2
|
-
import { DRAFT_PERSPECTIVE, DRAFT_VARIANT, readLiveRedirects,
|
|
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).
|
|
127
|
-
|
|
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
|
-
|
|
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)
|
|
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,
|