@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/ingest.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { httpActionGeneric, makeFunctionReference } from 'convex/server'
|
|
2
|
-
import type { FunctionReference, PublicHttpAction } from 'convex/server'
|
|
2
|
+
import type { FunctionReference, GenericActionCtx, GenericDataModel, PublicHttpAction } from 'convex/server'
|
|
3
3
|
import { v } from 'convex/values'
|
|
4
4
|
import type { GenericId } from 'convex/values'
|
|
5
5
|
import { z } from 'zod'
|
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
INGEST_MAX_BODY_BYTES,
|
|
8
8
|
INGEST_MAX_UPLOAD_BYTES,
|
|
9
9
|
INGEST_SIGNATURE_HEADER,
|
|
10
|
+
INGEST_UPLOAD_SHA256,
|
|
10
11
|
INGEST_UPLOAD_TTL_MS,
|
|
12
|
+
compareIngestUploadSha256,
|
|
11
13
|
ingestPayloadSchema,
|
|
12
14
|
readIngestReceiverBody,
|
|
13
15
|
verifyIngestSignature,
|
|
@@ -39,22 +41,30 @@ import { findRecordDoc, listRecordWire, saveRecord } from './record-functions'
|
|
|
39
41
|
* analytics addon must fail visibly (EC-618's counsel gate relies on it);
|
|
40
42
|
* 7. an accepted `upload` gets ONE short-lived storage URL (`ctx.storage.generateUploadUrl`)
|
|
41
43
|
* — the bytes go browser → storage, cross-origin by design; the submission that follows
|
|
42
|
-
* claims the stored file with {@link claimIngestUpload}, which checks
|
|
43
|
-
* upload request declared
|
|
44
|
+
* claims the stored file with {@link claimIngestUpload}, which checks that the deployment
|
|
45
|
+
* stored it for THIS intent (EC-671) — its bytes the ones the upload request declared by
|
|
46
|
+
* SHA-256 (EC-685) — and that it matches the declared size and type, and binds it;
|
|
47
|
+
* {@link releaseIngestUploads} is the only way such bytes are deleted.
|
|
44
48
|
*
|
|
45
49
|
* The engine never imports an addon: the handler is an internal-mutation REFERENCE the
|
|
46
50
|
* consumer passes in its one `http.ts` line (`defineStudioHttp({ auth, ingest: {
|
|
47
51
|
* submission: formsIngest } })`), exported by the addon's `./convex` entry.
|
|
48
52
|
*/
|
|
49
53
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
54
|
+
/**
|
|
55
|
+
* What an ingest handler receives: the verified, parsed payload, the receipt time, and — only
|
|
56
|
+
* when the receiver RETRIES the request (EC-618) — which attempt this is. `attempt` is absent on
|
|
57
|
+
* the first run (so a handler declared with the first two arguments alone runs unchanged) and
|
|
58
|
+
* `2…INGEST_HANDLER_ATTEMPTS` on a retry; the payload and `receivedAt` are the same on every
|
|
59
|
+
* attempt. A handler that does not care ignores it.
|
|
60
|
+
*/
|
|
61
|
+
export const ingestHandlerArgs = { payload: v.any(), receivedAt: v.string(), attempt: v.optional(v.number()) }
|
|
52
62
|
|
|
53
63
|
/** An addon's ingest handler: an INTERNAL mutation (never client-callable). */
|
|
54
64
|
export type IngestHandlerRef = FunctionReference<
|
|
55
65
|
'mutation',
|
|
56
66
|
'internal',
|
|
57
|
-
{ payload: IngestPayload; receivedAt: string },
|
|
67
|
+
{ payload: IngestPayload; receivedAt: string; attempt?: number },
|
|
58
68
|
IngestHandlerResult
|
|
59
69
|
>
|
|
60
70
|
|
|
@@ -96,6 +106,32 @@ function utf8Length(text: string): number {
|
|
|
96
106
|
return new TextEncoder().encode(text).byteLength
|
|
97
107
|
}
|
|
98
108
|
|
|
109
|
+
/**
|
|
110
|
+
* How many times the receiver runs a handler whose mutation THREW before the failure surfaces
|
|
111
|
+
* (EC-618). A Convex mutation cannot observe its own optimistic-concurrency conflict — Convex
|
|
112
|
+
* re-executes it and reports exhaustion only to the caller, this `runMutation` — so the receiver
|
|
113
|
+
* is where a request gets another attempt (`attempt: 2, 3` — see {@link ingestHandlerArgs}).
|
|
114
|
+
* A thrown mutation committed nothing, so a retry never counts twice. A handler's REFUSAL
|
|
115
|
+
* (`{ ok: false }`) is an answer, never retried.
|
|
116
|
+
*/
|
|
117
|
+
export const INGEST_HANDLER_ATTEMPTS = 3
|
|
118
|
+
|
|
119
|
+
async function runIngestHandler(
|
|
120
|
+
ctx: Pick<GenericActionCtx<GenericDataModel>, 'runMutation'>,
|
|
121
|
+
handler: IngestHandlerRef,
|
|
122
|
+
payload: IngestPayload,
|
|
123
|
+
receivedAt: string,
|
|
124
|
+
): Promise<IngestHandlerResult> {
|
|
125
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
126
|
+
try {
|
|
127
|
+
// Attempt 1 passes exactly the two arguments every handler has always received.
|
|
128
|
+
return await ctx.runMutation(handler, attempt === 1 ? { payload, receivedAt } : { payload, receivedAt, attempt })
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (attempt >= INGEST_HANDLER_ATTEMPTS) throw error
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
99
135
|
/** The receiver `httpAction` (see the module header). */
|
|
100
136
|
export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction {
|
|
101
137
|
return httpActionGeneric(async (ctx, request) => {
|
|
@@ -126,7 +162,7 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
|
|
|
126
162
|
handled: false,
|
|
127
163
|
})
|
|
128
164
|
}
|
|
129
|
-
const result = await ctx
|
|
165
|
+
const result = await runIngestHandler(ctx, handler, body, new Date().toISOString())
|
|
130
166
|
if (!result.ok) {
|
|
131
167
|
return json(result.status, { error: result.message, ...(result.issues ? { issues: result.issues } : {}) })
|
|
132
168
|
}
|
|
@@ -147,10 +183,24 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
|
|
|
147
183
|
// intent (`recordIngestUpload`); a submission CLAIMS it (`claimIngestUpload`), which checks the
|
|
148
184
|
// stored file against the intent. Abuse is bounded by: the route's limiter and bot gate on every
|
|
149
185
|
// URL issued, `INGEST_MAX_UPLOAD_BYTES` per file, the URL's one-hour life, the post-upload check
|
|
150
|
-
// (an over-size or mistyped file is
|
|
186
|
+
// (an over-size or mistyped file is refused, never attached), and the unclaimed intents an addon can
|
|
151
187
|
// list (`unclaimedIngestUploads`) and surface. Convex returns the storage id to the uploader
|
|
152
188
|
// only, so the bytes of an upload no submission ever claims cannot be located by the engine —
|
|
153
189
|
// that is the documented residue, bounded by the limits above.
|
|
190
|
+
//
|
|
191
|
+
// EC-671 — ownership. The storage id a claim names is the VISITOR's word: nothing at intent
|
|
192
|
+
// time can record it (`generateUploadUrl` takes no argument; the id reaches the uploader
|
|
193
|
+
// alone), and an asset's storage id is public on the delivery snapshot. So a claim binds a
|
|
194
|
+
// file only when the deployment's own facts say it is this upload's ({@link claimIngestUpload}),
|
|
195
|
+
// it never deletes, and ingest bytes are deleted only through {@link releaseIngestUploads},
|
|
196
|
+
// which deletes only what a claim bound.
|
|
197
|
+
//
|
|
198
|
+
// EC-685 — causation. Eligibility (stored after the intent, unbound, no asset's) is not
|
|
199
|
+
// ownership: with intents A and B open, bytes stored through B's URL were eligible for A. So
|
|
200
|
+
// the intent DECLARES the bytes' SHA-256 before they exist, and the claim requires the digest
|
|
201
|
+
// the deployment computed at store time (`_storage.sha256`) to equal it — A can bind B's bytes
|
|
202
|
+
// only by predicting them. Ownership is also kept true when a delete spends it: release keeps
|
|
203
|
+
// a bound file an asset references now, and `assets:deleteBlob` keeps bytes a binding holds.
|
|
154
204
|
|
|
155
205
|
/** The upload intent — an engine-internal record kind (no public functions). */
|
|
156
206
|
export const ingestUploadRecordKind = defineRecordKind({
|
|
@@ -163,12 +213,33 @@ export const ingestUploadRecordKind = defineRecordKind({
|
|
|
163
213
|
field: z.string().min(1),
|
|
164
214
|
contentType: z.string().min(1),
|
|
165
215
|
size: z.number().int().positive(),
|
|
216
|
+
/** EC-685: the SHA-256 the upload declared for its bytes (lowercase hex) — what makes the claim causal. */
|
|
217
|
+
sha256: z.string().regex(INGEST_UPLOAD_SHA256),
|
|
166
218
|
state: z.enum(['issued', 'claimed', 'rejected']),
|
|
167
219
|
issuedAt: z.string().min(1),
|
|
220
|
+
/** The id the intent's claim NAMED (claimed or rejected) — what was asked, not what is owned. */
|
|
168
221
|
storageId: z.string().optional(),
|
|
169
222
|
},
|
|
170
223
|
})
|
|
171
224
|
|
|
225
|
+
/**
|
|
226
|
+
* EC-671 — the engine's ONE ownership fact for ingest bytes: a record per stored file a claim
|
|
227
|
+
* bound to its intent, keyed BY the storage id (`recordId`), so a file is bound at most once
|
|
228
|
+
* and "is it bound?" is one indexed read. Written only by {@link claimIngestUpload}, in the
|
|
229
|
+
* claim's transaction; read by the claim (a file another claim bound is refused) and by
|
|
230
|
+
* {@link releaseIngestUploads}. Engine-internal (no public functions), like the intent.
|
|
231
|
+
*/
|
|
232
|
+
export const ingestBindingRecordKind = defineRecordKind({
|
|
233
|
+
kind: 'ingestBinding',
|
|
234
|
+
module: 'ingest',
|
|
235
|
+
time: 'boundAt',
|
|
236
|
+
fields: {
|
|
237
|
+
uploadId: z.string().min(1),
|
|
238
|
+
target: z.string().min(1),
|
|
239
|
+
boundAt: z.string().min(1),
|
|
240
|
+
},
|
|
241
|
+
})
|
|
242
|
+
|
|
172
243
|
let mintUploadUuid: (() => string) | undefined
|
|
173
244
|
|
|
174
245
|
export interface IngestUploadPolicy {
|
|
@@ -213,7 +284,13 @@ export async function recordIngestUpload(
|
|
|
213
284
|
}
|
|
214
285
|
|
|
215
286
|
function payloadColumns(payload: Extract<IngestPayload, { kind: 'upload' }>) {
|
|
216
|
-
return {
|
|
287
|
+
return {
|
|
288
|
+
target: payload.target,
|
|
289
|
+
field: payload.field,
|
|
290
|
+
contentType: payload.contentType,
|
|
291
|
+
size: payload.size,
|
|
292
|
+
sha256: payload.sha256,
|
|
293
|
+
}
|
|
217
294
|
}
|
|
218
295
|
|
|
219
296
|
/** What a claim yields: the stored file's facts, or a refusal for the handler to return. */
|
|
@@ -221,11 +298,69 @@ export type IngestUploadClaimResult =
|
|
|
221
298
|
| { ok: true; storageId: string; field: string; contentType: string; size: number }
|
|
222
299
|
| { ok: false; status: 409 | 413 | 415 | 422; message: string }
|
|
223
300
|
|
|
301
|
+
/**
|
|
302
|
+
* EC-671 — whether the deployment's own facts say a stored file is this intent's to claim. The
|
|
303
|
+
* storage id cannot be recorded at intent time (it reaches the uploader alone), so ownership
|
|
304
|
+
* is read from `_storage` facts and engine rows; a file is claimable only when ALL hold:
|
|
305
|
+
*
|
|
306
|
+
* 1. it was stored AFTER the intent was issued — compared in ONE clock: the intent row's
|
|
307
|
+
* `_creationTime` against the file's, both stamped by the deployment at insert (`issuedAt`
|
|
308
|
+
* is the HTTP action's clock, at millisecond grain: a file stored in the same millisecond
|
|
309
|
+
* before the intent would compare equal);
|
|
310
|
+
* 2. no claim bound it already ({@link ingestBindingRecordKind});
|
|
311
|
+
* 3. no asset row references it — `storageKey` or `thumb.storageKey` (an asset's storage id is
|
|
312
|
+
* public on the delivery snapshot the moment its row exists);
|
|
313
|
+
* 4. EC-685: its bytes are the ones the intent DECLARED — the digest the deployment computed
|
|
314
|
+
* when it stored them (`_storage.sha256`) equals the intent's `sha256`, through the ONE
|
|
315
|
+
* comparator (`compareIngestUploadSha256`, which reads the stored digest by its form). Checks
|
|
316
|
+
* 1–3 are eligibility; this one is causation: with intents A and B open, bytes stored
|
|
317
|
+
* through B's URL pass 1–3 for A too, and fail 4 unless A predicted them.
|
|
318
|
+
*
|
|
319
|
+
* A stored digest in neither form (`unreadable`) is its own answer — a deployment fault, the
|
|
320
|
+
* same for every file, refused visibly and never read as a match. It is read first, so it
|
|
321
|
+
* says nothing about which ownership check a named file would fail.
|
|
322
|
+
*
|
|
323
|
+
* Hiding `storageKey` would not be ownership; these are.
|
|
324
|
+
*/
|
|
325
|
+
async function claimableFile(
|
|
326
|
+
ctx: MutationCtx,
|
|
327
|
+
file: { _id: GenericId<'_storage'>; _creationTime: number; sha256: string },
|
|
328
|
+
intent: { createdAt: number; sha256: string },
|
|
329
|
+
): Promise<'claimable' | 'not-this-intent' | 'digest-unreadable'> {
|
|
330
|
+
const digest = compareIngestUploadSha256(intent.sha256, file.sha256)
|
|
331
|
+
if (digest === 'unreadable') return 'digest-unreadable'
|
|
332
|
+
if (file._creationTime < intent.createdAt) return 'not-this-intent'
|
|
333
|
+
if (await findRecordDoc(ctx, ingestBindingRecordKind, file._id)) return 'not-this-intent'
|
|
334
|
+
if (await assetReferencesStorage(ctx, file._id)) return 'not-this-intent'
|
|
335
|
+
return digest === 'same' ? 'claimable' : 'not-this-intent'
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Check (3): does any asset row reference these bytes, as its file or its preview (`thumb`)? */
|
|
339
|
+
async function assetReferencesStorage(ctx: MutationCtx, storageId: string): Promise<boolean> {
|
|
340
|
+
const asFile = await ctx.db
|
|
341
|
+
.query('assets')
|
|
342
|
+
.withIndex('by_storage', (q) => q.eq('storageKey', storageId))
|
|
343
|
+
.first()
|
|
344
|
+
if (asFile) return true
|
|
345
|
+
const asThumb = await ctx.db
|
|
346
|
+
.query('assets')
|
|
347
|
+
.withIndex('by_thumb_storage', (q) => q.eq('thumb.storageKey', storageId))
|
|
348
|
+
.first()
|
|
349
|
+
return asThumb !== null
|
|
350
|
+
}
|
|
351
|
+
|
|
224
352
|
/**
|
|
225
353
|
* Claim one upload for a submission to `target`: the intent must exist, be unclaimed,
|
|
226
|
-
* belong to `target`, be younger than `INGEST_UPLOAD_TTL_MS
|
|
227
|
-
*
|
|
228
|
-
*
|
|
354
|
+
* belong to `target`, be younger than `INGEST_UPLOAD_TTL_MS`; the stored file must be this
|
|
355
|
+
* intent's ({@link claimableFile}, EC-671 + EC-685: its bytes the declared ones) and match it
|
|
356
|
+
* (size ≤ declared, same content type).
|
|
357
|
+
* A claimed file is BOUND to the intent ({@link ingestBindingRecordKind}) — the fact every
|
|
358
|
+
* deletion reads ({@link releaseIngestUploads}).
|
|
359
|
+
*
|
|
360
|
+
* A refusal marks the intent `rejected` (recording the id it named) and deletes NOTHING
|
|
361
|
+
* (EC-671): the named id is the visitor's word, and even a file that passed every ownership
|
|
362
|
+
* check may belong to someone the engine cannot see. A refused upload's bytes stay where the
|
|
363
|
+
* uploader put them — the same residue as an upload never claimed.
|
|
229
364
|
*/
|
|
230
365
|
export async function claimIngestUpload(
|
|
231
366
|
ctx: MutationCtx,
|
|
@@ -235,30 +370,113 @@ export async function claimIngestUpload(
|
|
|
235
370
|
): Promise<IngestUploadClaimResult> {
|
|
236
371
|
const intent = await findRecordDoc(ctx, ingestUploadRecordKind, claim.uploadId)
|
|
237
372
|
const columns = intent?.data as
|
|
238
|
-
| { target: string; field: string; contentType: string; size: number; state: string; issuedAt: string }
|
|
373
|
+
| { target: string; field: string; contentType: string; size: number; sha256?: string; state: string; issuedAt: string }
|
|
239
374
|
| undefined
|
|
240
|
-
if (!columns || columns.target !== target)
|
|
375
|
+
if (!intent || !columns || columns.target !== target) {
|
|
376
|
+
return { ok: false, status: 422, message: `Unknown upload “${claim.uploadId}”.` }
|
|
377
|
+
}
|
|
241
378
|
if (columns.state !== 'issued') return { ok: false, status: 409, message: `Upload “${claim.uploadId}” was already claimed.` }
|
|
242
379
|
if (Date.parse(receivedAt) - Date.parse(columns.issuedAt) > INGEST_UPLOAD_TTL_MS) {
|
|
243
380
|
return { ok: false, status: 422, message: `Upload “${claim.uploadId}” expired.` }
|
|
244
381
|
}
|
|
382
|
+
// LEGACY (marked, EC-685): an intent issued before uploads declared their digest cannot be
|
|
383
|
+
// checked for causation. It is refused with its own reason, BEFORE any write: its row lacks
|
|
384
|
+
// the column the kind now requires, and whether the record rule accepts a write onto such a
|
|
385
|
+
// row is the record store's business (measured on 6310b7b4: it does; EC-682 rewrites that
|
|
386
|
+
// validator) — this branch never asks. Without it the claim would still be refused (the
|
|
387
|
+
// digest compares `different`), under the generic answer. Such intents are claimable for at
|
|
388
|
+
// most INGEST_UPLOAD_TTL_MS after the deploy (the expiry above refuses them first
|
|
389
|
+
// afterwards); delete this branch one release after EC-685 shipped.
|
|
390
|
+
const declared = columns.sha256
|
|
391
|
+
if (typeof declared !== 'string') {
|
|
392
|
+
return {
|
|
393
|
+
ok: false,
|
|
394
|
+
status: 422,
|
|
395
|
+
message: `Upload “${claim.uploadId}” was issued before uploads declared their SHA-256 — upload the file again.`,
|
|
396
|
+
}
|
|
397
|
+
}
|
|
245
398
|
const storageId = claim.storageId as GenericId<'_storage'>
|
|
246
399
|
const file = await ctx.db.system.get(storageId).catch(() => null)
|
|
247
400
|
const stamps = { createdAt: receivedAt, updatedAt: receivedAt }
|
|
248
401
|
if (!file) return { ok: false, status: 422, message: `No stored file “${claim.storageId}”.` }
|
|
249
|
-
const
|
|
250
|
-
file.size > columns.size
|
|
251
|
-
? ({ status: 413, message: 'The stored file is larger than the upload declared.' } as const)
|
|
252
|
-
: (file.contentType ?? '') !== columns.contentType
|
|
253
|
-
? ({ status: 415, message: 'The stored file is not of the declared type.' } as const)
|
|
254
|
-
: undefined
|
|
255
|
-
if (refused) {
|
|
256
|
-
await ctx.storage.delete(storageId)
|
|
402
|
+
const reject = async (refusal: { status: 413 | 415 | 422; message: string }): Promise<IngestUploadClaimResult> => {
|
|
257
403
|
await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'rejected', storageId: claim.storageId }, stamps)
|
|
258
|
-
return { ok: false, ...
|
|
404
|
+
return { ok: false, ...refusal }
|
|
405
|
+
}
|
|
406
|
+
const intentRow = await ctx.db.get(intent._id)
|
|
407
|
+
const verdict = intentRow
|
|
408
|
+
? await claimableFile(ctx, file, { createdAt: intentRow._creationTime, sha256: declared })
|
|
409
|
+
: 'not-this-intent'
|
|
410
|
+
if (verdict === 'digest-unreadable') {
|
|
411
|
+
return reject({
|
|
412
|
+
status: 422,
|
|
413
|
+
message: `The deployment's stored digest of “${claim.storageId}” is in neither form the claim reads (64 hex or 44 base64 characters), so the upload cannot be verified.`,
|
|
414
|
+
})
|
|
259
415
|
}
|
|
416
|
+
if (verdict !== 'claimable') {
|
|
417
|
+
// One answer for all four checks: which one failed is not the visitor's to learn.
|
|
418
|
+
return reject({ status: 422, message: `Upload “${claim.uploadId}” did not store the file “${claim.storageId}”.` })
|
|
419
|
+
}
|
|
420
|
+
if (file.size > columns.size) return reject({ status: 413, message: 'The stored file is larger than the upload declared.' })
|
|
421
|
+
if ((file.contentType ?? '') !== columns.contentType) {
|
|
422
|
+
return reject({ status: 415, message: 'The stored file is not of the declared type.' })
|
|
423
|
+
}
|
|
424
|
+
// Bound — and handed back — under the deployment's own id for the file (`file._id`), the
|
|
425
|
+
// spelling checks (2) and (3) read, never the string the visitor sent.
|
|
426
|
+
await saveRecord(ctx, ingestBindingRecordKind, file._id, { uploadId: claim.uploadId, target, boundAt: receivedAt }, stamps)
|
|
260
427
|
await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'claimed', storageId: claim.storageId }, stamps)
|
|
261
|
-
return { ok: true, storageId:
|
|
428
|
+
return { ok: true, storageId: file._id, field: columns.field, contentType: columns.contentType, size: file.size }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Delete ingest bytes an addon holds (EC-671) — the ONE way they are deleted, and it deletes
|
|
433
|
+
* only files a claim BOUND that no asset references NOW: the bytes and their binding go
|
|
434
|
+
* together. Any other id is KEPT and returned in `kept`:
|
|
435
|
+
*
|
|
436
|
+
* - an id with no binding — an asset's, an id a write put into a row by hand, a file claimed
|
|
437
|
+
* before bindings existed — because ingest cannot show it owns them;
|
|
438
|
+
* - EC-685: a bound file an asset row references now (`storageKey` or `thumb.storageKey` — an
|
|
439
|
+
* editor promoted the upload to an asset with `assets:upsert`, a legitimate edit). Ownership
|
|
440
|
+
* has passed to the asset: the binding is spent (deleted) and the bytes stay, so the
|
|
441
|
+
* asset's own delete removes them later. Read in this transaction, so no asset can acquire
|
|
442
|
+
* the file between the check and the delete.
|
|
443
|
+
*
|
|
444
|
+
* Call it for a refused submission's already-claimed files and when removing a row that holds
|
|
445
|
+
* claimed files. The mirror is `assets:deleteBlob`, which keeps bytes a binding holds
|
|
446
|
+
* ({@link ingestBindingHolds}): each delete path re-checks the OTHER owner in its own
|
|
447
|
+
* transaction.
|
|
448
|
+
*/
|
|
449
|
+
export async function releaseIngestUploads(
|
|
450
|
+
ctx: MutationCtx,
|
|
451
|
+
storageIds: readonly string[],
|
|
452
|
+
): Promise<{ deleted: string[]; kept: string[] }> {
|
|
453
|
+
const deleted: string[] = []
|
|
454
|
+
const kept: string[] = []
|
|
455
|
+
for (const storageId of new Set(storageIds)) {
|
|
456
|
+
const binding = await findRecordDoc(ctx, ingestBindingRecordKind, storageId)
|
|
457
|
+
if (!binding) {
|
|
458
|
+
kept.push(storageId)
|
|
459
|
+
continue
|
|
460
|
+
}
|
|
461
|
+
await ctx.db.delete(binding._id)
|
|
462
|
+
if (await assetReferencesStorage(ctx, storageId)) {
|
|
463
|
+
kept.push(storageId) // the asset owns the bytes now (EC-685)
|
|
464
|
+
continue
|
|
465
|
+
}
|
|
466
|
+
const id = storageId as GenericId<'_storage'>
|
|
467
|
+
if (await ctx.db.system.get(id).catch(() => null)) await ctx.storage.delete(id)
|
|
468
|
+
deleted.push(storageId)
|
|
469
|
+
}
|
|
470
|
+
return { deleted, kept }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* EC-685 — does an ingest binding hold these bytes (a claimed upload a submission still owns)?
|
|
475
|
+
* The one reader `assets:deleteBlob` asks before it deletes stored bytes, so an asset an
|
|
476
|
+
* editor created over a submission's file never takes the submission's bytes with it.
|
|
477
|
+
*/
|
|
478
|
+
export async function ingestBindingHolds(ctx: QueryCtx | MutationCtx, storageId: string): Promise<boolean> {
|
|
479
|
+
return (await findRecordDoc(ctx, ingestBindingRecordKind, storageId)) !== null
|
|
262
480
|
}
|
|
263
481
|
|
|
264
482
|
/** Upload intents of `target` still unclaimed past their life — the visible orphan finding. */
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import type { ConvexHttpClient } from 'convex/browser'
|
|
4
|
+
import type { FunctionReference } from 'convex/server'
|
|
5
|
+
import type { CmsDocument, CollectionDef } from '@elytracms/core/cms-core'
|
|
6
|
+
import { createOperations } from '@elytracms/core/operations'
|
|
7
|
+
import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
|
|
8
|
+
import schema from './schema'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* EC-684 (Codex round 4, F27) — the `convex-parity` measurement the refusal rests on, and the refusal
|
|
12
|
+
* itself over the production `ConvexPersistenceAdapter` (its client a call-through onto convex-test's
|
|
13
|
+
* REAL `cms.ts` mutations — the wiring of `depth-codec-lookalike.test.ts`).
|
|
14
|
+
*
|
|
15
|
+
* 1. Re-measured (Codex measured it in `codec-own-keys.convex.test.ts`): the Convex value converters
|
|
16
|
+
* rebuild objects by assignment (`convex/src/values/value.ts` `jsonToConvex` / `convexToJsonInternal`),
|
|
17
|
+
* so an own `__proto__` key written straight through the adapter — past the operations layer — is
|
|
18
|
+
* lost before any sink, at the top level of `values` and nested. The known-positive control: an
|
|
19
|
+
* ordinary own key on the same path survives, so the instrument CAN see a key survive.
|
|
20
|
+
* 2. So the in-memory store keeping the key would be drift; the operations layer refuses it on the raw
|
|
21
|
+
* input, located, in this store too, and nothing is written.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const modules = {
|
|
25
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
26
|
+
'./_generated/api.ts': async () => ({}),
|
|
27
|
+
}
|
|
28
|
+
const P = 'site'
|
|
29
|
+
const ADMIN = 'user_admin'
|
|
30
|
+
const AT = '2026-10-28T12:00:00.000Z'
|
|
31
|
+
|
|
32
|
+
const collections: CollectionDef[] = [
|
|
33
|
+
{ id: 'post', kind: 'document', fields: [{ name: 'payload', type: 'object', multiple: false, fields: [] }] },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
interface Backend {
|
|
37
|
+
query: (ref: FunctionReference<'query'>, args: Record<string, unknown>) => Promise<unknown>
|
|
38
|
+
mutation: (ref: FunctionReference<'mutation'>, args: Record<string, unknown>) => Promise<unknown>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function asAdapterClient(backend: Backend): ConvexHttpClient {
|
|
42
|
+
return {
|
|
43
|
+
query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => backend.query(ref, args ?? {}),
|
|
44
|
+
mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => backend.mutation(ref, args ?? {}),
|
|
45
|
+
} as unknown as ConvexHttpClient
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function harness() {
|
|
49
|
+
const t = convexTest(schema, modules)
|
|
50
|
+
await t.run(async (ctx) => {
|
|
51
|
+
await ctx.db.insert('members', { userId: ADMIN, role: 'admin', createdAt: AT, updatedAt: AT })
|
|
52
|
+
})
|
|
53
|
+
const adapter = new ConvexPersistenceAdapter(asAdapterClient(t.withIdentity({ subject: ADMIN, issuer: 'https://convex.test' })), P)
|
|
54
|
+
const ops = createOperations({ adapter, collections: () => collections, validateDocument: () => [] })
|
|
55
|
+
const rows = async () => t.run(async (ctx) => (await ctx.db.query('cmsDocuments').collect()).map((row) => row.docId))
|
|
56
|
+
return { adapter, ops, rows }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const parse = <T = Record<string, unknown>>(json: string): T => JSON.parse(json) as T
|
|
60
|
+
const ownProto = (value: unknown) => typeof value === 'object' && value !== null && Object.hasOwn(value, '__proto__')
|
|
61
|
+
|
|
62
|
+
describe('EC-684 on Convex — an own `__proto__` key', () => {
|
|
63
|
+
it('re-measured: written straight through the adapter (past operations), Convex loses it before the sink — CONTROL: an ordinary own key survives', async () => {
|
|
64
|
+
const { adapter } = await harness()
|
|
65
|
+
const document = parse<CmsDocument>('{"collection":"post","id":"direct","values":{"__proto__":{"text":"TOP"},"payload":{"__proto__":{"text":"NESTED"},"ordinary":{"text":"KEEP"}}}}')
|
|
66
|
+
expect(ownProto(document.values)).toBe(true)
|
|
67
|
+
expect(ownProto(document.values.payload)).toBe(true)
|
|
68
|
+
await adapter.cms.replaceDocument(P, document, { ifAbsent: true })
|
|
69
|
+
const stored = (await adapter.cms.getDocument(P, { collection: 'post', id: 'direct' })).document
|
|
70
|
+
expect((stored.values.payload as Record<string, unknown>).ordinary).toEqual({ text: 'KEEP' })
|
|
71
|
+
expect(ownProto(stored.values)).toBe(false)
|
|
72
|
+
expect(ownProto(stored.values.payload)).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('through the operations layer it is refused on the raw input, located — and nothing reaches the store', async () => {
|
|
76
|
+
const { ops, rows } = await harness()
|
|
77
|
+
const values = parse('{"__proto__":{"text":"TOP"},"payload":{"__proto__":{"text":"NESTED"}}}')
|
|
78
|
+
const result = await ops.documents.replace({ projectId: P, document: { collection: 'post', id: 'refused', values }, precondition: { ifAbsent: true } })
|
|
79
|
+
expect(result.ok).toBe(false)
|
|
80
|
+
if (result.ok) return
|
|
81
|
+
expect(result.error.code).toBe('validation')
|
|
82
|
+
expect(result.error.code === 'validation' ? result.error.issues.map((issue) => [issue.code, issue.path]) : []).toEqual([
|
|
83
|
+
['prototype-key', ['document', 'values', '__proto__']],
|
|
84
|
+
['prototype-key', ['document', 'values', 'payload', '__proto__']],
|
|
85
|
+
])
|
|
86
|
+
expect(await rows()).toEqual([])
|
|
87
|
+
// CONTROL: the same write without the key goes through.
|
|
88
|
+
const plain = await ops.documents.replace({ projectId: P, document: { collection: 'post', id: 'plain', values: { payload: { text: 'x' } } }, precondition: { ifAbsent: true } })
|
|
89
|
+
expect(plain.ok).toBe(true)
|
|
90
|
+
expect(await rows()).toEqual(['plain'])
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import type { ConvexHttpClient } from 'convex/browser'
|
|
4
|
+
import { getFunctionName, makeFunctionReference } from 'convex/server'
|
|
5
|
+
import type { FunctionReference } from 'convex/server'
|
|
6
|
+
import { measureMaxDepth } from '@elytracms/core/cms-core'
|
|
7
|
+
import { createChangesets, createOperations } from '@elytracms/core/operations'
|
|
8
|
+
import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
|
|
9
|
+
import schema from './schema'
|
|
10
|
+
|
|
11
|
+
// convex-test fires a `runAfter(0)` on a REAL timer (the publish trigger's revalidation);
|
|
12
|
+
// fake timers keep it queued.
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.useFakeTimers()
|
|
15
|
+
vi.setSystemTime('2026-10-28T00:00:00.000Z')
|
|
16
|
+
})
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.useRealTimers()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* EC-663 — the Codex review's F16 and F17 counterexamples on Convex (round 3,
|
|
23
|
+
* `races.convex.test.ts`), copied and INVERTED: the real `cms:*` handlers, schema and triggers
|
|
24
|
+
* under convex-test, driven by the production operations and changesets over the production
|
|
25
|
+
* `ConvexPersistenceAdapter`. Each race is an explicit interleaving — the client HOLDS one
|
|
26
|
+
* store mutation after every check, runs the colleague's operation, then sends the held
|
|
27
|
+
* mutation — and the mutation REFUSES inside its transaction. convex-test serialises
|
|
28
|
+
* mutations, so this proves the sink's check and its refusal, not Convex's OCC retry.
|
|
29
|
+
*
|
|
30
|
+
* F17's wiring changed on purpose (an adapted copy, as the preamble requires): the hidden-clear
|
|
31
|
+
* no longer lands BEFORE `cms:publishDocument` — it is part of it — so at the pause the draft
|
|
32
|
+
* still holds the hidden value (the probe asserted it was already gone).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const modules = {
|
|
36
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
37
|
+
'./_generated/api.ts': async () => ({}),
|
|
38
|
+
}
|
|
39
|
+
const projectId = 'receipt-races'
|
|
40
|
+
const ref = { collection: 'post', id: 'p' }
|
|
41
|
+
const author = { collection: 'author', id: 'a' }
|
|
42
|
+
|
|
43
|
+
async function world() {
|
|
44
|
+
const t = convexTest(schema, modules)
|
|
45
|
+
const stamp = '2026-10-28T00:00:00Z'
|
|
46
|
+
await t.run(async (ctx) => {
|
|
47
|
+
await ctx.db.insert('members', { userId: 'user_admin', role: 'admin', createdAt: stamp, updatedAt: stamp })
|
|
48
|
+
})
|
|
49
|
+
const admin = t.withIdentity({ subject: 'user_admin', issuer: 'https://convex.test' })
|
|
50
|
+
const held = new Map<string, { reached(): void; gate: Promise<void> }>()
|
|
51
|
+
const client = {
|
|
52
|
+
query: (fn: FunctionReference<'query'>, args?: Record<string, unknown>) => admin.query(fn, args ?? {}),
|
|
53
|
+
mutation: async (fn: FunctionReference<'mutation'>, args?: Record<string, unknown>) => {
|
|
54
|
+
const key = `${getFunctionName(fn)}#${String(args?.docId)}`
|
|
55
|
+
const pause = held.get(key)
|
|
56
|
+
if (pause) {
|
|
57
|
+
held.delete(key)
|
|
58
|
+
pause.reached()
|
|
59
|
+
await pause.gate
|
|
60
|
+
}
|
|
61
|
+
return admin.mutation(fn, args ?? {})
|
|
62
|
+
},
|
|
63
|
+
} as unknown as ConvexHttpClient
|
|
64
|
+
const adapter = new ConvexPersistenceAdapter(client, projectId)
|
|
65
|
+
const collections = () => [
|
|
66
|
+
{ id: 'author', kind: 'document' as const, fields: [{ name: 'name', type: 'text' as const }] },
|
|
67
|
+
{ id: 'post', kind: 'document' as const, fields: [
|
|
68
|
+
{ name: 'title', type: 'text' as const },
|
|
69
|
+
{ name: 'hidden', type: 'text' as const, form: { hidden: () => true } },
|
|
70
|
+
{ name: 'author', type: 'relation' as const, target: 'author', multiple: false, strict: true },
|
|
71
|
+
] },
|
|
72
|
+
]
|
|
73
|
+
const options = { adapter, collections, validateDocument: () => [] }
|
|
74
|
+
const ops = createOperations(options)
|
|
75
|
+
const changesets = createChangesets(options)
|
|
76
|
+
/** Hold the next `cms:<verb>` mutation for document `id` at the client. */
|
|
77
|
+
const hold = (verb: string, id: string) => {
|
|
78
|
+
let reached!: () => void
|
|
79
|
+
let resume!: () => void
|
|
80
|
+
const at = new Promise<void>((r) => { reached = r })
|
|
81
|
+
const gate = new Promise<void>((r) => { resume = r })
|
|
82
|
+
held.set(`cms:${verb}#${id}`, { reached, gate })
|
|
83
|
+
return { at, resume }
|
|
84
|
+
}
|
|
85
|
+
const put = async (document: { collection: string; id: string; values: Record<string, unknown> }) => {
|
|
86
|
+
expect((await ops.documents.replace({ projectId, document, precondition: { ifAbsent: true } })).ok).toBe(true)
|
|
87
|
+
}
|
|
88
|
+
return { t, admin, adapter, ops, changesets, hold, put }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const inverse of ['pin', 'unpublish'] as const) {
|
|
92
|
+
it(`F16 Convex: a ${inverse} inverse refuses in its mutation when a colleague published after its checks`, async () => {
|
|
93
|
+
const { adapter, ops, changesets, hold, put } = await world()
|
|
94
|
+
await put({ ...ref, values: { title: 'one' } })
|
|
95
|
+
if (inverse === 'pin') expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true)
|
|
96
|
+
const applied = await changesets.apply(changesets.create({ title: 'publish', steps: [{ operation: 'documents.publish', input: { projectId, ref } }] }))
|
|
97
|
+
expect(applied.ok).toBe(true)
|
|
98
|
+
if (!applied.ok) throw new Error(applied.error.message)
|
|
99
|
+
const pause = hold(inverse === 'pin' ? 'publishDocumentVersion' : 'unpublishDocument', ref.id)
|
|
100
|
+
const reverting = changesets.revert(applied.value)
|
|
101
|
+
await pause.at
|
|
102
|
+
expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true)
|
|
103
|
+
const b = await adapter.cms.getDocument(projectId, ref)
|
|
104
|
+
expect(b.publishedVersion).toBe(inverse === 'pin' ? 3 : 2)
|
|
105
|
+
pause.resume()
|
|
106
|
+
// Inverted: the revert refuses and the colleague's publication stays live.
|
|
107
|
+
const reverted = await reverting
|
|
108
|
+
expect(reverted.ok).toBe(false)
|
|
109
|
+
if (!reverted.ok) expect(reverted.error.code).toBe('conflict')
|
|
110
|
+
expect((await adapter.cms.getDocument(projectId, ref)).publishedVersion).toBe(b.publishedVersion)
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
it('F16 Convex control: a revert with nothing racing it lands (the precondition is not a blanket refusal)', async () => {
|
|
115
|
+
const { adapter, ops, changesets, put } = await world()
|
|
116
|
+
await put({ ...ref, values: { title: 'one' } })
|
|
117
|
+
expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true) // v1
|
|
118
|
+
const applied = await changesets.apply(changesets.create({ title: 'publish', steps: [{ operation: 'documents.publish', input: { projectId, ref } }] }))
|
|
119
|
+
if (!applied.ok) throw new Error(applied.error.message)
|
|
120
|
+
expect((await adapter.cms.getDocument(projectId, ref)).publishedVersion).toBe(2)
|
|
121
|
+
expect((await changesets.revert(applied.value)).ok).toBe(true)
|
|
122
|
+
expect((await adapter.cms.getDocument(projectId, ref)).publishedVersion).toBe(1)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('F17 Convex (adapted wiring): a publish its mutation refuses cleared nothing — compensation’s rollback claim is true', async () => {
|
|
126
|
+
const { adapter, ops, changesets, hold, put } = await world()
|
|
127
|
+
await put({ ...author, values: { name: 'Ada' } })
|
|
128
|
+
await put({ ...ref, values: { hidden: 'KEEP DRAFT', author: { type: 'reference', ...author } } })
|
|
129
|
+
expect((await ops.documents.publish({ projectId, ref: author })).ok).toBe(true)
|
|
130
|
+
const before = await adapter.cms.getDocument(projectId, ref)
|
|
131
|
+
const pause = hold('publishDocument', ref.id)
|
|
132
|
+
const applying = changesets.apply(changesets.create({ title: 'publish', steps: [{ operation: 'documents.publish', input: { projectId, ref } }] }))
|
|
133
|
+
await pause.at
|
|
134
|
+
// Changed wiring: the clear is part of the held mutation, so nothing is cleared yet.
|
|
135
|
+
expect((await adapter.cms.getDocument(projectId, ref)).document.values.hidden).toBe('KEEP DRAFT')
|
|
136
|
+
expect((await ops.documents.unpublish({ projectId, ref: author })).ok).toBe(true)
|
|
137
|
+
pause.resume()
|
|
138
|
+
const result = await applying
|
|
139
|
+
expect(result.ok).toBe(false)
|
|
140
|
+
if (result.ok) throw new Error('expected strict conflict')
|
|
141
|
+
expect(result.error.message).toContain('All previously applied steps were rolled back')
|
|
142
|
+
// Inverted: the refused publish wrote nothing.
|
|
143
|
+
const after = await adapter.cms.getDocument(projectId, ref)
|
|
144
|
+
expect(after.document.values.hidden).toBe('KEEP DRAFT')
|
|
145
|
+
expect(after.revision).toBe(before.revision)
|
|
146
|
+
expect(after.publishedVersion).toBeUndefined()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('EC-663 Convex — the mutations enforce the new preconditions themselves', () => {
|
|
150
|
+
const pinRef = makeFunctionReference<'mutation'>('cms:publishDocumentVersion')
|
|
151
|
+
const unpublishRef = makeFunctionReference<'mutation'>('cms:unpublishDocument')
|
|
152
|
+
|
|
153
|
+
it('cms:publishDocumentVersion / cms:unpublishDocument with a stale expectedRevision return conflict and write nothing', async () => {
|
|
154
|
+
const { admin, adapter, ops, put } = await world()
|
|
155
|
+
await put({ ...ref, values: { title: 'one' } })
|
|
156
|
+
expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true) // r2, v1
|
|
157
|
+
const at = '2026-10-28T00:00:01Z'
|
|
158
|
+
const pin = await admin.mutation(pinRef, { collection: 'post', docId: 'p', version: 1, updatedAt: at, publishedAt: at, expectedRevision: 1 })
|
|
159
|
+
expect(pin).toMatchObject({ status: 'conflict', current: { revision: 2 } })
|
|
160
|
+
const unpublish = await admin.mutation(unpublishRef, { collection: 'post', docId: 'p', updatedAt: at, expectedRevision: 1 })
|
|
161
|
+
expect(unpublish).toMatchObject({ status: 'conflict', current: { revision: 2 } })
|
|
162
|
+
const kept = await adapter.cms.getDocument(projectId, ref)
|
|
163
|
+
expect([kept.revision, kept.publishedVersion]).toEqual([2, 1])
|
|
164
|
+
// Control: at the current revision both land.
|
|
165
|
+
expect(await admin.mutation(unpublishRef, { collection: 'post', docId: 'p', updatedAt: at, expectedRevision: 2 })).toMatchObject({ revision: 3 })
|
|
166
|
+
expect(await admin.mutation(pinRef, { collection: 'post', docId: 'p', version: 1, updatedAt: at, publishedAt: at, expectedRevision: 3 })).toMatchObject({ status: 'ok' })
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('a publish with a hidden-clear is ONE mutation: patch row at +1, publish row at +2, the pinned snapshot cleared', async () => {
|
|
170
|
+
const { t, adapter, ops, put } = await world()
|
|
171
|
+
await put({ ...ref, values: { title: 'T', hidden: 'gone' } })
|
|
172
|
+
const published = await ops.documents.publish({ projectId, ref })
|
|
173
|
+
expect(published.ok).toBe(true)
|
|
174
|
+
const log = await adapter.cms.listDocumentPatches(projectId, ref)
|
|
175
|
+
expect(log.map((row) => `${row.revision}:${row.kind}`)).toEqual(['1:replace', '2:patch', '3:publish'])
|
|
176
|
+
const record = await adapter.cms.getDocument(projectId, ref)
|
|
177
|
+
expect(record.document.values).toEqual({ title: 'T' })
|
|
178
|
+
expect(record.hasUnpublishedChanges).toBe(false)
|
|
179
|
+
expect((await adapter.cms.getDocumentVersion(projectId, ref, 1)).snapshot.values).toEqual({ title: 'T' })
|
|
180
|
+
// The stored row stays inside Convex's 16-level cap.
|
|
181
|
+
const rows = await t.run((ctx) => ctx.db.query('cmsDocuments').collect())
|
|
182
|
+
for (const row of rows) expect(measureMaxDepth(row).depth).toBeLessThanOrEqual(16)
|
|
183
|
+
})
|
|
184
|
+
})
|