@elytracms/studio 0.0.101 → 0.0.103
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/asset-meta-patch.test.ts +7 -27
- package/convex/assets.ts +6 -41
- package/convex/cms.ts +2 -29
- package/convex/delivery.ts +2 -2
- package/convex/ingest.test.ts +54 -21
- package/convex/ingest.ts +52 -15
- package/convex/record-functions.ts +15 -82
- package/convex/redirects.ts +11 -116
- package/convex/schema.ts +4 -23
- package/convex/triggers.ts +7 -14
- package/dist/components/ai/ai-chat-panel.js +8 -5
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- 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 +34 -7
- package/dist/components/ai/translate-skipped.js.map +1 -1
- package/dist/components/assets/asset-detail.js +1 -2
- package/dist/components/assets/asset-detail.js.map +1 -1
- package/dist/components/assets/asset-metadata.d.ts +2 -9
- package/dist/components/assets/asset-metadata.js +3 -10
- package/dist/components/assets/asset-metadata.js.map +1 -1
- package/dist/components/assets/asset-search.js +1 -1
- package/dist/components/assets/asset-search.js.map +1 -1
- package/dist/components/assets/asset-selection.js +2 -2
- package/dist/components/assets/asset-selection.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.d.ts +2 -3
- package/dist/components/assets/assets-collection-view-source.js +2 -3
- package/dist/components/assets/assets-collection-view-source.js.map +1 -1
- package/dist/components/builder/__fixtures__/reuse-race.d.ts +30 -0
- package/dist/components/builder/__fixtures__/reuse-race.js +77 -0
- package/dist/components/builder/__fixtures__/reuse-race.js.map +1 -0
- package/dist/components/builder/composition-field-editor.js +3 -3
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/reuse-context.d.ts +2 -1
- package/dist/components/builder/reuse-context.js +5 -20
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/builder/reuse.d.ts +18 -11
- package/dist/components/builder/reuse.js +12 -14
- package/dist/components/builder/reuse.js.map +1 -1
- package/dist/components/cms/asset-field.js +1 -3
- package/dist/components/cms/asset-field.js.map +1 -1
- package/dist/components/cms/cell.d.ts +2 -2
- package/dist/components/cms/cell.js +8 -18
- package/dist/components/cms/cell.js.map +1 -1
- package/dist/components/cms/detail-editor.js +2 -1
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/field-control.js +13 -1
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/lib/ai/chat-context.d.ts +6 -2
- package/dist/lib/ai/chat-context.js +11 -5
- package/dist/lib/ai/chat-context.js.map +1 -1
- package/dist/lib/ai/knowledge.d.ts +9 -9
- package/dist/lib/ai/knowledge.js +22 -0
- package/dist/lib/ai/knowledge.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +8 -1
- package/dist/lib/ai/tools.js +33 -15
- package/dist/lib/ai/tools.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 +101 -7
- package/dist/lib/ai/translate-document.js +189 -54
- package/dist/lib/ai/translate-document.js.map +1 -1
- package/dist/lib/ai/update-fields.js +5 -12
- package/dist/lib/ai/update-fields.js.map +1 -1
- package/dist/lib/ai/writer-form.d.ts +93 -1
- package/dist/lib/ai/writer-form.js +389 -37
- package/dist/lib/ai/writer-form.js.map +1 -1
- package/dist/lib/assets/upload.d.ts +1 -2
- package/dist/lib/assets/upload.js +1 -2
- package/dist/lib/assets/upload.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 +54 -4
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +44 -1
- package/dist/lib/i18n/messages.en.js +58 -4
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +20 -10
- package/dist/lib/operations/cms-persistence.js +11 -3
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +7 -2
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/package.json +3 -3
- package/convex/asset-alt-migration.test.ts +0 -127
- package/convex/redirect-migration.test.ts +0 -205
- package/convex/version-kind-stamping.test.ts +0 -118
|
@@ -4,7 +4,7 @@ import { convexTest } from 'convex-test'
|
|
|
4
4
|
import type { ConvexHttpClient } from 'convex/browser'
|
|
5
5
|
import type { FunctionReference } from 'convex/server'
|
|
6
6
|
import { defaultAssetCollection, measureMaxDepth } from '@elytracms/core/cms-core'
|
|
7
|
-
import { InMemoryBlobStorage, InMemoryPersistenceAdapter, PersistenceValidationError
|
|
7
|
+
import { InMemoryBlobStorage, InMemoryPersistenceAdapter, PersistenceValidationError } from '@elytracms/core/persistence'
|
|
8
8
|
import type { AssetRepository } from '@elytracms/core/persistence'
|
|
9
9
|
import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
|
|
10
10
|
import { runUpload } from '../src/lib/assets/upload'
|
|
@@ -29,8 +29,7 @@ afterEach(() => {
|
|
|
29
29
|
* EC-634 — an asset's `meta` is written per key, inside the mutation, on BOTH adapters:
|
|
30
30
|
* two sessions (two adapters over ONE backend — two studio tabs on one deployment) whose
|
|
31
31
|
* dialog saves interleave keep both keys; a dialog save landing while a file replace is in
|
|
32
|
-
* flight survives the replace
|
|
33
|
-
* stored bag by the same transaction. convex-test serialises mutations, so this proves the
|
|
32
|
+
* flight survives the replace. convex-test serialises mutations, so this proves the
|
|
34
33
|
* per-key MERGE in `assets:upsert`, not Convex's OCC retry (which re-runs the same handler
|
|
35
34
|
* against the newer row — the merge is what makes that retry correct).
|
|
36
35
|
*/
|
|
@@ -42,7 +41,6 @@ const modules = {
|
|
|
42
41
|
|
|
43
42
|
const PROJECT_ID = 'asset-meta-patch'
|
|
44
43
|
const SESSION_USER_ID = 'user_admin'
|
|
45
|
-
const STAMP = '2026-10-27T00:00:00.000Z'
|
|
46
44
|
|
|
47
45
|
async function convexBackend() {
|
|
48
46
|
const t = convexTest(schema, modules)
|
|
@@ -161,31 +159,13 @@ describe('EC-634 — asset meta is patched per key in the write, on both adapter
|
|
|
161
159
|
expect(convex).toMatchObject({ alt: 'A harbour at dusk & boats', note: 'B note' })
|
|
162
160
|
})
|
|
163
161
|
|
|
164
|
-
it('a
|
|
162
|
+
it('EC-708: a write naming alt is refused before the wire — the mutation declares no alt argument', async () => {
|
|
165
163
|
const { t, a } = await convexBackend()
|
|
166
|
-
await
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
filename: 'l.jpg',
|
|
170
|
-
contentType: 'image/jpeg',
|
|
171
|
-
byteSize: 1,
|
|
172
|
-
storageKey: 'k_legacy',
|
|
173
|
-
alt: 'Column alt',
|
|
174
|
-
createdAt: STAMP,
|
|
175
|
-
updatedAt: STAMP,
|
|
176
|
-
})
|
|
177
|
-
})
|
|
178
|
-
// An edit of ANOTHER key: the alt it did not name moves home and is kept.
|
|
179
|
-
const kept = await a.upsertAsset(PROJECT_ID, { id: 'legacy', metaPatch: { credit: 'Ada' } })
|
|
180
|
-
expect(kept.meta).toEqual({ alt: 'Column alt', credit: 'Ada' })
|
|
181
|
-
expect(kept.alt).toBeUndefined()
|
|
182
|
-
expect(assetAlt(kept, null)).toBe('Column alt')
|
|
183
|
-
// A clear of the alt stays cleared — no column left to re-surface it (ASSETS D21).
|
|
184
|
-
const cleared = await a.upsertAsset(PROJECT_ID, { id: 'legacy', metaPatch: { alt: null } })
|
|
185
|
-
expect(assetAlt(cleared, null)).toBeNull()
|
|
186
|
-
const row = await t.run((ctx) => ctx.db.query('assets').filter((q) => q.eq(q.field('assetId'), 'legacy')).first())
|
|
187
|
-
expect(row?.alt).toBeUndefined()
|
|
164
|
+
await seed(a, { credit: 'Ada' })
|
|
165
|
+
await expect(a.upsertAsset(PROJECT_ID, { id: 'hero', alt: 'Column alt' } as never)).rejects.toThrow(/meta\.alt/)
|
|
166
|
+
const row = await t.run((ctx) => ctx.db.query('assets').filter((q) => q.eq(q.field('assetId'), 'hero')).first())
|
|
188
167
|
expect(row?.meta).toEqual({ credit: 'Ada' })
|
|
168
|
+
expect(row && 'alt' in row).toBe(false)
|
|
189
169
|
})
|
|
190
170
|
|
|
191
171
|
it('EC-632 — per-locale buckets are stored by the real mutation, and the stored row measures its depth', async () => {
|
package/convex/assets.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { ConvexError, v } from 'convex/values'
|
|
2
2
|
import type { GenericId, Validator } from 'convex/values'
|
|
3
3
|
import type { DocumentByName, WithoutSystemFields } from 'convex/server'
|
|
4
|
-
import {
|
|
4
|
+
import { rejectedWriteMessage, resolveAssetWrite } from '@elytracms/core/persistence'
|
|
5
5
|
import type { DataModel } from './functions'
|
|
6
|
-
import {
|
|
6
|
+
import { mutation, query } from './functions'
|
|
7
7
|
import { requireCapability } from './guard'
|
|
8
8
|
import { ingestBindingHolds } from './ingest'
|
|
9
9
|
|
|
@@ -26,9 +26,10 @@ const clearable = <T, F extends string>(validator: Validator<T, 'required', F>)
|
|
|
26
26
|
/**
|
|
27
27
|
* Write one asset (EC-577). The ONE write rule (`resolveAssetWrite`, shared with
|
|
28
28
|
* the in-memory adapter) runs here, inside the transaction: a column absent from
|
|
29
|
-
* the args keeps its stored value, `null` clears it, a value sets it —
|
|
30
|
-
*
|
|
31
|
-
* `metaPatch` changes `meta` per key against the row read here
|
|
29
|
+
* the args keeps its stored value, `null` clears it, a value sets it — there is no `alt`
|
|
30
|
+
* argument (EC-708: an asset's alt is `meta.alt`; the adapter refuses an `alt` write
|
|
31
|
+
* before the wire), and a `metaPatch` changes `meta` per key against the row read here
|
|
32
|
+
* (EC-634). An edit is
|
|
32
33
|
* ONE `db.patch` — the rule's store patch, where a cleared column is `undefined`,
|
|
33
34
|
* which `db.patch` removes — so the write-chokepoint triggers fire once. No stored
|
|
34
35
|
* asset → ONE `db.insert` of the complete row (the `defaults` fill what the write
|
|
@@ -44,13 +45,6 @@ export const upsert = mutation({
|
|
|
44
45
|
storageKey: v.optional(v.string()),
|
|
45
46
|
width: clearable(v.number()),
|
|
46
47
|
height: clearable(v.number()),
|
|
47
|
-
/**
|
|
48
|
-
* LEGACY column (EC-587): accepted on the wire only so a write that still sets it
|
|
49
|
-
* is refused BY THE RULE with a message naming `meta.alt` (not a bare validator
|
|
50
|
-
* error); `null` clears it. Dropped with the column once `liftLegacyAlt` ran on
|
|
51
|
-
* every deployment.
|
|
52
|
-
*/
|
|
53
|
-
alt: clearable(v.string()),
|
|
54
48
|
title: clearable(v.string()),
|
|
55
49
|
url: clearable(v.string()),
|
|
56
50
|
focalPoint: clearable(v.object({ x: v.number(), y: v.number() })),
|
|
@@ -124,35 +118,6 @@ export const upsert = mutation({
|
|
|
124
118
|
},
|
|
125
119
|
})
|
|
126
120
|
|
|
127
|
-
/**
|
|
128
|
-
* EC-587 — the one-shot migration of the legacy `alt` column: every asset row holding
|
|
129
|
-
* it gets the value lifted into `meta.alt` (unless `meta` already carries an `alt`
|
|
130
|
-
* key, which wins — it is what every reader already reads) and the column unset, by
|
|
131
|
-
* the ONE pure rule (core `liftLegacyAlt`, shared with the in-memory runner
|
|
132
|
-
* `liftLegacyAssetAlts`). Idempotent: a second run lifts nothing.
|
|
133
|
-
*
|
|
134
|
-
* Internal: run by an admin from the CLI, never by a client —
|
|
135
|
-
* `npx convex run assets:liftLegacyAlt` (no args). Each lifted row is one `db.patch`
|
|
136
|
-
* through the write-chokepoint triggers, so it schedules the asset's revalidation like
|
|
137
|
-
* any asset edit (the delivered alt is unchanged by the lift; the sweep is harmless).
|
|
138
|
-
* One transaction: Convex bounds it at 8192 written rows, far above any v1 library —
|
|
139
|
-
* documented, not paginated (as `cms:clearDocuments`).
|
|
140
|
-
*/
|
|
141
|
-
export const liftLegacyAlt = internalMutation({
|
|
142
|
-
args: {},
|
|
143
|
-
handler: async (ctx) => {
|
|
144
|
-
const rows = await ctx.db.query('assets').collect()
|
|
145
|
-
const lifted: string[] = []
|
|
146
|
-
for (const row of rows) {
|
|
147
|
-
const patch = legacyAltLift(row)
|
|
148
|
-
if (!patch) continue
|
|
149
|
-
await ctx.db.patch(row._id, patch as Partial<AssetDoc>)
|
|
150
|
-
lifted.push(row.assetId)
|
|
151
|
-
}
|
|
152
|
-
return { scanned: rows.length, lifted }
|
|
153
|
-
},
|
|
154
|
-
})
|
|
155
|
-
|
|
156
121
|
export const get = query({
|
|
157
122
|
args: { assetId: v.string() },
|
|
158
123
|
handler: async (ctx, args) => {
|
package/convex/cms.ts
CHANGED
|
@@ -12,9 +12,9 @@ import {
|
|
|
12
12
|
unfoldValue,
|
|
13
13
|
} from '@elytracms/core/cms-core'
|
|
14
14
|
import type { CmsDocument, DocumentPatch, PatchLogKind, ReplacePrecondition } from '@elytracms/core/cms-core'
|
|
15
|
-
import { isPinnableVersion
|
|
15
|
+
import { isPinnableVersion } from '@elytracms/core/persistence'
|
|
16
16
|
import type { DocumentVersionKind } from '@elytracms/core/persistence'
|
|
17
|
-
import {
|
|
17
|
+
import { mutation, query } from './functions'
|
|
18
18
|
import type { MutationCtx, QueryCtx } from './functions'
|
|
19
19
|
import { requireCapability } from './guard'
|
|
20
20
|
import { assertWithinWriteLimits } from './write-limits'
|
|
@@ -866,33 +866,6 @@ export const listAllDocumentVersions = query({
|
|
|
866
866
|
},
|
|
867
867
|
})
|
|
868
868
|
|
|
869
|
-
/**
|
|
870
|
-
* EC-707 — the EC-585 kind stamping: every version row recorded before EC-585 (no `kind`)
|
|
871
|
-
* gets the kind `isPinnableVersion` already reads it as — `label: 'published'` → `'publish'`,
|
|
872
|
-
* anything else → `'snapshot'` (core `planVersionKindStamps`, the same inference) — so
|
|
873
|
-
* pinnability is unchanged for every row, and once every deployment reads 0 kind-less rows
|
|
874
|
-
* the tolerant branch and the optional `kind` can go (EC-707's deletions). Idempotent: a
|
|
875
|
-
* second run stamps 0; a row that names a kind is never touched; nothing but `kind` is written.
|
|
876
|
-
*
|
|
877
|
-
* Internal: an admin runs it from the CLI, once per deployment — `npx convex run
|
|
878
|
-
* cms:stampVersionKinds` (no args) → `{ scanned, stamped }`; `pnpm demo:reseed` runs it twice.
|
|
879
|
-
* ONE transaction at a documented bound (`VERSION_KIND_STAMP_BOUND`: 4096 rows / 4 MiB of
|
|
880
|
-
* rewritten rows, half of Convex's per-transaction write limits): it reads every version row,
|
|
881
|
-
* as `listAllDocumentVersions` already does for delivery, and above the bound it throws
|
|
882
|
-
* before writing anything — a refusal is whole, never a partial stamping. `documentVersions`
|
|
883
|
-
* has no write-chokepoint trigger, so the stamp schedules no revalidation (delivery reads the
|
|
884
|
-
* same pins before and after).
|
|
885
|
-
*/
|
|
886
|
-
export const stampVersionKinds = internalMutation({
|
|
887
|
-
args: {},
|
|
888
|
-
handler: async (ctx) => {
|
|
889
|
-
const rows = await ctx.db.query('documentVersions').collect()
|
|
890
|
-
const stamps = planVersionKindStamps(rows)
|
|
891
|
-
for (const { row, kind } of stamps) await ctx.db.patch(row._id, { kind })
|
|
892
|
-
return { scanned: rows.length, stamped: stamps.length }
|
|
893
|
-
},
|
|
894
|
-
})
|
|
895
|
-
|
|
896
869
|
/**
|
|
897
870
|
* Pin an EXISTING version live (EC-224 `pinVersion`, changeset revert). Pin integrity is
|
|
898
871
|
* enforced here, in the transaction: the version must exist for THIS document
|
package/convex/delivery.ts
CHANGED
|
@@ -181,8 +181,8 @@ export async function loadSnapshot(
|
|
|
181
181
|
|
|
182
182
|
// Editorial redirects (EC-303): only ENABLED entries serve — a disabled redirect is
|
|
183
183
|
// stored but never live, in either perspective. EC-616: read through the redirect
|
|
184
|
-
// kind's one server-side reader (records
|
|
185
|
-
//
|
|
184
|
+
// kind's one server-side reader (the `records` store) and decided by the ONE
|
|
185
|
+
// live-redirect rule, `readLiveRedirects` (EC-590 / EC-627 — this
|
|
186
186
|
// was the last hand-built copy on Convex). EC-628: a stored redirect the rule cannot
|
|
187
187
|
// read (a non-boolean switch, a non-string `to`) is withheld AND reported — the host
|
|
188
188
|
// carries `redirectIssues` into its source issues — instead of being served or dropped
|
package/convex/ingest.test.ts
CHANGED
|
@@ -77,11 +77,12 @@ const testIngestModule = {
|
|
|
77
77
|
}
|
|
78
78
|
if (payload.kind !== 'submission') return { ok: false, status: 422, message: 'Not a submission.' }
|
|
79
79
|
if (payload.target !== 'contact') return { ok: false, status: 404, message: `No form “${payload.target}”.` }
|
|
80
|
+
// EC-750: the row's id first — the claim binds every file to it (the owner release is asked for).
|
|
81
|
+
const id = `sub_${(await ctx.db.query('records').collect()).length + 1}`
|
|
80
82
|
// 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)
|
|
83
|
+
const claim = await claimIngestUploads(ctx, payload.uploads ?? [], payload.target, args.receivedAt, id)
|
|
82
84
|
if (!claim.ok) return claim
|
|
83
85
|
const files = claim.files.map((file) => file.storageId)
|
|
84
|
-
const id = `sub_${(await ctx.db.query('records').collect()).length + 1}`
|
|
85
86
|
await saveRecord(
|
|
86
87
|
ctx,
|
|
87
88
|
testSubmissionKind,
|
|
@@ -340,9 +341,10 @@ describe('the ingest receiver — dispatch (EC-617)', () => {
|
|
|
340
341
|
/**
|
|
341
342
|
* EC-671 — a claim binds only bytes the deployment's own facts say are this intent's, each
|
|
342
343
|
* 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
|
|
344
|
+
* and `releaseIngestUploads` deletes only what a claim bound to the OWNER it is asked for
|
|
345
|
+
* (EC-750). The forms addon's end-to-end probes (its rollback and removal, the attacker's
|
|
346
|
+
* harvest; a sibling's removal) are `packages/forms/src/convex/claim-ownership.convex.test.ts`
|
|
347
|
+
* and `sibling-release.convex.test.ts`.
|
|
346
348
|
*/
|
|
347
349
|
describe('the claim binds only this intent’s bytes; release deletes only bound bytes (EC-671)', () => {
|
|
348
350
|
const PNG = { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 500 } as const
|
|
@@ -352,6 +354,11 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
352
354
|
}
|
|
353
355
|
const claimWith = (t: ReturnType<typeof backend>, uploadId: string, storageId: string) =>
|
|
354
356
|
signedPost(t, { ...SUBMISSION, uploads: [{ uploadId, storageId }] })
|
|
357
|
+
/** The id of the row an accepted submission wrote — the owner its claim bound the files to (EC-750). */
|
|
358
|
+
const ownerOf = async (answer: Response) => {
|
|
359
|
+
expect(answer.status).toBe(200)
|
|
360
|
+
return ((await answer.json()) as { id: string }).id
|
|
361
|
+
}
|
|
355
362
|
async function insertAsset(t: ReturnType<typeof backend>, files: { storageKey: string; thumb?: string }) {
|
|
356
363
|
await t.run(async (ctx) => {
|
|
357
364
|
await ctx.db.insert('assets', {
|
|
@@ -372,6 +379,15 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
372
379
|
.filter((row) => row.kind === 'ingestBinding')
|
|
373
380
|
.map((row) => ({ storageId: row.recordId, uploadId: (row.data as { uploadId: string }).uploadId })),
|
|
374
381
|
)
|
|
382
|
+
/** EC-750: every binding's owner, by its storage id. */
|
|
383
|
+
const owners = (t: ReturnType<typeof backend>) =>
|
|
384
|
+
t.run(async (ctx) =>
|
|
385
|
+
Object.fromEntries(
|
|
386
|
+
(await ctx.db.query('records').collect())
|
|
387
|
+
.filter((row) => row.kind === 'ingestBinding')
|
|
388
|
+
.map((row) => [row.recordId, (row.data as { owner: string }).owner]),
|
|
389
|
+
),
|
|
390
|
+
)
|
|
375
391
|
|
|
376
392
|
it('(1) a file stored BEFORE the intent — in the same millisecond — is refused, rejected and kept; one stored after is claimed', async () => {
|
|
377
393
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
@@ -429,18 +445,24 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
429
445
|
expect([await stored(t, original), await stored(t, preview)]).toEqual([true, true])
|
|
430
446
|
})
|
|
431
447
|
|
|
432
|
-
it('releaseIngestUploads deletes
|
|
448
|
+
it('EC-750: releaseIngestUploads deletes the files its OWNER claimed, with their bindings — never another owner’s, never an asset’s', async () => {
|
|
433
449
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
434
450
|
const t = backend()
|
|
435
|
-
const
|
|
451
|
+
const [mine, theirs] = [await declare(t), await declare(t)]
|
|
436
452
|
const own = await storeFile(t, 400, 'image/png')
|
|
437
|
-
|
|
453
|
+
const other = await storeFile(t, 400, 'image/png')
|
|
454
|
+
const owner = await ownerOf(await claimWith(t, mine, own))
|
|
455
|
+
const otherOwner = await ownerOf(await claimWith(t, theirs, other))
|
|
438
456
|
const assetFile = await storeFile(t, 400, 'image/png')
|
|
439
457
|
await insertAsset(t, { storageKey: assetFile })
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
expect(
|
|
443
|
-
expect(await
|
|
458
|
+
expect(await owners(t)).toEqual({ [own]: owner, [other]: otherOwner })
|
|
459
|
+
const released = await t.run((ctx) => releaseIngestUploads(ctx, owner))
|
|
460
|
+
expect(released).toEqual({ deleted: [own], kept: [] })
|
|
461
|
+
expect([await stored(t, own), await stored(t, other), await stored(t, assetFile)]).toEqual([false, true, true])
|
|
462
|
+
expect(await bindings(t)).toEqual([{ storageId: other, uploadId: theirs }])
|
|
463
|
+
// An owner with nothing bound any more (released already, or a row that never claimed) deletes nothing.
|
|
464
|
+
expect(await t.run((ctx) => releaseIngestUploads(ctx, owner))).toEqual({ deleted: [], kept: [] })
|
|
465
|
+
expect(await t.run((ctx) => releaseIngestUploads(ctx, 'sub_never'))).toEqual({ deleted: [], kept: [] })
|
|
444
466
|
})
|
|
445
467
|
|
|
446
468
|
// EC-685 (Codex r4 F20, ordering 2): a fact checked at claim time is not permanent — an
|
|
@@ -460,12 +482,20 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
460
482
|
const promoted = await storeFile(t, 400, 'image/png')
|
|
461
483
|
const preview = await storeFile(t, 40, 'image/png')
|
|
462
484
|
const unreferenced = await storeFile(t, 400, 'image/png')
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
485
|
+
// One submission claims all three (EC-750: one owner).
|
|
486
|
+
const owner = await ownerOf(
|
|
487
|
+
await signedPost(t, {
|
|
488
|
+
...SUBMISSION,
|
|
489
|
+
uploads: [
|
|
490
|
+
{ uploadId: asFile, storageId: promoted },
|
|
491
|
+
{ uploadId: asThumb, storageId: preview },
|
|
492
|
+
{ uploadId: plain, storageId: unreferenced },
|
|
493
|
+
],
|
|
494
|
+
}),
|
|
495
|
+
)
|
|
466
496
|
// An editor promotes two of the uploads to an asset (its file and its preview) — after the claims.
|
|
467
497
|
await insertAsset(t, { storageKey: promoted, thumb: preview })
|
|
468
|
-
const released = await t.run((ctx) => releaseIngestUploads(ctx,
|
|
498
|
+
const released = await t.run((ctx) => releaseIngestUploads(ctx, owner))
|
|
469
499
|
expect(released).toEqual({ deleted: [unreferenced], kept: [promoted, preview] })
|
|
470
500
|
expect([await stored(t, promoted), await stored(t, preview), await stored(t, unreferenced)]).toEqual([true, true, false])
|
|
471
501
|
expect(await bindings(t)).toEqual([]) // ownership passed: no binding is left to hold the asset's bytes
|
|
@@ -480,7 +510,7 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
480
510
|
const t = backend()
|
|
481
511
|
const uploadId = await declare(t)
|
|
482
512
|
const own = await storeFile(t, 400, 'image/png')
|
|
483
|
-
|
|
513
|
+
const owner = await ownerOf(await claimWith(t, uploadId, own))
|
|
484
514
|
await insertAsset(t, { storageKey: own }) // promoted to an asset while the submission still holds it
|
|
485
515
|
const editor = await asEditor(t)
|
|
486
516
|
expect(await editor.mutation(deleteBlob, { storageKey: own })).toEqual({ deleted: false, heldBy: 'ingest' })
|
|
@@ -490,7 +520,7 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
490
520
|
await t.run(async (ctx) => {
|
|
491
521
|
for (const row of await ctx.db.query('assets').collect()) await ctx.db.delete(row._id)
|
|
492
522
|
})
|
|
493
|
-
expect(await t.run((ctx) => releaseIngestUploads(ctx,
|
|
523
|
+
expect(await t.run((ctx) => releaseIngestUploads(ctx, owner))).toEqual({ deleted: [own], kept: [] })
|
|
494
524
|
expect(await stored(t, own)).toBe(false)
|
|
495
525
|
})
|
|
496
526
|
|
|
@@ -593,9 +623,12 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
593
623
|
const t = backend()
|
|
594
624
|
const uploadId = await declare(t)
|
|
595
625
|
const own = await storeFile(t, 400, 'image/png')
|
|
596
|
-
|
|
626
|
+
const owner = await ownerOf(await claimWith(t, uploadId, own))
|
|
597
627
|
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' })
|
|
628
|
+
expect(row.data).toEqual({ uploadId, target: 'contact', owner, boundAt: '2026-10-26T12:00:00.000Z' })
|
|
629
|
+
// EC-750: the owner fills the static `key1` slot (`by_kind_key1`) — no schema or index change.
|
|
630
|
+
expect(row.key1).toBe(owner)
|
|
631
|
+
expect(row.key2).toBeUndefined()
|
|
599
632
|
expect(measureMaxDepth(row).depth).toBe(2) // the row object → `data` → its string columns
|
|
600
633
|
expect(measureMaxDepth(row).depth).toBeLessThanOrEqual(CONVEX_MAX_NESTING_DEPTH)
|
|
601
634
|
})
|
|
@@ -610,7 +643,7 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
610
643
|
),
|
|
611
644
|
)
|
|
612
645
|
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'))
|
|
646
|
+
t.run((ctx) => claimIngestUploads(ctx, claims, 'contact', '2026-10-26T12:00:00.000Z', 'sub_direct'))
|
|
614
647
|
|
|
615
648
|
it('EC-695: a submission with one refused claim binds NOTHING — the claims before it stay issued; nothing is deleted', async () => {
|
|
616
649
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
package/convex/ingest.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type { IngestHandlerResult, IngestPayload, IngestUploadClaim } from '@ely
|
|
|
18
18
|
import { defineRecordKind, resolveRecordQuery } from '@elytracms/core/persistence'
|
|
19
19
|
import { createUuidIdFactory } from '@elytracms/core/determinism'
|
|
20
20
|
import type { MutationCtx, QueryCtx } from './functions'
|
|
21
|
-
import { findRecordDoc, listRecordWire, saveRecord } from './record-functions'
|
|
21
|
+
import { findRecordDoc, listRecordWire, queryRecordDocs, saveRecord } from './record-functions'
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* EC-617 — the ONE inbound receiver of the public ingest seam (ruling EC-565 § 6c), and the
|
|
@@ -44,7 +44,8 @@ import { findRecordDoc, listRecordWire, saveRecord } from './record-functions'
|
|
|
44
44
|
* claims its stored files with {@link claimIngestUploads}, which checks that the deployment
|
|
45
45
|
* stored each for ITS intent (EC-671) — its bytes the ones the upload request declared by
|
|
46
46
|
* SHA-256 (EC-685) — and that it matches the declared size and type, and binds them all or
|
|
47
|
-
* none (EC-695)
|
|
47
|
+
* none (EC-695) to the record that claims them (EC-750); {@link releaseIngestUploads}, asked
|
|
48
|
+
* for that record, is the only way such bytes are deleted.
|
|
48
49
|
*
|
|
49
50
|
* The engine never imports an addon: the handler is an internal-mutation REFERENCE the
|
|
50
51
|
* consumer passes in its one `http.ts` line (`defineStudioHttp({ auth, ingest: {
|
|
@@ -195,6 +196,11 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
|
|
|
195
196
|
// it never deletes, and ingest bytes are deleted only through {@link releaseIngestUploads},
|
|
196
197
|
// which deletes only what a claim bound.
|
|
197
198
|
//
|
|
199
|
+
// EC-750 (Codex r9 F46) — bound to WHOM. A binding records the record that claimed the file
|
|
200
|
+
// (`owner`), and release is asked for an owner, never for storage ids: removing a row deletes
|
|
201
|
+
// only the files that row claimed. Before, release took the removed row's `files` ids — a column
|
|
202
|
+
// a whole-record write can fill with another row's file — and deleted any bound one.
|
|
203
|
+
//
|
|
198
204
|
// EC-685 — causation. Eligibility (stored after the intent, unbound, no asset's) is not
|
|
199
205
|
// ownership: with intents A and B open, bytes stored through B's URL were eligible for A. So
|
|
200
206
|
// the intent DECLARES the bytes' SHA-256 before they exist, and the claim requires the digest
|
|
@@ -236,14 +242,25 @@ export const ingestUploadRecordKind = defineRecordKind({
|
|
|
236
242
|
* and "is it bound?" is one indexed read. Written only by {@link claimIngestUploads}, in the
|
|
237
243
|
* claim's transaction; read by the claim (a file another claim bound is refused) and by
|
|
238
244
|
* {@link releaseIngestUploads}. Engine-internal (no public functions), like the intent.
|
|
245
|
+
*
|
|
246
|
+
* EC-750 (Codex r9 F46) — and WHOSE: `owner` is the id of the record that claimed the file (a
|
|
247
|
+
* forms submission), its `key1` slot, so "this record's files" is one indexed read
|
|
248
|
+
* (`by_kind_key1`) and release asks that question, never "is this id bound?". `target` is the
|
|
249
|
+
* intent's (the form every submission to it shares), so it cannot say whose a file is. A
|
|
250
|
+
* binding written before EC-750 has no owner and no slot: no release reads it, so it is never
|
|
251
|
+
* deleted and keeps protecting its bytes from `assets:deleteBlob` (residue, never loss — EC-671's
|
|
252
|
+
* no-migration precedent).
|
|
239
253
|
*/
|
|
240
254
|
export const ingestBindingRecordKind = defineRecordKind({
|
|
241
255
|
kind: 'ingestBinding',
|
|
242
256
|
module: 'ingest',
|
|
257
|
+
keys: ['owner'],
|
|
243
258
|
time: 'boundAt',
|
|
244
259
|
fields: {
|
|
245
260
|
uploadId: z.string().min(1),
|
|
246
261
|
target: z.string().min(1),
|
|
262
|
+
/** EC-750: the id of the record that claimed the file — what {@link releaseIngestUploads} is asked for. */
|
|
263
|
+
owner: z.string().min(1),
|
|
247
264
|
boundAt: z.string().min(1),
|
|
248
265
|
},
|
|
249
266
|
})
|
|
@@ -403,12 +420,18 @@ async function assetReferencesStorage(ctx: MutationCtx, storageId: string): Prom
|
|
|
403
420
|
* A handler calls this ONCE per submission, after every check of its own that can refuse: a
|
|
404
421
|
* mutation that RETURNS commits its writes (a claim made before a later refusal stays bound with
|
|
405
422
|
* no row to hold it); one that THROWS commits nothing.
|
|
423
|
+
*
|
|
424
|
+
* EC-750: `owner` is the id of the record the handler is about to write — mint it BEFORE the
|
|
425
|
+
* claim. Every binding records it, and {@link releaseIngestUploads} deletes a file only for the
|
|
426
|
+
* owner that claimed it: a row naming another row's file (an editor's whole-record write can
|
|
427
|
+
* put any id in its `files`) never deletes it.
|
|
406
428
|
*/
|
|
407
429
|
export async function claimIngestUploads(
|
|
408
430
|
ctx: MutationCtx,
|
|
409
431
|
claims: readonly IngestUploadClaim[],
|
|
410
432
|
target: string,
|
|
411
433
|
receivedAt: string,
|
|
434
|
+
owner: string,
|
|
412
435
|
): Promise<IngestUploadsClaimResult> {
|
|
413
436
|
const accepted: AcceptedClaim[] = []
|
|
414
437
|
for (const [index, claim] of claims.entries()) {
|
|
@@ -418,7 +441,13 @@ export async function claimIngestUploads(
|
|
|
418
441
|
}
|
|
419
442
|
const stamps = { createdAt: receivedAt, updatedAt: receivedAt }
|
|
420
443
|
for (const claim of accepted) {
|
|
421
|
-
await saveRecord(
|
|
444
|
+
await saveRecord(
|
|
445
|
+
ctx,
|
|
446
|
+
ingestBindingRecordKind,
|
|
447
|
+
claim.storageId,
|
|
448
|
+
{ uploadId: claim.uploadId, target, owner, boundAt: receivedAt },
|
|
449
|
+
stamps,
|
|
450
|
+
)
|
|
422
451
|
await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'claimed', storageId: claim.storageId }, stamps)
|
|
423
452
|
}
|
|
424
453
|
return { ok: true, files: accepted.map(({ storageId, field, contentType, size }) => ({ storageId, field, contentType, size })) }
|
|
@@ -508,18 +537,25 @@ async function judgeIngestUploadClaim(
|
|
|
508
537
|
}
|
|
509
538
|
|
|
510
539
|
/**
|
|
511
|
-
* Delete ingest bytes
|
|
512
|
-
*
|
|
513
|
-
*
|
|
540
|
+
* Delete the ingest bytes ONE record claimed (EC-671, EC-750) — the ONE way ingest bytes are
|
|
541
|
+
* deleted. It is asked for an OWNER (the id {@link claimIngestUploads} bound the files to), never
|
|
542
|
+
* for storage ids: it reads that owner's bindings by their key (`by_kind_key1`) and deletes each
|
|
543
|
+
* file with its binding — so it never sees, and never writes, another owner's binding. A row's
|
|
544
|
+
* `files` column is the visitor's and the editor's word (a whole-record write can name any id,
|
|
545
|
+
* or clear the list), so release never reads it: a row naming another row's file deletes
|
|
546
|
+
* nothing, and a row whose list was cleared still deletes what it claimed.
|
|
547
|
+
*
|
|
548
|
+
* Of the owner's files it KEEPS, returned in `kept`:
|
|
514
549
|
*
|
|
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
550
|
* - EC-685: a bound file an asset row references now (`storageKey` or `thumb.storageKey` — an
|
|
518
551
|
* editor promoted the upload to an asset with `assets:upsert`, a legitimate edit). Ownership
|
|
519
552
|
* has passed to the asset: the binding is spent (deleted) and the bytes stay, so the
|
|
520
553
|
* asset's own delete removes them later. Read in this transaction, so no asset can acquire
|
|
521
554
|
* the file between the check and the delete.
|
|
522
555
|
*
|
|
556
|
+
* Never touched: a binding of another owner, and a binding written before EC-750 (no owner, no
|
|
557
|
+
* key slot — residue, never loss; it keeps protecting its bytes from `assets:deleteBlob`).
|
|
558
|
+
*
|
|
523
559
|
* Call it when removing a row that holds claimed files — never for a refused submission, which
|
|
524
560
|
* bound nothing ({@link claimIngestUploads}, EC-695). The mirror is `assets:deleteBlob`, which keeps bytes a binding holds
|
|
525
561
|
* ({@link ingestBindingHolds}): each delete path re-checks the OTHER owner in its own
|
|
@@ -527,16 +563,17 @@ async function judgeIngestUploadClaim(
|
|
|
527
563
|
*/
|
|
528
564
|
export async function releaseIngestUploads(
|
|
529
565
|
ctx: MutationCtx,
|
|
530
|
-
|
|
566
|
+
owner: string,
|
|
531
567
|
): Promise<{ deleted: string[]; kept: string[] }> {
|
|
532
568
|
const deleted: string[] = []
|
|
533
569
|
const kept: string[] = []
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
570
|
+
const owned = await queryRecordDocs(
|
|
571
|
+
ctx,
|
|
572
|
+
ingestBindingRecordKind,
|
|
573
|
+
resolveRecordQuery(ingestBindingRecordKind, { where: { owner } }),
|
|
574
|
+
)
|
|
575
|
+
for (const binding of owned) {
|
|
576
|
+
const storageId = binding.recordId
|
|
540
577
|
await ctx.db.delete(binding._id)
|
|
541
578
|
if (await assetReferencesStorage(ctx, storageId)) {
|
|
542
579
|
kept.push(storageId) // the asset owns the bytes now (EC-685)
|