@elytracms/studio 0.0.97 → 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/ingest-attempt.test.ts +161 -0
- package/convex/ingest.test.ts +185 -9
- package/convex/ingest.ts +136 -24
- package/convex/prototype-key.test.ts +92 -0
- package/convex/records-zod-refinements.convex.test.ts +94 -0
- package/dist/components/cms/cms-store.d.ts +2 -4
- package/dist/components/cms/cms-store.js +4 -22
- 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 +35 -2
- package/dist/components/cms/use-document-draft.js +42 -15
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/records/record-lens.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 +3 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +3 -0
- package/dist/lib/i18n/messages.en.js +3 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +9 -4
- package/dist/lib/operations/cms-persistence.js +132 -29
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persona/labels.js +1 -0
- package/dist/lib/persona/labels.js.map +1 -1
- package/package.json +4 -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,
|
|
@@ -40,22 +42,29 @@ import { findRecordDoc, listRecordWire, saveRecord } from './record-functions'
|
|
|
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
44
|
* claims the stored file with {@link claimIngestUpload}, which checks that the deployment
|
|
43
|
-
* stored it for THIS intent (EC-671)
|
|
44
|
-
* and
|
|
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.
|
|
45
48
|
*
|
|
46
49
|
* The engine never imports an addon: the handler is an internal-mutation REFERENCE the
|
|
47
50
|
* consumer passes in its one `http.ts` line (`defineStudioHttp({ auth, ingest: {
|
|
48
51
|
* submission: formsIngest } })`), exported by the addon's `./convex` entry.
|
|
49
52
|
*/
|
|
50
53
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
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()) }
|
|
53
62
|
|
|
54
63
|
/** An addon's ingest handler: an INTERNAL mutation (never client-callable). */
|
|
55
64
|
export type IngestHandlerRef = FunctionReference<
|
|
56
65
|
'mutation',
|
|
57
66
|
'internal',
|
|
58
|
-
{ payload: IngestPayload; receivedAt: string },
|
|
67
|
+
{ payload: IngestPayload; receivedAt: string; attempt?: number },
|
|
59
68
|
IngestHandlerResult
|
|
60
69
|
>
|
|
61
70
|
|
|
@@ -97,6 +106,32 @@ function utf8Length(text: string): number {
|
|
|
97
106
|
return new TextEncoder().encode(text).byteLength
|
|
98
107
|
}
|
|
99
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
|
+
|
|
100
135
|
/** The receiver `httpAction` (see the module header). */
|
|
101
136
|
export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction {
|
|
102
137
|
return httpActionGeneric(async (ctx, request) => {
|
|
@@ -127,7 +162,7 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
|
|
|
127
162
|
handled: false,
|
|
128
163
|
})
|
|
129
164
|
}
|
|
130
|
-
const result = await ctx
|
|
165
|
+
const result = await runIngestHandler(ctx, handler, body, new Date().toISOString())
|
|
131
166
|
if (!result.ok) {
|
|
132
167
|
return json(result.status, { error: result.message, ...(result.issues ? { issues: result.issues } : {}) })
|
|
133
168
|
}
|
|
@@ -159,6 +194,13 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
|
|
|
159
194
|
// file only when the deployment's own facts say it is this upload's ({@link claimIngestUpload}),
|
|
160
195
|
// it never deletes, and ingest bytes are deleted only through {@link releaseIngestUploads},
|
|
161
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.
|
|
162
204
|
|
|
163
205
|
/** The upload intent — an engine-internal record kind (no public functions). */
|
|
164
206
|
export const ingestUploadRecordKind = defineRecordKind({
|
|
@@ -171,6 +213,8 @@ export const ingestUploadRecordKind = defineRecordKind({
|
|
|
171
213
|
field: z.string().min(1),
|
|
172
214
|
contentType: z.string().min(1),
|
|
173
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),
|
|
174
218
|
state: z.enum(['issued', 'claimed', 'rejected']),
|
|
175
219
|
issuedAt: z.string().min(1),
|
|
176
220
|
/** The id the intent's claim NAMED (claimed or rejected) — what was asked, not what is owned. */
|
|
@@ -240,7 +284,13 @@ export async function recordIngestUpload(
|
|
|
240
284
|
}
|
|
241
285
|
|
|
242
286
|
function payloadColumns(payload: Extract<IngestPayload, { kind: 'upload' }>) {
|
|
243
|
-
return {
|
|
287
|
+
return {
|
|
288
|
+
target: payload.target,
|
|
289
|
+
field: payload.field,
|
|
290
|
+
contentType: payload.contentType,
|
|
291
|
+
size: payload.size,
|
|
292
|
+
sha256: payload.sha256,
|
|
293
|
+
}
|
|
244
294
|
}
|
|
245
295
|
|
|
246
296
|
/** What a claim yields: the stored file's facts, or a refusal for the handler to return. */
|
|
@@ -259,18 +309,30 @@ export type IngestUploadClaimResult =
|
|
|
259
309
|
* before the intent would compare equal);
|
|
260
310
|
* 2. no claim bound it already ({@link ingestBindingRecordKind});
|
|
261
311
|
* 3. no asset row references it — `storageKey` or `thumb.storageKey` (an asset's storage id is
|
|
262
|
-
* public on the delivery snapshot the moment its row exists)
|
|
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.
|
|
263
322
|
*
|
|
264
323
|
* Hiding `storageKey` would not be ownership; these are.
|
|
265
324
|
*/
|
|
266
325
|
async function claimableFile(
|
|
267
326
|
ctx: MutationCtx,
|
|
268
|
-
file: { _id: GenericId<'_storage'>; _creationTime: number },
|
|
269
|
-
|
|
270
|
-
): Promise<
|
|
271
|
-
|
|
272
|
-
if (
|
|
273
|
-
|
|
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'
|
|
274
336
|
}
|
|
275
337
|
|
|
276
338
|
/** Check (3): does any asset row reference these bytes, as its file or its preview (`thumb`)? */
|
|
@@ -290,7 +352,8 @@ async function assetReferencesStorage(ctx: MutationCtx, storageId: string): Prom
|
|
|
290
352
|
/**
|
|
291
353
|
* Claim one upload for a submission to `target`: the intent must exist, be unclaimed,
|
|
292
354
|
* belong to `target`, be younger than `INGEST_UPLOAD_TTL_MS`; the stored file must be this
|
|
293
|
-
* intent's ({@link claimableFile}, EC-671
|
|
355
|
+
* intent's ({@link claimableFile}, EC-671 + EC-685: its bytes the declared ones) and match it
|
|
356
|
+
* (size ≤ declared, same content type).
|
|
294
357
|
* A claimed file is BOUND to the intent ({@link ingestBindingRecordKind}) — the fact every
|
|
295
358
|
* deletion reads ({@link releaseIngestUploads}).
|
|
296
359
|
*
|
|
@@ -307,7 +370,7 @@ export async function claimIngestUpload(
|
|
|
307
370
|
): Promise<IngestUploadClaimResult> {
|
|
308
371
|
const intent = await findRecordDoc(ctx, ingestUploadRecordKind, claim.uploadId)
|
|
309
372
|
const columns = intent?.data as
|
|
310
|
-
| { 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 }
|
|
311
374
|
| undefined
|
|
312
375
|
if (!intent || !columns || columns.target !== target) {
|
|
313
376
|
return { ok: false, status: 422, message: `Unknown upload “${claim.uploadId}”.` }
|
|
@@ -316,6 +379,22 @@ export async function claimIngestUpload(
|
|
|
316
379
|
if (Date.parse(receivedAt) - Date.parse(columns.issuedAt) > INGEST_UPLOAD_TTL_MS) {
|
|
317
380
|
return { ok: false, status: 422, message: `Upload “${claim.uploadId}” expired.` }
|
|
318
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
|
+
}
|
|
319
398
|
const storageId = claim.storageId as GenericId<'_storage'>
|
|
320
399
|
const file = await ctx.db.system.get(storageId).catch(() => null)
|
|
321
400
|
const stamps = { createdAt: receivedAt, updatedAt: receivedAt }
|
|
@@ -325,8 +404,17 @@ export async function claimIngestUpload(
|
|
|
325
404
|
return { ok: false, ...refusal }
|
|
326
405
|
}
|
|
327
406
|
const intentRow = await ctx.db.get(intent._id)
|
|
328
|
-
|
|
329
|
-
|
|
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
|
+
})
|
|
415
|
+
}
|
|
416
|
+
if (verdict !== 'claimable') {
|
|
417
|
+
// One answer for all four checks: which one failed is not the visitor's to learn.
|
|
330
418
|
return reject({ status: 422, message: `Upload “${claim.uploadId}” did not store the file “${claim.storageId}”.` })
|
|
331
419
|
}
|
|
332
420
|
if (file.size > columns.size) return reject({ status: 413, message: 'The stored file is larger than the upload declared.' })
|
|
@@ -342,10 +430,21 @@ export async function claimIngestUpload(
|
|
|
342
430
|
|
|
343
431
|
/**
|
|
344
432
|
* Delete ingest bytes an addon holds (EC-671) — the ONE way they are deleted, and it deletes
|
|
345
|
-
* only files a claim BOUND: the bytes and their binding go
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
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.
|
|
349
448
|
*/
|
|
350
449
|
export async function releaseIngestUploads(
|
|
351
450
|
ctx: MutationCtx,
|
|
@@ -359,14 +458,27 @@ export async function releaseIngestUploads(
|
|
|
359
458
|
kept.push(storageId)
|
|
360
459
|
continue
|
|
361
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
|
+
}
|
|
362
466
|
const id = storageId as GenericId<'_storage'>
|
|
363
467
|
if (await ctx.db.system.get(id).catch(() => null)) await ctx.storage.delete(id)
|
|
364
|
-
await ctx.db.delete(binding._id)
|
|
365
468
|
deleted.push(storageId)
|
|
366
469
|
}
|
|
367
470
|
return { deleted, kept }
|
|
368
471
|
}
|
|
369
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
|
|
480
|
+
}
|
|
481
|
+
|
|
370
482
|
/** Upload intents of `target` still unclaimed past their life — the visible orphan finding. */
|
|
371
483
|
export async function unclaimedIngestUploads(
|
|
372
484
|
ctx: QueryCtx | MutationCtx,
|
|
@@ -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,94 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import { makeFunctionReference } from 'convex/server'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { z as newerZod } from 'zod-copy-newer'
|
|
6
|
+
import { z as olderZod } from 'zod-copy-older'
|
|
7
|
+
import { defineRecordKind } from '@elytracms/core/persistence'
|
|
8
|
+
import type { AnyRecordKind, RecordColumnSchema } from '@elytracms/core/persistence'
|
|
9
|
+
import schema from './schema'
|
|
10
|
+
import { defineRecordFunctions } from './record-functions'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* EC-682 (Codex round 4 F24, a regression of 0.0.97) — the generated Convex record functions
|
|
14
|
+
* (`defineRecordFunctions(kind).save` → `saveRecord` → `resolveRecordSave`, in the transaction)
|
|
15
|
+
* for a kind whose column carries a refinement on its `.optional()` / `.default()` WRAPPER and
|
|
16
|
+
* comes from another zod release than the engine's. Measured before: the adoption judged only
|
|
17
|
+
* the wrapper's `innerType`, so `'forbidden'` — refused by the column's own `safeParse` — was
|
|
18
|
+
* STORED (create and edit). Now a supplied value is judged by the whole column; the engine's own
|
|
19
|
+
* copy is the control row (composed as it is, refused before too). `zod-copy-older` (4.1.13) and
|
|
20
|
+
* `zod-copy-newer` (4.6.7) are real second copies (aliased devDependencies), not mocks.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const MESSAGE = 'forbidden value'
|
|
24
|
+
const notForbidden = (value: string | undefined) => value !== 'forbidden'
|
|
25
|
+
|
|
26
|
+
// The copies' builders differ only in their TYPES (EC-639 owns those); the runtime is the subject here.
|
|
27
|
+
type Vendor = typeof z
|
|
28
|
+
const COPIES = [
|
|
29
|
+
['own', 'the engine’s own copy (the control)', z],
|
|
30
|
+
['older', 'an OLDER zod copy (4.1.13)', olderZod as unknown as Vendor],
|
|
31
|
+
['newer', 'a NEWER zod copy (4.6.7)', newerZod as unknown as Vendor],
|
|
32
|
+
] as const
|
|
33
|
+
|
|
34
|
+
const SHAPES = [
|
|
35
|
+
// The defect: the refinement is the WRAPPER's own check.
|
|
36
|
+
['OptionalRefine', 'optional().refine()', (v: Vendor) => v.string().optional().refine(notForbidden, MESSAGE)],
|
|
37
|
+
['DefaultRefine', 'default().refine()', (v: Vendor) => v.string().default('safe').refine(notForbidden, MESSAGE)],
|
|
38
|
+
// The neighbours: the refinement sits on the inner type (judged before this change too).
|
|
39
|
+
['RefineOptional', 'refine().optional()', (v: Vendor) => v.string().refine(notForbidden, MESSAGE).optional()],
|
|
40
|
+
['RefineDefault', 'refine().default()', (v: Vendor) => v.string().refine(notForbidden, MESSAGE).default('safe')],
|
|
41
|
+
] as const
|
|
42
|
+
|
|
43
|
+
const CASES = COPIES.flatMap(([copy, copyLabel, vendor]) =>
|
|
44
|
+
SHAPES.map(([shape, shapeLabel, build]) => {
|
|
45
|
+
const kind = defineRecordKind({
|
|
46
|
+
kind: `${copy}${shape}`,
|
|
47
|
+
module: `${copy}${shape}Notes`,
|
|
48
|
+
fields: { code: build(vendor) as unknown as RecordColumnSchema },
|
|
49
|
+
})
|
|
50
|
+
return [`${copyLabel}, \`z.string().${shapeLabel}\``, { kind, defaulted: shape.includes('Default') }] as const
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const modules = {
|
|
55
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
56
|
+
'./_generated/api.ts': async () => ({}),
|
|
57
|
+
...Object.fromEntries(CASES.map(([, { kind }]) => [`./${kind.module}.ts`, async () => defineRecordFunctions(kind)])),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const STAMP = '2026-10-28T00:00:00.000Z'
|
|
61
|
+
|
|
62
|
+
async function editorBackend() {
|
|
63
|
+
const t = convexTest(schema, modules)
|
|
64
|
+
await t.run(async (ctx) => {
|
|
65
|
+
await ctx.db.insert('members', { userId: 'editor', role: 'editor', createdAt: STAMP, updatedAt: STAMP })
|
|
66
|
+
})
|
|
67
|
+
return { t, editor: t.withIdentity({ subject: 'editor', issuer: 'https://convex.test' }) }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe.each(CASES)('EC-682 — Convex record save, %s', (_label, { kind, defaulted }: { kind: AnyRecordKind; defaulted: boolean }) => {
|
|
71
|
+
const save = makeFunctionReference<'mutation'>(`${kind.module}:save`)
|
|
72
|
+
const get = makeFunctionReference<'query'>(`${kind.module}:get`)
|
|
73
|
+
const write = (id: string, columns: Record<string, unknown> = {}) => ({ [kind.idKey]: id, ...columns, createdAt: STAMP, updatedAt: STAMP })
|
|
74
|
+
|
|
75
|
+
it('refuses `forbidden` on create with the column’s own message, in the transaction — nothing stored', async () => {
|
|
76
|
+
const { t, editor } = await editorBackend()
|
|
77
|
+
await expect(editor.mutation(save, write('created', { code: 'forbidden' }))).rejects.toThrow(/code: forbidden value/)
|
|
78
|
+
expect(await t.run(async (ctx) => ctx.db.query('records').collect())).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('saves `safe`, refuses an edit to `forbidden`, keeps the stored value', async () => {
|
|
82
|
+
const { editor } = await editorBackend()
|
|
83
|
+
expect(await editor.mutation(save, write('edited', { code: 'safe' }))).toMatchObject({ code: 'safe' })
|
|
84
|
+
await expect(editor.mutation(save, write('edited', { code: 'forbidden' }))).rejects.toThrow(/code: forbidden value/)
|
|
85
|
+
expect(await editor.query(get, { [kind.idKey]: 'edited' })).toMatchObject({ code: 'safe' })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('an absent key is the wrapper’s: the optional stays absent, the default is the column’s own (`safe`)', async () => {
|
|
89
|
+
const { editor } = await editorBackend()
|
|
90
|
+
const created = (await editor.mutation(save, write('absent'))) as Record<string, unknown>
|
|
91
|
+
if (defaulted) expect(created.code).toBe('safe')
|
|
92
|
+
else expect('code' in created).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import type { CmsDocument, CmsValidationIssue, CollectionDef, CollectionRegistry, DocumentPatch, DocumentRef, FieldDef, ValuePath, Locale, LocaleConfig, LocalizedString, RestoreScope, ValidateDocumentOptions } from '@elytracms/core/cms-core';
|
|
3
3
|
import type { CmsDocumentRecord, DocumentVersionRecord, IdFactory, PresenceEntry } from '@elytracms/core/persistence';
|
|
4
|
+
import type { ImportRejection } from './import-batch.js';
|
|
4
5
|
import type { CommitOutcome, DocumentConflict, OperationError, OperationsClient } from '../../lib/operations/index.js';
|
|
5
6
|
import type { TableViewState } from '../../lib/cms-views/index.js';
|
|
6
7
|
import type { CellCommitInput, CellCommitOutcome } from './cell-edit.js';
|
|
@@ -24,10 +25,7 @@ export interface SavedView {
|
|
|
24
25
|
}
|
|
25
26
|
export interface ImportReport {
|
|
26
27
|
added: number;
|
|
27
|
-
rejected:
|
|
28
|
-
id: string;
|
|
29
|
-
issues: CmsValidationIssue[];
|
|
30
|
-
}[];
|
|
28
|
+
rejected: ImportRejection[];
|
|
31
29
|
}
|
|
32
30
|
/**
|
|
33
31
|
* A document's recorded version history plus its live pin (EC-224), as the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
-
import { applyDocumentPatches, createCollectionRegistry, createDocumentStore, documentKey, documentReachabilityIssues,
|
|
3
|
+
import { applyDocumentPatches, createCollectionRegistry, createDocumentStore, documentKey, documentReachabilityIssues, resolveDocumentTitle, seedDefaults, titleFieldOf, touchedPaths, validateDocument, valuesInLocale, writeField, } from '@elytracms/core/cms-core';
|
|
4
4
|
import { createUuidIdFactory } from '@elytracms/core/persistence';
|
|
5
5
|
import { translate } from '../../lib/i18n/index.js';
|
|
6
6
|
import { configureStudioLocales, useStudioLocale } from '../../lib/locale/index.js';
|
|
@@ -9,6 +9,7 @@ import { resolveDeploymentCollections, resolveDeploymentLocaleConfig, resolveDep
|
|
|
9
9
|
import { Heading, Panel, Stack, Text } from '@elytracms/studio/ui';
|
|
10
10
|
import { assetLibraryOracle, gateValidateOptions } from './gate-validation.js';
|
|
11
11
|
import { matchesRef, removeDoc, replaceDoc, upsertDoc } from './document-ref-utils.js';
|
|
12
|
+
import { parseImportBatch } from './import-batch.js';
|
|
12
13
|
import { createRequiredContext } from '../../lib/required-context.js';
|
|
13
14
|
import { createCmsPersister, getStudioActor, getStudioOperations, importWrite } from '../../lib/operations/index.js';
|
|
14
15
|
import { publishInSession, publishWithRevision as publishRevision, } from '../../lib/operations/publish-with-revision.js';
|
|
@@ -684,28 +685,9 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
|
|
|
684
685
|
// transfer dialog) can no longer change a verdict. `validate` above stays the
|
|
685
686
|
// session's DISPLAY validation (the editor's issues, cell edit, import, AI).
|
|
686
687
|
const importDocuments = useCallback((collectionId, raw) => {
|
|
687
|
-
const report = { added: 0, rejected: [] };
|
|
688
688
|
// Pre-parse so intra-batch relation/asset targets resolve during validation.
|
|
689
|
-
const parsedBatch =
|
|
690
|
-
const
|
|
691
|
-
for (const item of raw) {
|
|
692
|
-
const parsed = documentSchema.safeParse({ ...item, collection: collectionId });
|
|
693
|
-
if (parsed.success)
|
|
694
|
-
parsedBatch.push(parsed.data);
|
|
695
|
-
else
|
|
696
|
-
schemaFails.push({ item, error: parsed });
|
|
697
|
-
}
|
|
698
|
-
for (const { item, error } of schemaFails) {
|
|
699
|
-
report.rejected.push({
|
|
700
|
-
id: item?.id ?? translate('cms.list.import.noId'),
|
|
701
|
-
issues: (error.success ? [] : error.error.issues).map((e) => ({
|
|
702
|
-
code: 'invalid-field-value',
|
|
703
|
-
severity: 'error',
|
|
704
|
-
message: e.message,
|
|
705
|
-
path: e.path.map((p) => (typeof p === 'symbol' ? String(p) : p)),
|
|
706
|
-
})),
|
|
707
|
-
});
|
|
708
|
-
}
|
|
689
|
+
const { parsed: parsedBatch, rejected } = parseImportBatch(collectionId, raw);
|
|
690
|
+
const report = { added: 0, rejected: [...rejected] };
|
|
709
691
|
// Validate each against existing docs PLUS the rest of the batch.
|
|
710
692
|
const batchStore = createDocumentStore([...documents, ...parsedBatch]);
|
|
711
693
|
const accepted = [];
|