@elytracms/studio 0.0.102 → 0.0.104
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/ingest.test.ts +251 -23
- package/convex/ingest.ts +77 -19
- package/dist/components/ai/translate-document-dialog.d.ts +43 -0
- package/dist/components/ai/translate-document-dialog.js +104 -36
- package/dist/components/ai/translate-document-dialog.js.map +1 -1
- package/dist/components/ai/translate-skipped.d.ts +19 -3
- package/dist/components/ai/translate-skipped.js +33 -7
- package/dist/components/ai/translate-skipped.js.map +1 -1
- package/dist/components/builder/canvas-theme-notice.js +4 -1
- package/dist/components/builder/canvas-theme-notice.js.map +1 -1
- package/dist/components/components/components-index.d.ts +0 -7
- package/dist/components/components/components-index.js +9 -2
- package/dist/components/components/components-index.js.map +1 -1
- package/dist/lib/ai/translate-apply.d.ts +69 -0
- package/dist/lib/ai/translate-apply.js +36 -0
- package/dist/lib/ai/translate-apply.js.map +1 -0
- package/dist/lib/ai/translate-document.d.ts +95 -7
- package/dist/lib/ai/translate-document.js +195 -57
- package/dist/lib/ai/translate-document.js.map +1 -1
- package/dist/lib/ai/writer-form.d.ts +33 -2
- package/dist/lib/ai/writer-form.js +188 -27
- package/dist/lib/ai/writer-form.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 +56 -10
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +41 -1
- package/dist/lib/i18n/messages.en.js +61 -10
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/package.json +3 -3
package/convex/ingest.test.ts
CHANGED
|
@@ -10,13 +10,14 @@ import {
|
|
|
10
10
|
signedIngestRequest,
|
|
11
11
|
} from '@elytracms/core/ingest'
|
|
12
12
|
import type { IngestHandlerResult, IngestPayload } from '@elytracms/core/ingest'
|
|
13
|
-
import { defineRecordKind } from '@elytracms/core/persistence'
|
|
13
|
+
import { defineRecordKind, resolveRecordQuery } from '@elytracms/core/persistence'
|
|
14
14
|
import schema from './schema'
|
|
15
15
|
import { internalMutation } from './functions'
|
|
16
16
|
import { defineStudioHttp } from './http'
|
|
17
17
|
import { CONVEX_MAX_NESTING_DEPTH, measureMaxDepth } from '@elytracms/core/cms-core'
|
|
18
18
|
import {
|
|
19
19
|
claimIngestUploads,
|
|
20
|
+
ingestBindingRecordKind,
|
|
20
21
|
ingestHandlerArgs,
|
|
21
22
|
parseIngestPayload,
|
|
22
23
|
recordIngestUpload,
|
|
@@ -24,7 +25,7 @@ import {
|
|
|
24
25
|
unclaimedIngestUploads,
|
|
25
26
|
} from './ingest'
|
|
26
27
|
import type { IngestHandlerRef } from './ingest'
|
|
27
|
-
import { saveRecord } from './record-functions'
|
|
28
|
+
import { findRecordDoc, saveRecord } from './record-functions'
|
|
28
29
|
import { createIngestRoute } from '../../next/src/ingest'
|
|
29
30
|
|
|
30
31
|
beforeEach(() => {
|
|
@@ -77,11 +78,12 @@ const testIngestModule = {
|
|
|
77
78
|
}
|
|
78
79
|
if (payload.kind !== 'submission') return { ok: false, status: 422, message: 'Not a submission.' }
|
|
79
80
|
if (payload.target !== 'contact') return { ok: false, status: 404, message: `No form “${payload.target}”.` }
|
|
81
|
+
// EC-750: the row's id first — the claim binds every file to it (the owner release is asked for).
|
|
82
|
+
const id = `sub_${(await ctx.db.query('records').collect()).length + 1}`
|
|
80
83
|
// EC-695: one call for every upload, after every check that can refuse — all bound or none.
|
|
81
|
-
const claim = await claimIngestUploads(ctx, payload.uploads ?? [], payload.target, args.receivedAt)
|
|
84
|
+
const claim = await claimIngestUploads(ctx, payload.uploads ?? [], payload.target, args.receivedAt, testSubmissionKind, id)
|
|
82
85
|
if (!claim.ok) return claim
|
|
83
86
|
const files = claim.files.map((file) => file.storageId)
|
|
84
|
-
const id = `sub_${(await ctx.db.query('records').collect()).length + 1}`
|
|
85
87
|
await saveRecord(
|
|
86
88
|
ctx,
|
|
87
89
|
testSubmissionKind,
|
|
@@ -97,11 +99,56 @@ const testIngestModule = {
|
|
|
97
99
|
const handle = makeFunctionReference<'mutation'>('testIngest:handle') as unknown as IngestHandlerRef
|
|
98
100
|
const silentAuth = { addHttpRoutes: () => {} }
|
|
99
101
|
|
|
102
|
+
/**
|
|
103
|
+
* EC-762 (Codex r10 F51) — a third-party addon whose ONE submission handler writes two record
|
|
104
|
+
* kinds, each row under an EXPLICIT id the payload names (the records API accepts any nonempty
|
|
105
|
+
* id; ids are unique only within a kind). The target names the kind.
|
|
106
|
+
*/
|
|
107
|
+
const attachmentRequestKind = defineRecordKind({
|
|
108
|
+
kind: 'attachmentRequest',
|
|
109
|
+
module: 'testCases',
|
|
110
|
+
fields: { note: z.string().min(1), files: z.array(z.string()).optional() },
|
|
111
|
+
})
|
|
112
|
+
const supportCaseKind = defineRecordKind({
|
|
113
|
+
kind: 'supportCase',
|
|
114
|
+
module: 'testCases',
|
|
115
|
+
fields: { note: z.string().min(1), files: z.array(z.string()).optional() },
|
|
116
|
+
})
|
|
117
|
+
const caseKindOf = (target: string) =>
|
|
118
|
+
target === 'attachmentRequest' ? attachmentRequestKind : target === 'supportCase' ? supportCaseKind : undefined
|
|
119
|
+
|
|
120
|
+
const testCasesModule = {
|
|
121
|
+
handle: internalMutation({
|
|
122
|
+
args: ingestHandlerArgs,
|
|
123
|
+
handler: async (ctx, args): Promise<IngestHandlerResult> => {
|
|
124
|
+
const payload: IngestPayload = parseIngestPayload(args.payload)
|
|
125
|
+
if (payload.kind === 'upload') {
|
|
126
|
+
return recordIngestUpload(ctx, payload, args.receivedAt, { maxBytes: 1000, contentTypes: ['image/*'] })
|
|
127
|
+
}
|
|
128
|
+
if (payload.kind !== 'submission') return { ok: false, status: 422, message: 'Not a submission.' }
|
|
129
|
+
const kind = caseKindOf(payload.target)
|
|
130
|
+
if (!kind) return { ok: false, status: 404, message: `No case kind “${payload.target}”.` }
|
|
131
|
+
const id = payload.data['id']
|
|
132
|
+
if (typeof id !== 'string' || id.length === 0) return { ok: false, status: 422, message: 'An id is required.' }
|
|
133
|
+
const claim = await claimIngestUploads(ctx, payload.uploads ?? [], payload.target, args.receivedAt, kind, id)
|
|
134
|
+
if (!claim.ok) return claim
|
|
135
|
+
const files = claim.files.map((file) => file.storageId)
|
|
136
|
+
await saveRecord(ctx, kind, id, { note: payload.target, ...(files.length ? { files } : {}) }, {
|
|
137
|
+
createdAt: args.receivedAt,
|
|
138
|
+
updatedAt: args.receivedAt,
|
|
139
|
+
})
|
|
140
|
+
return { ok: true, id }
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
}
|
|
144
|
+
const casesHandle = makeFunctionReference<'mutation'>('testCases:handle') as unknown as IngestHandlerRef
|
|
145
|
+
|
|
100
146
|
function backend(handlers: Parameters<typeof defineStudioHttp>[0]['ingest'] = { submission: handle }) {
|
|
101
147
|
return convexTest(schema, {
|
|
102
148
|
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
103
149
|
'./_generated/api.ts': async () => ({}),
|
|
104
150
|
'./testIngest.ts': async () => testIngestModule,
|
|
151
|
+
'./testCases.ts': async () => testCasesModule,
|
|
105
152
|
'./http.ts': async () => ({ default: defineStudioHttp({ auth: silentAuth, ingest: handlers }) }),
|
|
106
153
|
})
|
|
107
154
|
}
|
|
@@ -340,9 +387,10 @@ describe('the ingest receiver — dispatch (EC-617)', () => {
|
|
|
340
387
|
/**
|
|
341
388
|
* EC-671 — a claim binds only bytes the deployment's own facts say are this intent's, each
|
|
342
389
|
* check isolated (the others pass by construction) beside a control the same harness claims;
|
|
343
|
-
* and `releaseIngestUploads` deletes only what a claim bound
|
|
344
|
-
*
|
|
345
|
-
* `packages/forms/src/convex/claim-ownership.convex.test.ts
|
|
390
|
+
* and `releaseIngestUploads` deletes only what a claim bound to the OWNER it is asked for
|
|
391
|
+
* (EC-750). The forms addon's end-to-end probes (its rollback and removal, the attacker's
|
|
392
|
+
* harvest; a sibling's removal) are `packages/forms/src/convex/claim-ownership.convex.test.ts`
|
|
393
|
+
* and `sibling-release.convex.test.ts`.
|
|
346
394
|
*/
|
|
347
395
|
describe('the claim binds only this intent’s bytes; release deletes only bound bytes (EC-671)', () => {
|
|
348
396
|
const PNG = { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 500 } as const
|
|
@@ -352,6 +400,11 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
352
400
|
}
|
|
353
401
|
const claimWith = (t: ReturnType<typeof backend>, uploadId: string, storageId: string) =>
|
|
354
402
|
signedPost(t, { ...SUBMISSION, uploads: [{ uploadId, storageId }] })
|
|
403
|
+
/** The id of the row an accepted submission wrote — the owner its claim bound the files to (EC-750). */
|
|
404
|
+
const ownerOf = async (answer: Response) => {
|
|
405
|
+
expect(answer.status).toBe(200)
|
|
406
|
+
return ((await answer.json()) as { id: string }).id
|
|
407
|
+
}
|
|
355
408
|
async function insertAsset(t: ReturnType<typeof backend>, files: { storageKey: string; thumb?: string }) {
|
|
356
409
|
await t.run(async (ctx) => {
|
|
357
410
|
await ctx.db.insert('assets', {
|
|
@@ -372,6 +425,15 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
372
425
|
.filter((row) => row.kind === 'ingestBinding')
|
|
373
426
|
.map((row) => ({ storageId: row.recordId, uploadId: (row.data as { uploadId: string }).uploadId })),
|
|
374
427
|
)
|
|
428
|
+
/** EC-750: every binding's owner, by its storage id. */
|
|
429
|
+
const owners = (t: ReturnType<typeof backend>) =>
|
|
430
|
+
t.run(async (ctx) =>
|
|
431
|
+
Object.fromEntries(
|
|
432
|
+
(await ctx.db.query('records').collect())
|
|
433
|
+
.filter((row) => row.kind === 'ingestBinding')
|
|
434
|
+
.map((row) => [row.recordId, (row.data as { owner: string }).owner]),
|
|
435
|
+
),
|
|
436
|
+
)
|
|
375
437
|
|
|
376
438
|
it('(1) a file stored BEFORE the intent — in the same millisecond — is refused, rejected and kept; one stored after is claimed', async () => {
|
|
377
439
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
@@ -429,18 +491,24 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
429
491
|
expect([await stored(t, original), await stored(t, preview)]).toEqual([true, true])
|
|
430
492
|
})
|
|
431
493
|
|
|
432
|
-
it('releaseIngestUploads deletes
|
|
494
|
+
it('EC-750: releaseIngestUploads deletes the files its OWNER claimed, with their bindings — never another owner’s, never an asset’s', async () => {
|
|
433
495
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
434
496
|
const t = backend()
|
|
435
|
-
const
|
|
497
|
+
const [mine, theirs] = [await declare(t), await declare(t)]
|
|
436
498
|
const own = await storeFile(t, 400, 'image/png')
|
|
437
|
-
|
|
499
|
+
const other = await storeFile(t, 400, 'image/png')
|
|
500
|
+
const owner = await ownerOf(await claimWith(t, mine, own))
|
|
501
|
+
const otherOwner = await ownerOf(await claimWith(t, theirs, other))
|
|
438
502
|
const assetFile = await storeFile(t, 400, 'image/png')
|
|
439
503
|
await insertAsset(t, { storageKey: assetFile })
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
expect(
|
|
443
|
-
expect(await
|
|
504
|
+
expect(await owners(t)).toEqual({ [own]: owner, [other]: otherOwner })
|
|
505
|
+
const released = await t.run((ctx) => releaseIngestUploads(ctx, testSubmissionKind, owner))
|
|
506
|
+
expect(released).toEqual({ deleted: [own], kept: [] })
|
|
507
|
+
expect([await stored(t, own), await stored(t, other), await stored(t, assetFile)]).toEqual([false, true, true])
|
|
508
|
+
expect(await bindings(t)).toEqual([{ storageId: other, uploadId: theirs }])
|
|
509
|
+
// An owner with nothing bound any more (released already, or a row that never claimed) deletes nothing.
|
|
510
|
+
expect(await t.run((ctx) => releaseIngestUploads(ctx, testSubmissionKind, owner))).toEqual({ deleted: [], kept: [] })
|
|
511
|
+
expect(await t.run((ctx) => releaseIngestUploads(ctx, testSubmissionKind, 'sub_never'))).toEqual({ deleted: [], kept: [] })
|
|
444
512
|
})
|
|
445
513
|
|
|
446
514
|
// EC-685 (Codex r4 F20, ordering 2): a fact checked at claim time is not permanent — an
|
|
@@ -460,12 +528,20 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
460
528
|
const promoted = await storeFile(t, 400, 'image/png')
|
|
461
529
|
const preview = await storeFile(t, 40, 'image/png')
|
|
462
530
|
const unreferenced = await storeFile(t, 400, 'image/png')
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
531
|
+
// One submission claims all three (EC-750: one owner).
|
|
532
|
+
const owner = await ownerOf(
|
|
533
|
+
await signedPost(t, {
|
|
534
|
+
...SUBMISSION,
|
|
535
|
+
uploads: [
|
|
536
|
+
{ uploadId: asFile, storageId: promoted },
|
|
537
|
+
{ uploadId: asThumb, storageId: preview },
|
|
538
|
+
{ uploadId: plain, storageId: unreferenced },
|
|
539
|
+
],
|
|
540
|
+
}),
|
|
541
|
+
)
|
|
466
542
|
// An editor promotes two of the uploads to an asset (its file and its preview) — after the claims.
|
|
467
543
|
await insertAsset(t, { storageKey: promoted, thumb: preview })
|
|
468
|
-
const released = await t.run((ctx) => releaseIngestUploads(ctx,
|
|
544
|
+
const released = await t.run((ctx) => releaseIngestUploads(ctx, testSubmissionKind, owner))
|
|
469
545
|
expect(released).toEqual({ deleted: [unreferenced], kept: [promoted, preview] })
|
|
470
546
|
expect([await stored(t, promoted), await stored(t, preview), await stored(t, unreferenced)]).toEqual([true, true, false])
|
|
471
547
|
expect(await bindings(t)).toEqual([]) // ownership passed: no binding is left to hold the asset's bytes
|
|
@@ -480,7 +556,7 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
480
556
|
const t = backend()
|
|
481
557
|
const uploadId = await declare(t)
|
|
482
558
|
const own = await storeFile(t, 400, 'image/png')
|
|
483
|
-
|
|
559
|
+
const owner = await ownerOf(await claimWith(t, uploadId, own))
|
|
484
560
|
await insertAsset(t, { storageKey: own }) // promoted to an asset while the submission still holds it
|
|
485
561
|
const editor = await asEditor(t)
|
|
486
562
|
expect(await editor.mutation(deleteBlob, { storageKey: own })).toEqual({ deleted: false, heldBy: 'ingest' })
|
|
@@ -490,7 +566,7 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
490
566
|
await t.run(async (ctx) => {
|
|
491
567
|
for (const row of await ctx.db.query('assets').collect()) await ctx.db.delete(row._id)
|
|
492
568
|
})
|
|
493
|
-
expect(await t.run((ctx) => releaseIngestUploads(ctx,
|
|
569
|
+
expect(await t.run((ctx) => releaseIngestUploads(ctx, testSubmissionKind, owner))).toEqual({ deleted: [own], kept: [] })
|
|
494
570
|
expect(await stored(t, own)).toBe(false)
|
|
495
571
|
})
|
|
496
572
|
|
|
@@ -593,9 +669,19 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
593
669
|
const t = backend()
|
|
594
670
|
const uploadId = await declare(t)
|
|
595
671
|
const own = await storeFile(t, 400, 'image/png')
|
|
596
|
-
|
|
672
|
+
const owner = await ownerOf(await claimWith(t, uploadId, own))
|
|
597
673
|
const row = await t.run(async (ctx) => (await ctx.db.query('records').collect()).find((r) => r.kind === 'ingestBinding')!)
|
|
598
|
-
expect(row.data).toEqual({ uploadId, target: 'contact', boundAt: '2026-10-26T12:00:00.000Z' })
|
|
674
|
+
expect(row.data).toEqual({ uploadId, target: 'contact', ownerKind: 'testSubmission', owner, boundAt: '2026-10-26T12:00:00.000Z' })
|
|
675
|
+
// EC-762: the owner's kind fills the static `key1` slot and its id `key2`, read together
|
|
676
|
+
// through the existing `by_kind_keys` index — no schema or index change.
|
|
677
|
+
expect(row.key1).toBe('testSubmission')
|
|
678
|
+
expect(row.key2).toBe(owner)
|
|
679
|
+
expect(resolveRecordQuery(ingestBindingRecordKind, { where: { ownerKind: 'testSubmission', owner } })).toEqual({
|
|
680
|
+
index: 'by_kind_keys',
|
|
681
|
+
key1: 'testSubmission',
|
|
682
|
+
key2: owner,
|
|
683
|
+
order: 'asc',
|
|
684
|
+
})
|
|
599
685
|
expect(measureMaxDepth(row).depth).toBe(2) // the row object → `data` → its string columns
|
|
600
686
|
expect(measureMaxDepth(row).depth).toBeLessThanOrEqual(CONVEX_MAX_NESTING_DEPTH)
|
|
601
687
|
})
|
|
@@ -610,7 +696,7 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
610
696
|
),
|
|
611
697
|
)
|
|
612
698
|
const claimDirect = (t: ReturnType<typeof backend>, claims: { uploadId: string; storageId: string }[]) =>
|
|
613
|
-
t.run((ctx) => claimIngestUploads(ctx, claims, 'contact', '2026-10-26T12:00:00.000Z'))
|
|
699
|
+
t.run((ctx) => claimIngestUploads(ctx, claims, 'contact', '2026-10-26T12:00:00.000Z', testSubmissionKind, 'sub_direct'))
|
|
614
700
|
|
|
615
701
|
it('EC-695: a submission with one refused claim binds NOTHING — the claims before it stay issued; nothing is deleted', async () => {
|
|
616
702
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
@@ -680,6 +766,148 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
680
766
|
})
|
|
681
767
|
})
|
|
682
768
|
|
|
769
|
+
/**
|
|
770
|
+
* EC-762 (Codex r10 F51) — the first EXECUTABLE probe of F51's static trace. A record is
|
|
771
|
+
* identified by its kind AND its id (`record-functions.ts`, `findRecordDoc`), and an explicit id
|
|
772
|
+
* is unique only within its kind. One third-party handler writes `attachmentRequest/same` and
|
|
773
|
+
* `supportCase/same`, each claiming its OWN upload; removing one must not delete the other's.
|
|
774
|
+
*/
|
|
775
|
+
describe('EC-762 probe — an upload’s owner is the record’s kind AND id (Codex r10 F51)', () => {
|
|
776
|
+
const REMOVED_AT = '2026-10-26T12:00:00.000Z'
|
|
777
|
+
async function declareFor(t: ReturnType<typeof backend>, target: string): Promise<string> {
|
|
778
|
+
const intent = { kind: 'upload', target, field: 'cv', contentType: 'image/png', size: 500, sha256: await sha256Of(400) } as const
|
|
779
|
+
const answer = await signedPost(t, intent)
|
|
780
|
+
expect(answer.status).toBe(200)
|
|
781
|
+
return ((await answer.json()) as { uploadId: string }).uploadId
|
|
782
|
+
}
|
|
783
|
+
/** A genuine case of `target`'s kind under the explicit id `id`, claiming one upload of its own. Returns its file. */
|
|
784
|
+
async function genuineCase(t: ReturnType<typeof backend>, target: string, id: string): Promise<string> {
|
|
785
|
+
const uploadId = await declareFor(t, target)
|
|
786
|
+
const file = await storeFile(t, 400, 'image/png')
|
|
787
|
+
const answer = await signedPost(t, { kind: 'submission', target, data: { id }, uploads: [{ uploadId, storageId: file }] })
|
|
788
|
+
expect(answer.status).toBe(200)
|
|
789
|
+
expect(await answer.json()).toEqual({ ok: true, id })
|
|
790
|
+
return file
|
|
791
|
+
}
|
|
792
|
+
/** The addon's removal of one row: release what it claimed, then delete it. */
|
|
793
|
+
const removeCase = (t: ReturnType<typeof backend>, kind: typeof attachmentRequestKind, id: string) =>
|
|
794
|
+
t.run(async (ctx) => {
|
|
795
|
+
const released = await releaseIngestUploads(ctx, kind, id)
|
|
796
|
+
const doc = await findRecordDoc(ctx, kind, id)
|
|
797
|
+
if (doc) await ctx.db.delete(doc._id)
|
|
798
|
+
return released
|
|
799
|
+
})
|
|
800
|
+
const caseBindings = (t: ReturnType<typeof backend>) =>
|
|
801
|
+
t.run(async (ctx) =>
|
|
802
|
+
(await ctx.db.query('records').collect())
|
|
803
|
+
.filter((row) => row.kind === 'ingestBinding')
|
|
804
|
+
.map((row) => {
|
|
805
|
+
const data = row.data as { ownerKind?: string; owner?: string }
|
|
806
|
+
return { storageId: row.recordId, ownerKind: data.ownerKind, owner: data.owner }
|
|
807
|
+
}),
|
|
808
|
+
)
|
|
809
|
+
async function twoKindsOneId() {
|
|
810
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
811
|
+
const t = backend({ submission: casesHandle })
|
|
812
|
+
const a = await genuineCase(t, 'attachmentRequest', 'same')
|
|
813
|
+
const b = await genuineCase(t, 'supportCase', 'same')
|
|
814
|
+
return { t, a, b }
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** A binding row as an earlier release wrote it, straight into the table. */
|
|
818
|
+
const plantBinding = (t: ReturnType<typeof backend>, storageId: string, data: Record<string, string>, key1?: string) =>
|
|
819
|
+
t.run(async (ctx) => {
|
|
820
|
+
await ctx.db.insert('records', {
|
|
821
|
+
kind: 'ingestBinding',
|
|
822
|
+
recordId: storageId,
|
|
823
|
+
data: { uploadId: `upload_${storageId}`, target: 'attachmentRequest', boundAt: REMOVED_AT, ...data },
|
|
824
|
+
...(key1 !== undefined ? { key1 } : {}),
|
|
825
|
+
at: REMOVED_AT,
|
|
826
|
+
createdAt: REMOVED_AT,
|
|
827
|
+
updatedAt: REMOVED_AT,
|
|
828
|
+
})
|
|
829
|
+
})
|
|
830
|
+
async function asEditor(t: ReturnType<typeof backend>) {
|
|
831
|
+
await t.run(async (ctx) => {
|
|
832
|
+
await ctx.db.insert('members', { userId: 'user_editor', role: 'admin', createdAt: REMOVED_AT, updatedAt: REMOVED_AT })
|
|
833
|
+
})
|
|
834
|
+
return t.withIdentity({ subject: 'user_editor', issuer: 'https://convex.test' })
|
|
835
|
+
}
|
|
836
|
+
const deleteBlob = makeFunctionReference<'mutation', { storageKey: string }, unknown>('assets:deleteBlob')
|
|
837
|
+
|
|
838
|
+
it('a claim binds each file to its owner’s kind AND id', async () => {
|
|
839
|
+
const { t, a, b } = await twoKindsOneId()
|
|
840
|
+
expect(await caseBindings(t)).toEqual([
|
|
841
|
+
{ storageId: a, ownerKind: 'attachmentRequest', owner: 'same' }, // base: ownerKind undefined
|
|
842
|
+
{ storageId: b, ownerKind: 'supportCase', owner: 'same' },
|
|
843
|
+
])
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
it('Codex’s trace: removing attachmentRequest/same keeps supportCase/same’s bytes and binding', async () => {
|
|
847
|
+
const { t, a, b } = await twoKindsOneId()
|
|
848
|
+
// Measured on the base (7c2e9261), counterexample form: `{ deleted: [a, b], kept: [] }`, both files gone, no binding left.
|
|
849
|
+
expect(await removeCase(t, attachmentRequestKind, 'same')).toEqual({ deleted: [a], kept: [] })
|
|
850
|
+
expect([await stored(t, a), await stored(t, b)]).toEqual([false, true])
|
|
851
|
+
expect(await caseBindings(t)).toEqual([{ storageId: b, ownerKind: 'supportCase', owner: 'same' }])
|
|
852
|
+
// supportCase/same still holds its file; its own removal deletes it (below).
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
it('control: removing the owner itself (same kind, same id) deletes its file and binding — the instrument sees a deletion', async () => {
|
|
856
|
+
const { t, a, b } = await twoKindsOneId()
|
|
857
|
+
const released = await removeCase(t, supportCaseKind, 'same')
|
|
858
|
+
expect(released.deleted).toContain(b)
|
|
859
|
+
expect(await stored(t, b)).toBe(false)
|
|
860
|
+
expect((await caseBindings(t)).map((binding) => binding.storageId)).not.toContain(b)
|
|
861
|
+
// …and after both rows are removed nothing is left: no residue.
|
|
862
|
+
await removeCase(t, attachmentRequestKind, 'same')
|
|
863
|
+
expect([await stored(t, a), await stored(t, b)]).toEqual([false, false])
|
|
864
|
+
expect(await caseBindings(t)).toEqual([])
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
it('a KIND-LESS binding (written before bindings recorded the owner’s kind) is never deleted — residue, never loss; it still holds its bytes', async () => {
|
|
868
|
+
const { t, a } = await twoKindsOneId()
|
|
869
|
+
const kindless = await storeFile(t, 300, 'image/png')
|
|
870
|
+
await plantBinding(t, kindless, { owner: 'same' }, 'same') // the shape EC-750 wrote: `owner` in `key1`, no kind
|
|
871
|
+
expect(await removeCase(t, attachmentRequestKind, 'same')).toEqual({ deleted: [a], kept: [] }) // base: deleted the kind-less file too
|
|
872
|
+
expect(await stored(t, kindless)).toBe(true)
|
|
873
|
+
expect((await caseBindings(t)).filter((binding) => binding.storageId === kindless)).toEqual([
|
|
874
|
+
{ storageId: kindless, ownerKind: undefined, owner: 'same' },
|
|
875
|
+
])
|
|
876
|
+
const editor = await asEditor(t)
|
|
877
|
+
expect(await editor.mutation(deleteBlob, { storageKey: kindless })).toEqual({ deleted: false, heldBy: 'ingest' })
|
|
878
|
+
expect(await stored(t, kindless)).toBe(true)
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
it('control: an OWNER-LESS binding (written before EC-750) is never deleted', async () => {
|
|
882
|
+
const { t, a } = await twoKindsOneId()
|
|
883
|
+
const ownerless = await storeFile(t, 300, 'image/png')
|
|
884
|
+
await plantBinding(t, ownerless, {})
|
|
885
|
+
await removeCase(t, attachmentRequestKind, 'same')
|
|
886
|
+
await removeCase(t, supportCaseKind, 'same')
|
|
887
|
+
expect([await stored(t, a), await stored(t, ownerless)]).toEqual([false, true])
|
|
888
|
+
expect(await caseBindings(t)).toEqual([{ storageId: ownerless, ownerKind: undefined, owner: undefined }])
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
it('assets:deleteBlob still keeps bytes a binding holds — after attachmentRequest/same’s removal, supportCase/same’s binding protects its file', async () => {
|
|
892
|
+
const { t, b } = await twoKindsOneId()
|
|
893
|
+
await removeCase(t, attachmentRequestKind, 'same')
|
|
894
|
+
await t.run(async (ctx) => {
|
|
895
|
+
await ctx.db.insert('assets', {
|
|
896
|
+
assetId: 'asset_case',
|
|
897
|
+
filename: 'case.png',
|
|
898
|
+
contentType: 'image/png',
|
|
899
|
+
byteSize: 400,
|
|
900
|
+
storageKey: b,
|
|
901
|
+
createdAt: REMOVED_AT,
|
|
902
|
+
updatedAt: REMOVED_AT,
|
|
903
|
+
})
|
|
904
|
+
})
|
|
905
|
+
const editor = await asEditor(t)
|
|
906
|
+
expect(await editor.mutation(deleteBlob, { storageKey: b })).toEqual({ deleted: false, heldBy: 'ingest' }) // base: the bytes were already gone
|
|
907
|
+
expect(await stored(t, b)).toBe(true)
|
|
908
|
+
})
|
|
909
|
+
})
|
|
910
|
+
|
|
683
911
|
describe('the site’s route → the receiver, end to end (EC-617)', () => {
|
|
684
912
|
it('a visitor’s submission through createIngestRoute lands a record; a mismatched site secret is a 502', async () => {
|
|
685
913
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
package/convex/ingest.ts
CHANGED
|
@@ -16,9 +16,10 @@ import {
|
|
|
16
16
|
} from '@elytracms/core/ingest'
|
|
17
17
|
import type { IngestHandlerResult, IngestPayload, IngestUploadClaim } from '@elytracms/core/ingest'
|
|
18
18
|
import { defineRecordKind, resolveRecordQuery } from '@elytracms/core/persistence'
|
|
19
|
+
import type { AnyRecordKind } from '@elytracms/core/persistence'
|
|
19
20
|
import { createUuidIdFactory } from '@elytracms/core/determinism'
|
|
20
21
|
import type { MutationCtx, QueryCtx } from './functions'
|
|
21
|
-
import { findRecordDoc, listRecordWire, saveRecord } from './record-functions'
|
|
22
|
+
import { findRecordDoc, listRecordWire, queryRecordDocs, saveRecord } from './record-functions'
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* EC-617 — the ONE inbound receiver of the public ingest seam (ruling EC-565 § 6c), and the
|
|
@@ -44,7 +45,8 @@ import { findRecordDoc, listRecordWire, saveRecord } from './record-functions'
|
|
|
44
45
|
* claims its stored files with {@link claimIngestUploads}, which checks that the deployment
|
|
45
46
|
* stored each for ITS intent (EC-671) — its bytes the ones the upload request declared by
|
|
46
47
|
* SHA-256 (EC-685) — and that it matches the declared size and type, and binds them all or
|
|
47
|
-
* none (EC-695)
|
|
48
|
+
* none (EC-695) to the record that claims them, by its kind and id (EC-750, EC-762);
|
|
49
|
+
* {@link releaseIngestUploads}, asked for that record, is the only way such bytes are deleted.
|
|
48
50
|
*
|
|
49
51
|
* The engine never imports an addon: the handler is an internal-mutation REFERENCE the
|
|
50
52
|
* consumer passes in its one `http.ts` line (`defineStudioHttp({ auth, ingest: {
|
|
@@ -195,6 +197,13 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
|
|
|
195
197
|
// it never deletes, and ingest bytes are deleted only through {@link releaseIngestUploads},
|
|
196
198
|
// which deletes only what a claim bound.
|
|
197
199
|
//
|
|
200
|
+
// EC-750 (Codex r9 F46) — bound to WHOM. A binding records the record that claimed the file
|
|
201
|
+
// — EC-762 (Codex r10 F51): its kind AND its id (`ownerKind` + `owner`), the identity the records
|
|
202
|
+
// table declares — and release is asked for that owner, never for storage ids: removing a row
|
|
203
|
+
// deletes only the files that row claimed. Before EC-750, release took the removed row's `files`
|
|
204
|
+
// ids — a column a whole-record write can fill with another row's file — and deleted any bound
|
|
205
|
+
// one; before EC-762, it took the id alone, so a row of another kind sharing the id lost its files.
|
|
206
|
+
//
|
|
198
207
|
// EC-685 — causation. Eligibility (stored after the intent, unbound, no asset's) is not
|
|
199
208
|
// ownership: with intents A and B open, bytes stored through B's URL were eligible for A. So
|
|
200
209
|
// the intent DECLARES the bytes' SHA-256 before they exist, and the claim requires the digest
|
|
@@ -236,14 +245,36 @@ export const ingestUploadRecordKind = defineRecordKind({
|
|
|
236
245
|
* and "is it bound?" is one indexed read. Written only by {@link claimIngestUploads}, in the
|
|
237
246
|
* claim's transaction; read by the claim (a file another claim bound is refused) and by
|
|
238
247
|
* {@link releaseIngestUploads}. Engine-internal (no public functions), like the intent.
|
|
248
|
+
*
|
|
249
|
+
* EC-750 (Codex r9 F46) — and WHOSE: the record that claimed the file (a forms submission), so
|
|
250
|
+
* release asks "this record's files", never "is this id bound?". `target` is the intent's (the
|
|
251
|
+
* form every submission to it shares), so it cannot say whose a file is.
|
|
252
|
+
*
|
|
253
|
+
* EC-762 (Codex r10 F51) — a record is its kind AND its id (the records table's `kind` +
|
|
254
|
+
* `recordId`; an explicit id is unique only within its kind), so the owner is both:
|
|
255
|
+
* `ownerKind` (the owning kind's name) in the `key1` slot and `owner` (its id) in `key2`, read
|
|
256
|
+
* TOGETHER through the existing `by_kind_keys` index. Two rows of different kinds sharing an id
|
|
257
|
+
* never share a binding. Two string columns, never one object column (a key slot holds a
|
|
258
|
+
* string: an object would stringify to the same `[object Object]` for every owner) and never a
|
|
259
|
+
* composite string (a second encoding of an identity the table already stores as two columns).
|
|
260
|
+
*
|
|
261
|
+
* Bindings of earlier releases are never read by a release, so never deleted: one written before
|
|
262
|
+
* EC-750 has no owner and no slot, one written before EC-762 has its owner's id in `key1` and no
|
|
263
|
+
* `key2`. Each keeps protecting its bytes from `assets:deleteBlob` (residue, never loss —
|
|
264
|
+
* EC-671's no-migration precedent; no owner-only fallback, which would re-open F51).
|
|
239
265
|
*/
|
|
240
266
|
export const ingestBindingRecordKind = defineRecordKind({
|
|
241
267
|
kind: 'ingestBinding',
|
|
242
268
|
module: 'ingest',
|
|
269
|
+
keys: ['ownerKind', 'owner'],
|
|
243
270
|
time: 'boundAt',
|
|
244
271
|
fields: {
|
|
245
272
|
uploadId: z.string().min(1),
|
|
246
273
|
target: z.string().min(1),
|
|
274
|
+
/** EC-762: the KIND of the record that claimed the file (its declaration's `kind`). */
|
|
275
|
+
ownerKind: z.string().min(1),
|
|
276
|
+
/** EC-750: the id of the record that claimed the file — with `ownerKind`, what {@link releaseIngestUploads} is asked for. */
|
|
277
|
+
owner: z.string().min(1),
|
|
247
278
|
boundAt: z.string().min(1),
|
|
248
279
|
},
|
|
249
280
|
})
|
|
@@ -403,12 +434,21 @@ async function assetReferencesStorage(ctx: MutationCtx, storageId: string): Prom
|
|
|
403
434
|
* A handler calls this ONCE per submission, after every check of its own that can refuse: a
|
|
404
435
|
* mutation that RETURNS commits its writes (a claim made before a later refusal stays bound with
|
|
405
436
|
* no row to hold it); one that THROWS commits nothing.
|
|
437
|
+
*
|
|
438
|
+
* EC-750 / EC-762: the owner is the record the handler is about to write — its kind's
|
|
439
|
+
* declaration (`ownerKind`) and its id (`ownerId`, minted BEFORE the claim). Every binding records
|
|
440
|
+
* both, and {@link releaseIngestUploads} deletes a file only for the owner that claimed it: a row
|
|
441
|
+
* naming another row's file (an editor's whole-record write can put any id in its `files`) never
|
|
442
|
+
* deletes it, nor does a row of another kind that happens to share the id. The declaration, not a
|
|
443
|
+
* string: a misspelt kind cannot compile into bindings no release ever finds.
|
|
406
444
|
*/
|
|
407
445
|
export async function claimIngestUploads(
|
|
408
446
|
ctx: MutationCtx,
|
|
409
447
|
claims: readonly IngestUploadClaim[],
|
|
410
448
|
target: string,
|
|
411
449
|
receivedAt: string,
|
|
450
|
+
ownerKind: AnyRecordKind,
|
|
451
|
+
ownerId: string,
|
|
412
452
|
): Promise<IngestUploadsClaimResult> {
|
|
413
453
|
const accepted: AcceptedClaim[] = []
|
|
414
454
|
for (const [index, claim] of claims.entries()) {
|
|
@@ -418,7 +458,13 @@ export async function claimIngestUploads(
|
|
|
418
458
|
}
|
|
419
459
|
const stamps = { createdAt: receivedAt, updatedAt: receivedAt }
|
|
420
460
|
for (const claim of accepted) {
|
|
421
|
-
await saveRecord(
|
|
461
|
+
await saveRecord(
|
|
462
|
+
ctx,
|
|
463
|
+
ingestBindingRecordKind,
|
|
464
|
+
claim.storageId,
|
|
465
|
+
{ uploadId: claim.uploadId, target, ownerKind: ownerKind.kind, owner: ownerId, boundAt: receivedAt },
|
|
466
|
+
stamps,
|
|
467
|
+
)
|
|
422
468
|
await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'claimed', storageId: claim.storageId }, stamps)
|
|
423
469
|
}
|
|
424
470
|
return { ok: true, files: accepted.map(({ storageId, field, contentType, size }) => ({ storageId, field, contentType, size })) }
|
|
@@ -508,35 +554,47 @@ async function judgeIngestUploadClaim(
|
|
|
508
554
|
}
|
|
509
555
|
|
|
510
556
|
/**
|
|
511
|
-
* Delete ingest bytes
|
|
512
|
-
*
|
|
513
|
-
*
|
|
557
|
+
* Delete the ingest bytes ONE record claimed (EC-671, EC-750, EC-762) — the ONE way ingest bytes
|
|
558
|
+
* are deleted. It is asked for an OWNER — the kind's declaration and the id
|
|
559
|
+
* {@link claimIngestUploads} bound the files to — never for storage ids: it reads that owner's
|
|
560
|
+
* bindings by BOTH keys (`by_kind_keys`) and deletes each file with its binding — so it never
|
|
561
|
+
* sees, and never writes, another owner's binding, a row of another kind sharing the id
|
|
562
|
+
* included. A row's `files` column is the visitor's and the editor's word (a whole-record write
|
|
563
|
+
* can name any id, or clear the list), so release never reads it: a row naming another row's
|
|
564
|
+
* file deletes nothing, and a row whose list was cleared still deletes what it claimed.
|
|
565
|
+
*
|
|
566
|
+
* Of the owner's files it KEEPS, returned in `kept`:
|
|
514
567
|
*
|
|
515
|
-
* - an id with no binding — an asset's, an id a write put into a row by hand, a file claimed
|
|
516
|
-
* before bindings existed — because ingest cannot show it owns them;
|
|
517
568
|
* - EC-685: a bound file an asset row references now (`storageKey` or `thumb.storageKey` — an
|
|
518
569
|
* editor promoted the upload to an asset with `assets:upsert`, a legitimate edit). Ownership
|
|
519
570
|
* has passed to the asset: the binding is spent (deleted) and the bytes stay, so the
|
|
520
571
|
* asset's own delete removes them later. Read in this transaction, so no asset can acquire
|
|
521
572
|
* the file between the check and the delete.
|
|
522
573
|
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
574
|
+
* Never touched: a binding of another owner; a binding written before EC-750 (no owner, no key
|
|
575
|
+
* slot); one written before EC-762 (its owner's id alone, in `key1`, no `key2` — read by id
|
|
576
|
+
* alone it would be F51 again, so there is no such fallback). Residue, never loss: each keeps
|
|
577
|
+
* protecting its bytes from `assets:deleteBlob`.
|
|
578
|
+
*
|
|
579
|
+
* Call it when removing a row that holds claimed files, with that row's kind and id — never for
|
|
580
|
+
* a refused submission, which bound nothing ({@link claimIngestUploads}, EC-695). The mirror is
|
|
581
|
+
* `assets:deleteBlob`, which keeps bytes a binding holds ({@link ingestBindingHolds}): each
|
|
582
|
+
* delete path re-checks the OTHER owner in its own transaction.
|
|
527
583
|
*/
|
|
528
584
|
export async function releaseIngestUploads(
|
|
529
585
|
ctx: MutationCtx,
|
|
530
|
-
|
|
586
|
+
ownerKind: AnyRecordKind,
|
|
587
|
+
ownerId: string,
|
|
531
588
|
): Promise<{ deleted: string[]; kept: string[] }> {
|
|
532
589
|
const deleted: string[] = []
|
|
533
590
|
const kept: string[] = []
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
591
|
+
const owned = await queryRecordDocs(
|
|
592
|
+
ctx,
|
|
593
|
+
ingestBindingRecordKind,
|
|
594
|
+
resolveRecordQuery(ingestBindingRecordKind, { where: { ownerKind: ownerKind.kind, owner: ownerId } }),
|
|
595
|
+
)
|
|
596
|
+
for (const binding of owned) {
|
|
597
|
+
const storageId = binding.recordId
|
|
540
598
|
await ctx.db.delete(binding._id)
|
|
541
599
|
if (await assetReferencesStorage(ctx, storageId)) {
|
|
542
600
|
kept.push(storageId) // the asset owns the bytes now (EC-685)
|
|
@@ -1,5 +1,48 @@
|
|
|
1
|
+
import type { CollectionDef, Locale } from '@elytracms/core/cms-core';
|
|
2
|
+
import type { TranslateMode, TranslatePlan, TranslateSkip, TranslateWrite } from '../../lib/ai/index.js';
|
|
1
3
|
import type { TranslateDocumentActionProps } from './translate-document-action.js';
|
|
4
|
+
/** What the done phase reports — what the run actually wrote, against what it planned. */
|
|
5
|
+
export interface TranslateRunSummary {
|
|
6
|
+
mode: TranslateMode;
|
|
7
|
+
plan: TranslatePlan;
|
|
8
|
+
/** Units translated (the fill message's count). */
|
|
9
|
+
translated: number;
|
|
10
|
+
locales: number;
|
|
11
|
+
cancelled: boolean;
|
|
12
|
+
/** EC-752: the writes that landed (a write refused at its revision is in {@link refused}). */
|
|
13
|
+
writes: readonly TranslateWrite[];
|
|
14
|
+
refused: readonly TranslateWrite[];
|
|
15
|
+
/** EC-752: the version saved before a re-translation wrote. */
|
|
16
|
+
restorePoint?: number;
|
|
17
|
+
/** EC-593: every text position the run could not translate — listed, never silent. */
|
|
18
|
+
skipped: readonly TranslateSkip[];
|
|
19
|
+
}
|
|
2
20
|
export declare function TranslateDocumentDialog({ projectId, collection, docRef, document, revision, localeConfig, flushDraft, open, onOpenChange, }: TranslateDocumentActionProps & {
|
|
3
21
|
open: boolean;
|
|
4
22
|
onOpenChange: (open: boolean) => void;
|
|
5
23
|
}): import("react").JSX.Element;
|
|
24
|
+
/**
|
|
25
|
+
* EC-752 — the confirm phase: the target languages, the scope choice and, for a
|
|
26
|
+
* re-translation, its plan (what it replaces, fills and leaves as it is — "up to").
|
|
27
|
+
*/
|
|
28
|
+
export declare function TranslateConfirm({ mode, onModeChange, base, targets, plan, collection, }: {
|
|
29
|
+
mode: TranslateMode;
|
|
30
|
+
onModeChange: (mode: TranslateMode) => void;
|
|
31
|
+
base: Locale;
|
|
32
|
+
targets: readonly Locale[];
|
|
33
|
+
/** The run's plan — present in `retranslate-all`. */
|
|
34
|
+
plan: TranslatePlan | null;
|
|
35
|
+
collection: CollectionDef;
|
|
36
|
+
}): import("react").JSX.Element;
|
|
37
|
+
/** The headline of the done phase (EC-752: mode-aware; slugs and replacements are listed below it). */
|
|
38
|
+
export declare function translateDoneMessage(summary: TranslateRunSummary, base: Locale): string;
|
|
39
|
+
/**
|
|
40
|
+
* EC-752 — the done phase: what was actually written (a re-translation lists what it
|
|
41
|
+
* replaced and filled), the slugs it set, what was refused at its revision, what a
|
|
42
|
+
* re-translation planned but left as it was, its restore point, and what was not translated.
|
|
43
|
+
*/
|
|
44
|
+
export declare function TranslateDoneSummary({ summary, base, collection, }: {
|
|
45
|
+
summary: TranslateRunSummary;
|
|
46
|
+
base: Locale;
|
|
47
|
+
collection: CollectionDef;
|
|
48
|
+
}): import("react").JSX.Element;
|