@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.
Files changed (90) hide show
  1. package/convex/asset-meta-patch.test.ts +7 -27
  2. package/convex/assets.ts +6 -41
  3. package/convex/cms.ts +2 -29
  4. package/convex/delivery.ts +2 -2
  5. package/convex/ingest.test.ts +54 -21
  6. package/convex/ingest.ts +52 -15
  7. package/convex/record-functions.ts +15 -82
  8. package/convex/redirects.ts +11 -116
  9. package/convex/schema.ts +4 -23
  10. package/convex/triggers.ts +7 -14
  11. package/dist/components/ai/ai-chat-panel.js +8 -5
  12. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  13. package/dist/components/ai/translate-document-dialog.d.ts +43 -0
  14. package/dist/components/ai/translate-document-dialog.js +104 -36
  15. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  16. package/dist/components/ai/translate-skipped.d.ts +19 -3
  17. package/dist/components/ai/translate-skipped.js +34 -7
  18. package/dist/components/ai/translate-skipped.js.map +1 -1
  19. package/dist/components/assets/asset-detail.js +1 -2
  20. package/dist/components/assets/asset-detail.js.map +1 -1
  21. package/dist/components/assets/asset-metadata.d.ts +2 -9
  22. package/dist/components/assets/asset-metadata.js +3 -10
  23. package/dist/components/assets/asset-metadata.js.map +1 -1
  24. package/dist/components/assets/asset-search.js +1 -1
  25. package/dist/components/assets/asset-search.js.map +1 -1
  26. package/dist/components/assets/asset-selection.js +2 -2
  27. package/dist/components/assets/asset-selection.js.map +1 -1
  28. package/dist/components/assets/assets-collection-view-source.d.ts +2 -3
  29. package/dist/components/assets/assets-collection-view-source.js +2 -3
  30. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  31. package/dist/components/builder/__fixtures__/reuse-race.d.ts +30 -0
  32. package/dist/components/builder/__fixtures__/reuse-race.js +77 -0
  33. package/dist/components/builder/__fixtures__/reuse-race.js.map +1 -0
  34. package/dist/components/builder/composition-field-editor.js +3 -3
  35. package/dist/components/builder/composition-field-editor.js.map +1 -1
  36. package/dist/components/builder/reuse-context.d.ts +2 -1
  37. package/dist/components/builder/reuse-context.js +5 -20
  38. package/dist/components/builder/reuse-context.js.map +1 -1
  39. package/dist/components/builder/reuse.d.ts +18 -11
  40. package/dist/components/builder/reuse.js +12 -14
  41. package/dist/components/builder/reuse.js.map +1 -1
  42. package/dist/components/cms/asset-field.js +1 -3
  43. package/dist/components/cms/asset-field.js.map +1 -1
  44. package/dist/components/cms/cell.d.ts +2 -2
  45. package/dist/components/cms/cell.js +8 -18
  46. package/dist/components/cms/cell.js.map +1 -1
  47. package/dist/components/cms/detail-editor.js +2 -1
  48. package/dist/components/cms/detail-editor.js.map +1 -1
  49. package/dist/components/cms/field-control.js +13 -1
  50. package/dist/components/cms/field-control.js.map +1 -1
  51. package/dist/lib/ai/chat-context.d.ts +6 -2
  52. package/dist/lib/ai/chat-context.js +11 -5
  53. package/dist/lib/ai/chat-context.js.map +1 -1
  54. package/dist/lib/ai/knowledge.d.ts +9 -9
  55. package/dist/lib/ai/knowledge.js +22 -0
  56. package/dist/lib/ai/knowledge.js.map +1 -1
  57. package/dist/lib/ai/tools.d.ts +8 -1
  58. package/dist/lib/ai/tools.js +33 -15
  59. package/dist/lib/ai/tools.js.map +1 -1
  60. package/dist/lib/ai/translate-apply.d.ts +69 -0
  61. package/dist/lib/ai/translate-apply.js +36 -0
  62. package/dist/lib/ai/translate-apply.js.map +1 -0
  63. package/dist/lib/ai/translate-document.d.ts +101 -7
  64. package/dist/lib/ai/translate-document.js +189 -54
  65. package/dist/lib/ai/translate-document.js.map +1 -1
  66. package/dist/lib/ai/update-fields.js +5 -12
  67. package/dist/lib/ai/update-fields.js.map +1 -1
  68. package/dist/lib/ai/writer-form.d.ts +93 -1
  69. package/dist/lib/ai/writer-form.js +389 -37
  70. package/dist/lib/ai/writer-form.js.map +1 -1
  71. package/dist/lib/assets/upload.d.ts +1 -2
  72. package/dist/lib/assets/upload.js +1 -2
  73. package/dist/lib/assets/upload.js.map +1 -1
  74. package/dist/lib/engine-version.d.ts +1 -1
  75. package/dist/lib/engine-version.js +1 -1
  76. package/dist/lib/engine-version.js.map +1 -1
  77. package/dist/lib/i18n/messages.de.js +54 -4
  78. package/dist/lib/i18n/messages.de.js.map +1 -1
  79. package/dist/lib/i18n/messages.en.d.ts +44 -1
  80. package/dist/lib/i18n/messages.en.js +58 -4
  81. package/dist/lib/i18n/messages.en.js.map +1 -1
  82. package/dist/lib/operations/cms-persistence.d.ts +20 -10
  83. package/dist/lib/operations/cms-persistence.js +11 -3
  84. package/dist/lib/operations/cms-persistence.js.map +1 -1
  85. package/dist/lib/persistence/convex-adapter.js +7 -2
  86. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  87. package/package.json +3 -3
  88. package/convex/asset-alt-migration.test.ts +0 -127
  89. package/convex/redirect-migration.test.ts +0 -205
  90. 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, assetAlt } from '@elytracms/core/persistence'
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; a not-yet-migrated legacy `alt` column is lifted into the
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 patch lifts a not-yet-migrated legacy alt column into the stored bag, in the same mutation', async () => {
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 t.run(async (ctx) => {
167
- await ctx.db.insert('assets', {
168
- assetId: 'legacy',
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 { liftLegacyAlt as legacyAltLift, rejectedWriteMessage, resolveAssetWrite } from '@elytracms/core/persistence'
4
+ import { rejectedWriteMessage, resolveAssetWrite } from '@elytracms/core/persistence'
5
5
  import type { DataModel } from './functions'
6
- import { internalMutation, mutation, query } from './functions'
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 — the legacy
30
- * `alt` column can only be cleared (EC-587: an asset's alt is `meta.alt`), and a
31
- * `metaPatch` changes `meta` per key against the row read here (EC-634). An edit is
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, planVersionKindStamps } from '@elytracms/core/persistence'
15
+ import { isPinnableVersion } from '@elytracms/core/persistence'
16
16
  import type { DocumentVersionKind } from '@elytracms/core/persistence'
17
- import { internalMutation, mutation, query } from './functions'
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
@@ -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 + the marked legacy tolerant read) and
185
- // decided by the ONE live-redirect rule, `readLiveRedirects` (EC-590 / EC-627 — this
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
@@ -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. The forms addon's end-to-end
344
- * probe (its rollback and removal, the attacker's harvest) is
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 a bound file and its binding, keeps an unbound one (an asset’s), and dedupes', async () => {
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 uploadId = await declare(t)
451
+ const [mine, theirs] = [await declare(t), await declare(t)]
436
452
  const own = await storeFile(t, 400, 'image/png')
437
- expect((await claimWith(t, uploadId, own)).status).toBe(200)
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
- const released = await t.run((ctx) => releaseIngestUploads(ctx, [own, assetFile, own]))
441
- expect(released).toEqual({ deleted: [own], kept: [assetFile] })
442
- expect([await stored(t, own), await stored(t, assetFile)]).toEqual([false, true])
443
- expect(await bindings(t)).toEqual([])
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
- for (const [uploadId, file] of [[asFile, promoted], [asThumb, preview], [plain, unreferenced]] as const) {
464
- expect((await claimWith(t, uploadId, file)).status).toBe(200)
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, [promoted, preview, unreferenced]))
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
- expect((await claimWith(t, uploadId, own)).status).toBe(200)
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, [own]))).toEqual({ deleted: [own], kept: [] })
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
- expect((await claimWith(t, uploadId, own)).status).toBe(200)
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); {@link releaseIngestUploads} is the only way such bytes are deleted.
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(ctx, ingestBindingRecordKind, claim.storageId, { uploadId: claim.uploadId, target, boundAt: receivedAt }, stamps)
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 an addon holds (EC-671) — the ONE way they are deleted, and it deletes
512
- * only files a claim BOUND that no asset references NOW: the bytes and their binding go
513
- * together. Any other id is KEPT and returned in `kept`:
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
- storageIds: readonly string[],
566
+ owner: string,
531
567
  ): Promise<{ deleted: string[]; kept: string[] }> {
532
568
  const deleted: string[] = []
533
569
  const kept: string[] = []
534
- for (const storageId of new Set(storageIds)) {
535
- const binding = await findRecordDoc(ctx, ingestBindingRecordKind, storageId)
536
- if (!binding) {
537
- kept.push(storageId)
538
- continue
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)