@elytracms/studio 0.0.96 → 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 +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.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/dist/components/cms/cms-store.d.ts +4 -1
- package/dist/components/cms/cms-store.js +11 -13
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +41 -2
- package/dist/components/cms/use-document-draft.js +60 -18
- package/dist/components/cms/use-document-draft.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 +2 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +2 -0
- package/dist/lib/i18n/messages.en.js +2 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +41 -2
- package/dist/lib/operations/cms-persistence.js +111 -24
- 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 +1 -0
- package/dist/lib/persona/labels.js.map +1 -1
- package/package.json +4 -3
package/convex/ingest.ts
CHANGED
|
@@ -39,8 +39,9 @@ import { findRecordDoc, listRecordWire, saveRecord } from './record-functions'
|
|
|
39
39
|
* analytics addon must fail visibly (EC-618's counsel gate relies on it);
|
|
40
40
|
* 7. an accepted `upload` gets ONE short-lived storage URL (`ctx.storage.generateUploadUrl`)
|
|
41
41
|
* — 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
|
|
42
|
+
* claims the stored file with {@link claimIngestUpload}, which checks that the deployment
|
|
43
|
+
* stored it for THIS intent (EC-671) and that it matches what the upload request declared,
|
|
44
|
+
* and binds it; {@link releaseIngestUploads} is the only way such bytes are deleted.
|
|
44
45
|
*
|
|
45
46
|
* The engine never imports an addon: the handler is an internal-mutation REFERENCE the
|
|
46
47
|
* consumer passes in its one `http.ts` line (`defineStudioHttp({ auth, ingest: {
|
|
@@ -147,10 +148,17 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
|
|
|
147
148
|
// intent (`recordIngestUpload`); a submission CLAIMS it (`claimIngestUpload`), which checks the
|
|
148
149
|
// stored file against the intent. Abuse is bounded by: the route's limiter and bot gate on every
|
|
149
150
|
// 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
|
|
151
|
+
// (an over-size or mistyped file is refused, never attached), and the unclaimed intents an addon can
|
|
151
152
|
// list (`unclaimedIngestUploads`) and surface. Convex returns the storage id to the uploader
|
|
152
153
|
// only, so the bytes of an upload no submission ever claims cannot be located by the engine —
|
|
153
154
|
// that is the documented residue, bounded by the limits above.
|
|
155
|
+
//
|
|
156
|
+
// EC-671 — ownership. The storage id a claim names is the VISITOR's word: nothing at intent
|
|
157
|
+
// time can record it (`generateUploadUrl` takes no argument; the id reaches the uploader
|
|
158
|
+
// alone), and an asset's storage id is public on the delivery snapshot. So a claim binds a
|
|
159
|
+
// file only when the deployment's own facts say it is this upload's ({@link claimIngestUpload}),
|
|
160
|
+
// it never deletes, and ingest bytes are deleted only through {@link releaseIngestUploads},
|
|
161
|
+
// which deletes only what a claim bound.
|
|
154
162
|
|
|
155
163
|
/** The upload intent — an engine-internal record kind (no public functions). */
|
|
156
164
|
export const ingestUploadRecordKind = defineRecordKind({
|
|
@@ -165,10 +173,29 @@ export const ingestUploadRecordKind = defineRecordKind({
|
|
|
165
173
|
size: z.number().int().positive(),
|
|
166
174
|
state: z.enum(['issued', 'claimed', 'rejected']),
|
|
167
175
|
issuedAt: z.string().min(1),
|
|
176
|
+
/** The id the intent's claim NAMED (claimed or rejected) — what was asked, not what is owned. */
|
|
168
177
|
storageId: z.string().optional(),
|
|
169
178
|
},
|
|
170
179
|
})
|
|
171
180
|
|
|
181
|
+
/**
|
|
182
|
+
* EC-671 — the engine's ONE ownership fact for ingest bytes: a record per stored file a claim
|
|
183
|
+
* bound to its intent, keyed BY the storage id (`recordId`), so a file is bound at most once
|
|
184
|
+
* and "is it bound?" is one indexed read. Written only by {@link claimIngestUpload}, in the
|
|
185
|
+
* claim's transaction; read by the claim (a file another claim bound is refused) and by
|
|
186
|
+
* {@link releaseIngestUploads}. Engine-internal (no public functions), like the intent.
|
|
187
|
+
*/
|
|
188
|
+
export const ingestBindingRecordKind = defineRecordKind({
|
|
189
|
+
kind: 'ingestBinding',
|
|
190
|
+
module: 'ingest',
|
|
191
|
+
time: 'boundAt',
|
|
192
|
+
fields: {
|
|
193
|
+
uploadId: z.string().min(1),
|
|
194
|
+
target: z.string().min(1),
|
|
195
|
+
boundAt: z.string().min(1),
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
|
|
172
199
|
let mintUploadUuid: (() => string) | undefined
|
|
173
200
|
|
|
174
201
|
export interface IngestUploadPolicy {
|
|
@@ -221,11 +248,56 @@ export type IngestUploadClaimResult =
|
|
|
221
248
|
| { ok: true; storageId: string; field: string; contentType: string; size: number }
|
|
222
249
|
| { ok: false; status: 409 | 413 | 415 | 422; message: string }
|
|
223
250
|
|
|
251
|
+
/**
|
|
252
|
+
* EC-671 — whether the deployment's own facts say a stored file is this intent's to claim. The
|
|
253
|
+
* storage id cannot be recorded at intent time (it reaches the uploader alone), so ownership
|
|
254
|
+
* is read from `_storage` facts and engine rows; a file is claimable only when ALL hold:
|
|
255
|
+
*
|
|
256
|
+
* 1. it was stored AFTER the intent was issued — compared in ONE clock: the intent row's
|
|
257
|
+
* `_creationTime` against the file's, both stamped by the deployment at insert (`issuedAt`
|
|
258
|
+
* is the HTTP action's clock, at millisecond grain: a file stored in the same millisecond
|
|
259
|
+
* before the intent would compare equal);
|
|
260
|
+
* 2. no claim bound it already ({@link ingestBindingRecordKind});
|
|
261
|
+
* 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).
|
|
263
|
+
*
|
|
264
|
+
* Hiding `storageKey` would not be ownership; these are.
|
|
265
|
+
*/
|
|
266
|
+
async function claimableFile(
|
|
267
|
+
ctx: MutationCtx,
|
|
268
|
+
file: { _id: GenericId<'_storage'>; _creationTime: number },
|
|
269
|
+
intentCreatedAt: number,
|
|
270
|
+
): Promise<boolean> {
|
|
271
|
+
if (file._creationTime < intentCreatedAt) return false
|
|
272
|
+
if (await findRecordDoc(ctx, ingestBindingRecordKind, file._id)) return false
|
|
273
|
+
return !(await assetReferencesStorage(ctx, file._id))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Check (3): does any asset row reference these bytes, as its file or its preview (`thumb`)? */
|
|
277
|
+
async function assetReferencesStorage(ctx: MutationCtx, storageId: string): Promise<boolean> {
|
|
278
|
+
const asFile = await ctx.db
|
|
279
|
+
.query('assets')
|
|
280
|
+
.withIndex('by_storage', (q) => q.eq('storageKey', storageId))
|
|
281
|
+
.first()
|
|
282
|
+
if (asFile) return true
|
|
283
|
+
const asThumb = await ctx.db
|
|
284
|
+
.query('assets')
|
|
285
|
+
.withIndex('by_thumb_storage', (q) => q.eq('thumb.storageKey', storageId))
|
|
286
|
+
.first()
|
|
287
|
+
return asThumb !== null
|
|
288
|
+
}
|
|
289
|
+
|
|
224
290
|
/**
|
|
225
291
|
* 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
|
-
* it (size ≤ declared, same content type).
|
|
228
|
-
*
|
|
292
|
+
* belong to `target`, be younger than `INGEST_UPLOAD_TTL_MS`; the stored file must be this
|
|
293
|
+
* intent's ({@link claimableFile}, EC-671) and match it (size ≤ declared, same content type).
|
|
294
|
+
* A claimed file is BOUND to the intent ({@link ingestBindingRecordKind}) — the fact every
|
|
295
|
+
* deletion reads ({@link releaseIngestUploads}).
|
|
296
|
+
*
|
|
297
|
+
* A refusal marks the intent `rejected` (recording the id it named) and deletes NOTHING
|
|
298
|
+
* (EC-671): the named id is the visitor's word, and even a file that passed every ownership
|
|
299
|
+
* check may belong to someone the engine cannot see. A refused upload's bytes stay where the
|
|
300
|
+
* uploader put them — the same residue as an upload never claimed.
|
|
229
301
|
*/
|
|
230
302
|
export async function claimIngestUpload(
|
|
231
303
|
ctx: MutationCtx,
|
|
@@ -237,7 +309,9 @@ export async function claimIngestUpload(
|
|
|
237
309
|
const columns = intent?.data as
|
|
238
310
|
| { target: string; field: string; contentType: string; size: number; state: string; issuedAt: string }
|
|
239
311
|
| undefined
|
|
240
|
-
if (!columns || columns.target !== target)
|
|
312
|
+
if (!intent || !columns || columns.target !== target) {
|
|
313
|
+
return { ok: false, status: 422, message: `Unknown upload “${claim.uploadId}”.` }
|
|
314
|
+
}
|
|
241
315
|
if (columns.state !== 'issued') return { ok: false, status: 409, message: `Upload “${claim.uploadId}” was already claimed.` }
|
|
242
316
|
if (Date.parse(receivedAt) - Date.parse(columns.issuedAt) > INGEST_UPLOAD_TTL_MS) {
|
|
243
317
|
return { ok: false, status: 422, message: `Upload “${claim.uploadId}” expired.` }
|
|
@@ -246,19 +320,51 @@ export async function claimIngestUpload(
|
|
|
246
320
|
const file = await ctx.db.system.get(storageId).catch(() => null)
|
|
247
321
|
const stamps = { createdAt: receivedAt, updatedAt: receivedAt }
|
|
248
322
|
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)
|
|
323
|
+
const reject = async (refusal: { status: 413 | 415 | 422; message: string }): Promise<IngestUploadClaimResult> => {
|
|
257
324
|
await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'rejected', storageId: claim.storageId }, stamps)
|
|
258
|
-
return { ok: false, ...
|
|
325
|
+
return { ok: false, ...refusal }
|
|
326
|
+
}
|
|
327
|
+
const intentRow = await ctx.db.get(intent._id)
|
|
328
|
+
if (!intentRow || !(await claimableFile(ctx, file, intentRow._creationTime))) {
|
|
329
|
+
// One answer for all three checks: which one failed is not the visitor's to learn.
|
|
330
|
+
return reject({ status: 422, message: `Upload “${claim.uploadId}” did not store the file “${claim.storageId}”.` })
|
|
331
|
+
}
|
|
332
|
+
if (file.size > columns.size) return reject({ status: 413, message: 'The stored file is larger than the upload declared.' })
|
|
333
|
+
if ((file.contentType ?? '') !== columns.contentType) {
|
|
334
|
+
return reject({ status: 415, message: 'The stored file is not of the declared type.' })
|
|
259
335
|
}
|
|
336
|
+
// Bound — and handed back — under the deployment's own id for the file (`file._id`), the
|
|
337
|
+
// spelling checks (2) and (3) read, never the string the visitor sent.
|
|
338
|
+
await saveRecord(ctx, ingestBindingRecordKind, file._id, { uploadId: claim.uploadId, target, boundAt: receivedAt }, stamps)
|
|
260
339
|
await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'claimed', storageId: claim.storageId }, stamps)
|
|
261
|
-
return { ok: true, storageId:
|
|
340
|
+
return { ok: true, storageId: file._id, field: columns.field, contentType: columns.contentType, size: file.size }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 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 together. Any other id is KEPT and
|
|
346
|
+
* returned in `kept` — an asset's, an id a write put into a row by hand, a file claimed before
|
|
347
|
+
* bindings existed — because ingest cannot show it owns them. Call it for a refused
|
|
348
|
+
* submission's already-claimed files and when removing a row that holds claimed files.
|
|
349
|
+
*/
|
|
350
|
+
export async function releaseIngestUploads(
|
|
351
|
+
ctx: MutationCtx,
|
|
352
|
+
storageIds: readonly string[],
|
|
353
|
+
): Promise<{ deleted: string[]; kept: string[] }> {
|
|
354
|
+
const deleted: string[] = []
|
|
355
|
+
const kept: string[] = []
|
|
356
|
+
for (const storageId of new Set(storageIds)) {
|
|
357
|
+
const binding = await findRecordDoc(ctx, ingestBindingRecordKind, storageId)
|
|
358
|
+
if (!binding) {
|
|
359
|
+
kept.push(storageId)
|
|
360
|
+
continue
|
|
361
|
+
}
|
|
362
|
+
const id = storageId as GenericId<'_storage'>
|
|
363
|
+
if (await ctx.db.system.get(id).catch(() => null)) await ctx.storage.delete(id)
|
|
364
|
+
await ctx.db.delete(binding._id)
|
|
365
|
+
deleted.push(storageId)
|
|
366
|
+
}
|
|
367
|
+
return { deleted, kept }
|
|
262
368
|
}
|
|
263
369
|
|
|
264
370
|
/** Upload intents of `target` still unclaimed past their life — the visible orphan finding. */
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
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 { defineRecordKind } from '@elytracms/core/persistence'
|
|
7
|
+
import type { AnyRecordKind } from '@elytracms/core/persistence'
|
|
8
|
+
import schema from './schema'
|
|
9
|
+
import { defineRecordFunctions } from './record-functions'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* EC-639 — the Convex write path (`defineRecordFunctions(kind).save` → `saveRecord` →
|
|
13
|
+
* `resolveRecordSave`) for a kind declared with ANOTHER zod copy than the engine's: an addon
|
|
14
|
+
* or a consumer's studio declares its kind with its own zod, and its Convex module validates
|
|
15
|
+
* the write in-transaction with that kind. Measured before: a `.default()` column of zod 4.6
|
|
16
|
+
* inside the engine's object (an older copy) refused every create on BOTH adapters
|
|
17
|
+
* (`expected nonoptional, received undefined`). `zod-copy-newer` is a real second copy (an
|
|
18
|
+
* aliased devDependency), not a mock.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const sameCopyKind = defineRecordKind({
|
|
22
|
+
kind: 'sameTicket',
|
|
23
|
+
module: 'sameTickets',
|
|
24
|
+
fields: { title: z.string().min(1), done: z.boolean().default(false), note: z.string().optional() },
|
|
25
|
+
})
|
|
26
|
+
const newerCopyKind = defineRecordKind({
|
|
27
|
+
kind: 'newerTicket',
|
|
28
|
+
module: 'newerTickets',
|
|
29
|
+
fields: { title: newerZod.string().min(1), done: newerZod.boolean().default(false), note: newerZod.string().optional() },
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const modules = {
|
|
33
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
34
|
+
'./_generated/api.ts': async () => ({}),
|
|
35
|
+
[`./${sameCopyKind.module}.ts`]: async () => defineRecordFunctions(sameCopyKind),
|
|
36
|
+
[`./${newerCopyKind.module}.ts`]: async () => defineRecordFunctions(newerCopyKind),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const STAMP = '2026-10-28T00:00:00.000Z'
|
|
40
|
+
|
|
41
|
+
async function editorBackend() {
|
|
42
|
+
const t = convexTest(schema, modules)
|
|
43
|
+
await t.run(async (ctx) => {
|
|
44
|
+
await ctx.db.insert('members', { userId: 'editor', role: 'editor', createdAt: STAMP, updatedAt: STAMP })
|
|
45
|
+
})
|
|
46
|
+
return { t, editor: t.withIdentity({ subject: 'editor', issuer: 'https://convex.test' }) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe.each([
|
|
50
|
+
['a NEWER zod copy (refused every create before)', newerCopyKind],
|
|
51
|
+
['the engine’s own copy (control — unchanged)', sameCopyKind],
|
|
52
|
+
] as const)('EC-639 — Convex record save with %s', (_label, kind: AnyRecordKind) => {
|
|
53
|
+
const save = makeFunctionReference<'mutation'>(`${kind.module}:save`)
|
|
54
|
+
const get = makeFunctionReference<'query'>(`${kind.module}:get`)
|
|
55
|
+
const idArg = (id: string) => ({ [kind.idKey]: id })
|
|
56
|
+
|
|
57
|
+
it('creates with the default applied, edits keep it, a clear removes the optional column', async () => {
|
|
58
|
+
const { editor } = await editorBackend()
|
|
59
|
+
await editor.mutation(save, { ...idArg('t1'), title: 'First', note: 'n', createdAt: STAMP, updatedAt: STAMP })
|
|
60
|
+
expect(await editor.query(get, idArg('t1'))).toMatchObject({ title: 'First', done: false, note: 'n' })
|
|
61
|
+
await editor.mutation(save, { ...idArg('t1'), done: true, createdAt: STAMP, updatedAt: STAMP })
|
|
62
|
+
await editor.mutation(save, { ...idArg('t1'), title: 'Renamed', note: null, createdAt: STAMP, updatedAt: STAMP })
|
|
63
|
+
const edited = (await editor.query(get, idArg('t1'))) as Record<string, unknown>
|
|
64
|
+
expect(edited).toMatchObject({ title: 'Renamed', done: true })
|
|
65
|
+
expect(edited.note).toBeUndefined()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('refuses an invalid column with the column’s own message, in the transaction', async () => {
|
|
69
|
+
const { t, editor } = await editorBackend()
|
|
70
|
+
await expect(
|
|
71
|
+
editor.mutation(save, { ...idArg('bad'), title: '', createdAt: STAMP, updatedAt: STAMP }),
|
|
72
|
+
).rejects.toThrow(/title: Too small/)
|
|
73
|
+
const stored = await t.run(async (ctx) => ctx.db.query('records').collect())
|
|
74
|
+
expect(stored).toEqual([])
|
|
75
|
+
})
|
|
76
|
+
})
|
package/convex/schema.ts
CHANGED
|
@@ -207,6 +207,21 @@ export default defineSchema({
|
|
|
207
207
|
.index('by_doc', ['collection', 'docId', 'variant'])
|
|
208
208
|
.index('by_doc_session', ['collection', 'docId', 'variant', 'sessionId']),
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* EC-669 — the FLOOR of a removed identity: the highest `revision` (of its rows) and
|
|
212
|
+
* `version` (of its history) any deleted or cleared lifetime of `collection:docId` reached.
|
|
213
|
+
* Written by `cms:deleteDocument` and `cms:clearDocuments` (raised, never lowered, never
|
|
214
|
+
* deleted — one row per identity ever removed); read by a create and by a lifetime's first
|
|
215
|
+
* version, which start above it — so a revision or version token names one state of ONE
|
|
216
|
+
* lifetime and a stale writer of a removed lifetime is refused. Never on the delivery wire.
|
|
217
|
+
*/
|
|
218
|
+
documentFloors: defineTable({
|
|
219
|
+
collection: v.string(),
|
|
220
|
+
docId: v.string(),
|
|
221
|
+
revision: v.number(),
|
|
222
|
+
version: v.number(),
|
|
223
|
+
}).index('by_doc', ['collection', 'docId']),
|
|
224
|
+
|
|
210
225
|
/**
|
|
211
226
|
* CMS document version snapshots — append-only (EC-224). One row per recorded
|
|
212
227
|
* version (created on publish or a pre-restore snapshot, never on a plain draft
|
|
@@ -285,7 +300,11 @@ export default defineSchema({
|
|
|
285
300
|
localizedMeta: v.optional(v.any()),
|
|
286
301
|
createdAt: v.string(),
|
|
287
302
|
updatedAt: v.string(),
|
|
288
|
-
})
|
|
303
|
+
})
|
|
304
|
+
.index('by_asset', ['assetId'])
|
|
305
|
+
// EC-671: an ingest claim refuses a storage id an asset row references (`claimIngestUpload`).
|
|
306
|
+
.index('by_storage', ['storageKey'])
|
|
307
|
+
.index('by_thumb_storage', ['thumb.storageKey']),
|
|
289
308
|
|
|
290
309
|
/**
|
|
291
310
|
* Editorial redirects (EC-303) — 301/302 forwards an editor owns, stored as
|
|
@@ -0,0 +1,129 @@
|
|
|
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 } from 'convex/server'
|
|
5
|
+
import type { FunctionReference } from 'convex/server'
|
|
6
|
+
import { fieldPath } from '@elytracms/core/cms-core'
|
|
7
|
+
import { 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-668 — the Codex review's F08-residual counterexample on Convex (round 3,
|
|
23
|
+
* `races.convex.test.ts`), copied and INVERTED: the client HOLDS `cms:deleteDocument` after the
|
|
24
|
+
* operation's guard, a colleague saves a strict draft reference to the target, the held
|
|
25
|
+
* mutation is sent — and it REFUSES, because its transaction reads the draft reference index
|
|
26
|
+
* (`referenceEntries` `by_target`, variant `draft`) and finds a referrer the guard did not judge
|
|
27
|
+
* at its current revision. The probe's positive detection control (a fresh guard refuses a
|
|
28
|
+
* second delete) is kept as written. convex-test serialises mutations: this proves the sink's
|
|
29
|
+
* data check and its refusal, not Convex's OCC retry.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const modules = {
|
|
33
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
34
|
+
'./_generated/api.ts': async () => ({}),
|
|
35
|
+
}
|
|
36
|
+
const projectId = 'strict-delete'
|
|
37
|
+
const ref = { collection: 'post', id: 'p' }
|
|
38
|
+
const author = { collection: 'author', id: 'a' }
|
|
39
|
+
|
|
40
|
+
async function world() {
|
|
41
|
+
const t = convexTest(schema, modules)
|
|
42
|
+
const stamp = '2026-10-28T00:00:00Z'
|
|
43
|
+
await t.run(async (ctx) => {
|
|
44
|
+
await ctx.db.insert('members', { userId: 'user_admin', role: 'admin', createdAt: stamp, updatedAt: stamp })
|
|
45
|
+
})
|
|
46
|
+
const admin = t.withIdentity({ subject: 'user_admin', issuer: 'https://convex.test' })
|
|
47
|
+
const held = new Map<string, { reached(): void; gate: Promise<void> }>()
|
|
48
|
+
const client = {
|
|
49
|
+
query: (fn: FunctionReference<'query'>, args?: Record<string, unknown>) => admin.query(fn, args ?? {}),
|
|
50
|
+
mutation: async (fn: FunctionReference<'mutation'>, args?: Record<string, unknown>) => {
|
|
51
|
+
const key = `${getFunctionName(fn)}#${String(args?.docId)}`
|
|
52
|
+
const pause = held.get(key)
|
|
53
|
+
if (pause) {
|
|
54
|
+
held.delete(key)
|
|
55
|
+
pause.reached()
|
|
56
|
+
await pause.gate
|
|
57
|
+
}
|
|
58
|
+
return admin.mutation(fn, args ?? {})
|
|
59
|
+
},
|
|
60
|
+
} as unknown as ConvexHttpClient
|
|
61
|
+
const adapter = new ConvexPersistenceAdapter(client, projectId)
|
|
62
|
+
const collections = () => [
|
|
63
|
+
{ id: 'author', kind: 'document' as const, fields: [{ name: 'name', type: 'text' as const }] },
|
|
64
|
+
{ id: 'post', kind: 'document' as const, fields: [
|
|
65
|
+
{ name: 'author', type: 'relation' as const, target: 'author', multiple: false, strict: true },
|
|
66
|
+
{ name: 'soft', type: 'relation' as const, target: 'author', multiple: false },
|
|
67
|
+
] },
|
|
68
|
+
]
|
|
69
|
+
const ops = createOperations({ adapter, collections, validateDocument: () => [] })
|
|
70
|
+
const hold = (verb: string, id: string) => {
|
|
71
|
+
let reached!: () => void
|
|
72
|
+
let resume!: () => void
|
|
73
|
+
const at = new Promise<void>((r) => { reached = r })
|
|
74
|
+
const gate = new Promise<void>((r) => { resume = r })
|
|
75
|
+
held.set(`cms:${verb}#${id}`, { reached, gate })
|
|
76
|
+
return { at, resume }
|
|
77
|
+
}
|
|
78
|
+
const put = async (document: { collection: string; id: string; values: Record<string, unknown> }) => {
|
|
79
|
+
expect((await ops.documents.replace({ projectId, document, precondition: { ifAbsent: true } })).ok).toBe(true)
|
|
80
|
+
}
|
|
81
|
+
return { adapter, ops, hold, put }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
it('F08 residual Convex: a strict draft saved before the delete mutation protects its target', async () => {
|
|
85
|
+
const { adapter, ops, hold, put } = await world()
|
|
86
|
+
await put({ ...author, values: { name: 'Ada' } })
|
|
87
|
+
await put({ ...ref, values: {} })
|
|
88
|
+
const pause = hold('deleteDocument', author.id)
|
|
89
|
+
const deleting = ops.documents.delete({ projectId, ref: author })
|
|
90
|
+
await pause.at
|
|
91
|
+
expect((await ops.documents.patch({ projectId, ref, baseRevision: 1, patches: [{ op: 'set', path: fieldPath('author'), value: { type: 'reference', ...author } }] })).ok).toBe(true)
|
|
92
|
+
// Positive detection control (kept as written): the guard already sees the now-saved draft and blocks a second delete.
|
|
93
|
+
expect((await ops.documents.delete({ projectId, ref: author })).ok).toBe(false)
|
|
94
|
+
pause.resume()
|
|
95
|
+
// Inverted: the held delete refuses in its transaction; the target survives.
|
|
96
|
+
const deleted = await deleting
|
|
97
|
+
expect(deleted.ok).toBe(false)
|
|
98
|
+
if (!deleted.ok) expect(deleted.error.code).toBe('conflict')
|
|
99
|
+
expect(await adapter.cms.findDocument(projectId, author)).toBeDefined()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('EC-668 Convex — the delete mutation checks the draft referrers the guard judged', () => {
|
|
103
|
+
it('a judged SOFT draft referrer edited to a strict one while the delete is held refuses (its revision moved)', async () => {
|
|
104
|
+
const { adapter, ops, hold, put } = await world()
|
|
105
|
+
await put({ ...author, values: { name: 'Ada' } })
|
|
106
|
+
await put({ ...ref, values: { soft: { type: 'reference', ...author } } })
|
|
107
|
+
const pause = hold('deleteDocument', author.id)
|
|
108
|
+
const deleting = ops.documents.delete({ projectId, ref: author })
|
|
109
|
+
await pause.at
|
|
110
|
+
expect((await ops.documents.patch({ projectId, ref, baseRevision: 1, patches: [{ op: 'set', path: fieldPath('author'), value: { type: 'reference', ...author } }] })).ok).toBe(true)
|
|
111
|
+
pause.resume()
|
|
112
|
+
expect((await deleting).ok).toBe(false)
|
|
113
|
+
expect(await adapter.cms.findDocument(projectId, author)).toBeDefined()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('control: the held delete with nothing racing it lands, a judged soft referrer and all', async () => {
|
|
117
|
+
const { adapter, ops, hold, put } = await world()
|
|
118
|
+
await put({ ...author, values: { name: 'Ada' } })
|
|
119
|
+
await put({ ...ref, values: { soft: { type: 'reference', ...author } } })
|
|
120
|
+
const pause = hold('deleteDocument', author.id)
|
|
121
|
+
const deleting = ops.documents.delete({ projectId, ref: author })
|
|
122
|
+
await pause.at
|
|
123
|
+
// An unrelated write in the window is not a referrer.
|
|
124
|
+
await put({ collection: 'author', id: 'b', values: { name: 'Bea' } })
|
|
125
|
+
pause.resume()
|
|
126
|
+
expect((await deleting).ok).toBe(true)
|
|
127
|
+
expect(await adapter.cms.findDocument(projectId, author)).toBeUndefined()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -73,8 +73,11 @@ export interface CmsStore {
|
|
|
73
73
|
* editor's `doc`): the value and its revision travel together, so a writer authoring
|
|
74
74
|
* from what it displayed names that revision. `undefined` for a value the session never
|
|
75
75
|
* showed (a fixture session, a copy) or a provisional one (a create in flight).
|
|
76
|
+
* EC-667: with `path`, the revision the content at that position was authored against
|
|
77
|
+
* (a view showing a pending write is a composite); without it, the EARLIEST of any
|
|
78
|
+
* position — the whole-document writer's token.
|
|
76
79
|
*/
|
|
77
|
-
revisionOfView(view: CmsDocument): number | undefined;
|
|
80
|
+
revisionOfView(view: CmsDocument, path?: ValuePath): number | undefined;
|
|
78
81
|
/**
|
|
79
82
|
* EC-595 A2 / EC-578 — create a document from a whole value (duplicate, import of a
|
|
80
83
|
* new row, an AI create, a transfer, a reuse promote): insert-if-absent
|