@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.
- package/convex/cms.ts +272 -16
- 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.test.ts +145 -3
- package/convex/ingest.ts +123 -17
- package/convex/receipt-races.test.ts +184 -0
- package/convex/records-zod-copy.test.ts +76 -0
- package/convex/schema.ts +20 -1
- package/convex/strict-delete.test.ts +129 -0
- package/convex/strict-reference-race.test.ts +139 -0
- package/dist/components/ai/ai-chat-panel.js +15 -2
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- package/dist/components/ai/translate-document-action.d.ts +6 -0
- package/dist/components/ai/translate-document-action.js.map +1 -1
- package/dist/components/ai/translate-document-dialog.d.ts +1 -1
- package/dist/components/ai/translate-document-dialog.js +5 -5
- package/dist/components/ai/translate-document-dialog.js.map +1 -1
- package/dist/components/assets/asset-detail.js +5 -2
- package/dist/components/assets/asset-detail.js.map +1 -1
- package/dist/components/assets/asset-locale.d.ts +7 -0
- package/dist/components/assets/asset-locale.js +16 -2
- package/dist/components/assets/asset-locale.js.map +1 -1
- package/dist/components/assets/asset-meta-drift-notice.d.ts +13 -0
- package/dist/components/assets/asset-meta-drift-notice.js +22 -0
- package/dist/components/assets/asset-meta-drift-notice.js.map +1 -0
- package/dist/components/assets/asset-meta-label.d.ts +2 -1
- package/dist/components/assets/asset-meta-label.js +4 -2
- package/dist/components/assets/asset-meta-label.js.map +1 -1
- package/dist/components/assets/asset-metadata.d.ts +33 -13
- package/dist/components/assets/asset-metadata.js +46 -22
- package/dist/components/assets/asset-metadata.js.map +1 -1
- package/dist/components/assets/asset-search.d.ts +6 -2
- package/dist/components/assets/asset-search.js +15 -5
- package/dist/components/assets/asset-search.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.d.ts +5 -3
- package/dist/components/assets/assets-collection-view-source.js +7 -5
- package/dist/components/assets/assets-collection-view-source.js.map +1 -1
- package/dist/components/builder/canvas-rebase.d.ts +36 -12
- package/dist/components/builder/canvas-rebase.js +13 -3
- package/dist/components/builder/canvas-rebase.js.map +1 -1
- package/dist/components/builder/canvas-render-context.js +5 -4
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/builder/composition-field-editor.d.ts +12 -6
- package/dist/components/builder/composition-field-editor.js +6 -5
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/cms/asset-picker-core.d.ts +2 -2
- package/dist/components/cms/asset-picker-core.js +2 -2
- package/dist/components/cms/asset-picker-core.js.map +1 -1
- package/dist/components/cms/asset-picker.js +3 -1
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/canvas-commit.d.ts +6 -0
- package/dist/components/cms/canvas-commit.js +5 -1
- package/dist/components/cms/canvas-commit.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +24 -8
- package/dist/components/cms/cms-store.js +21 -14
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.js +4 -11
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/detail-editor.js +2 -2
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/draft-commit.d.ts +16 -3
- package/dist/components/cms/draft-commit.js +3 -2
- package/dist/components/cms/draft-commit.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +107 -10
- package/dist/components/cms/use-document-draft.js +157 -42
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/collection-view/cms-collection-view-source.js +6 -4
- package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
- package/dist/components/components/transfer-dialog.js +6 -4
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/lib/ai/chat-state.d.ts +7 -0
- package/dist/lib/ai/chat-state.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +8 -0
- package/dist/lib/ai/tools.js +4 -0
- package/dist/lib/ai/tools.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +15 -8
- package/dist/lib/cms-views/view-model.js +19 -5
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/collection-view/__fixtures__/document-writes.js +1 -0
- package/dist/lib/collection-view/__fixtures__/document-writes.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +18 -7
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/data/collection-content.js +5 -0
- package/dist/lib/data/collection-content.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 +15 -1
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +13 -0
- package/dist/lib/i18n/messages.en.js +16 -1
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +119 -35
- package/dist/lib/operations/cms-persistence.js +441 -91
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +80 -8
- 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 +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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
656
|
-
*
|
|
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: {
|
|
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
|
+
})
|
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,
|