@elytracms/forms 0.0.103 → 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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elytracms/forms",
3
3
  "author": "finaldream <hi@finaldream.de>",
4
- "version": "0.0.103",
4
+ "version": "0.0.104",
5
5
  "engines": {
6
6
  "node": ">=22.18.0"
7
7
  },
@@ -28,13 +28,13 @@
28
28
  "dependencies": {
29
29
  "lucide-react": "^1.23.0",
30
30
  "zod": "^4.4.3",
31
- "@elytracms/core": "^0.0.103"
31
+ "@elytracms/core": "^0.0.104"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "convex": "^1.42.1",
35
35
  "react": "^19.0.0",
36
- "@elytracms/next": "^0.0.103",
37
- "@elytracms/studio": "^0.0.103"
36
+ "@elytracms/next": "^0.0.104",
37
+ "@elytracms/studio": "^0.0.104"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
40
  "@elytracms/next": {
@@ -59,8 +59,8 @@
59
59
  "react": "^19.2.7",
60
60
  "react-dom": "^19.2.7",
61
61
  "typescript": "^6.0.3",
62
- "@elytracms/next": "^0.0.103",
63
- "@elytracms/studio": "^0.0.103"
62
+ "@elytracms/next": "^0.0.104",
63
+ "@elytracms/studio": "^0.0.104"
64
64
  },
65
65
  "files": [
66
66
  "dist",
@@ -195,21 +195,22 @@ describe('pnpm dev:ingest — every scenario against the real engine (convex-tes
195
195
  expect(await t.run(async (ctx) => (await ctx.db.system.query('_storage').collect()).length)).toBe(0)
196
196
  })
197
197
 
198
- it('the EC-750 scenario: 11/11 — a sibling forged by forms:save with B’s files is removed, B keeps its bytes and binding (the close’s expected lines)', async () => {
198
+ it('the EC-750 scenario: 12/12 — a sibling forged by forms:save with B’s files is removed, B keeps its bytes and binding (the close’s expected lines)', async () => {
199
199
  const core = await runner()
200
200
  const t = await backend()
201
201
  const lines: string[] = []
202
202
  const scenario = core.INGEST_SCENARIOS.find((entry) => entry.name === 'ec750')!
203
203
  const result = await core.runIngestScenario(scenario, port(core, t), { say: (line) => lines.push(line), wire, runId: 'r', now: () => T0 })
204
- expect(result).toEqual({ ok: true, passed: 11, failed: [] })
204
+ expect(result).toEqual({ ok: true, passed: 12, failed: [] })
205
205
  expect(lines).toEqual(
206
206
  expect.arrayContaining([
207
207
  ' ✓ B’s binding records B as its owner: true',
208
+ ' ✓ …and B’s kind: true', // EC-762: the owner is the record's kind AND id
208
209
  ' ✓ A, created by forms:save, lists B’s file: true',
209
210
  ' ✓ removing A keeps B’s bytes: true',
210
211
  ' ✓ …and B’s binding: true',
211
212
  ' ✓ removing B deletes B’s file: false',
212
- '✓ ec750: 11/11 expectation(s) held',
213
+ '✓ ec750: 12/12 expectation(s) held',
213
214
  ]),
214
215
  )
215
216
  expect(await leftovers(t)).toEqual({ submissions: 0, bindings: 0, assets: 0 })
@@ -66,8 +66,8 @@ import type { FormDeliveryRequest } from '../shared/delivery'
66
66
  * predictable file another intent stored can never be deleted through a rollback). The
67
67
  * one deletion, `remove`, goes through the engine's `releaseIngestUploads`, which deletes
68
68
  * only bytes a claim bound (EC-671) to THAT row (EC-750 — every binding records the row
69
- * that claimed it): an id a visitor or an editor merely NAMES is never deleted, even
70
- * another row's claimed file;
69
+ * that claimed it, by its kind and id, EC-762): an id a visitor or an editor merely NAMES is
70
+ * never deleted, even another row's claimed file;
71
71
  * - **`deliver`** — the scheduled action per row: it asks the site's delivery route to send
72
72
  * (`attemptFormDelivery`) and `recordDelivery` logs the answer per recipient and schedules
73
73
  * the next attempt (`applyDeliveryOutcome`);
@@ -139,10 +139,11 @@ export const save = mutation({
139
139
 
140
140
  /**
141
141
  * Delete a submission AND the files it claimed (no claimed bytes outlive their row). EC-671 /
142
- * EC-750: through `releaseIngestUploads`, asked for THIS row's id — it deletes only the files
143
- * this row's claim bound, read by owner, never the row's `files` column (an editor's write can
144
- * put any id there, another row's file included, or clear it) — and, EC-685, keeps a claimed
145
- * file an editor has since made an asset of (the asset owns it now).
142
+ * EC-750: through `releaseIngestUploads`, asked for THIS row — the submission kind and its id
143
+ * (EC-762) — it deletes only the files this row's claim bound, read by owner, never the row's
144
+ * `files` column (an editor's write can put any id there, another row's file included, or clear
145
+ * it) — and, EC-685, keeps a claimed file an editor has since made an asset of (the asset owns
146
+ * it now).
146
147
  */
147
148
  export const remove = mutation({
148
149
  args: { [kind.idKey]: v.string() },
@@ -150,7 +151,7 @@ export const remove = mutation({
150
151
  await requireCapability(ctx, kind.access.write)
151
152
  const doc = await findRecordDoc(ctx, kind, args[kind.idKey] as string)
152
153
  if (!doc) return
153
- await releaseIngestUploads(ctx, doc.recordId)
154
+ await releaseIngestUploads(ctx, kind, doc.recordId)
154
155
  await ctx.db.delete(doc._id)
155
156
  },
156
157
  })
@@ -179,14 +180,14 @@ export const ingest = internalMutation({
179
180
  if (!read.ok) return { ok: false, status: read.status, message: read.message, issues: read.issues }
180
181
 
181
182
  // EC-578: the one id factory (UUID v4) — never a clock or a counter. EC-750: minted BEFORE
182
- // the claim, which binds every file to this row (a refused claim leaves the id unused; a
183
- // retried request re-mints).
183
+ // the claim, which binds every file to this row — its kind and this id, EC-762 (a refused
184
+ // claim leaves the id unused; a retried request re-mints).
184
185
  mintSubmissionUuid ??= createUuidIdFactory()
185
186
  const id = `${kind.idPrefix}_${mintSubmissionUuid()}`
186
187
  // EC-695: every upload claimed in ONE engine call, after every check above that can refuse —
187
188
  // all bound, or none (a refused claim refuses the submission, and nothing was bound, so
188
189
  // nothing is released: this handler never deletes bytes). Nothing after it refuses.
189
- const claim = await claimIngestUploads(ctx, body.uploads ?? [], body.target, receivedAt, id)
190
+ const claim = await claimIngestUploads(ctx, body.uploads ?? [], body.target, receivedAt, kind, id)
190
191
  if (!claim.ok) return claim
191
192
  const files: SubmissionFile[] = []
192
193
  const namesUsed = new Map<string, number>()
@@ -137,7 +137,10 @@ const bindings = (t: Backend) =>
137
137
  t.run(async (ctx) =>
138
138
  (await ctx.db.query('records').collect())
139
139
  .filter((record) => record.kind === 'ingestBinding')
140
- .map((record) => ({ storageId: record.recordId, owner: (record.data as { owner?: string }).owner })),
140
+ .map((record) => {
141
+ const data = record.data as { ownerKind?: string; owner?: string }
142
+ return { storageId: record.recordId, ownerKind: data.ownerKind, owner: data.owner }
143
+ }),
141
144
  )
142
145
  const deleteBlob = makeFunctionReference<'mutation', { storageKey: string }, unknown>('assets:deleteBlob')
143
146
 
@@ -155,10 +158,11 @@ async function promote(t: Backend, assetId: string, storageKey: string) {
155
158
  }
156
159
 
157
160
  describe('EC-750 probe — removing a submission deletes only the files THAT submission claimed', () => {
158
- it('a claim binds each file to the submission that claimed it (the owner, not the form)', async () => {
161
+ it('a claim binds each file to the submission that claimed it (the owner — its kind and id — not the form)', async () => {
159
162
  const t = await backend()
160
163
  const b = await genuineSubmission(t)
161
- expect(await bindings(t)).toEqual([{ storageId: b.file, owner: b.id }]) // base: owner undefined (the form key only)
164
+ // EC-762: the owner is the record's kind AND id (a record is both).
165
+ expect(await bindings(t)).toEqual([{ storageId: b.file, ownerKind: 'submission', owner: b.id }]) // base: owner undefined (the form key only)
162
166
  })
163
167
 
164
168
  it('Codex’s trace: A is created by forms:save carrying B’s files; removing A keeps B’s bytes and B’s binding', async () => {
@@ -171,7 +175,7 @@ describe('EC-750 probe — removing a submission deletes only the files THAT sub
171
175
  expect(await row(t, 'submission_forged')).toBeUndefined()
172
176
  // Measured on the base (0b7bb049), counterexample form: both lines below read false / [].
173
177
  expect(await survives(t, b.file)).toBe(true) // base: false — A's removal deleted B's bytes
174
- expect(await bindings(t)).toEqual([{ storageId: b.file, owner: b.id }]) // base: [] — B's binding stripped
178
+ expect(await bindings(t)).toEqual([{ storageId: b.file, ownerKind: 'submission', owner: b.id }]) // base: [] — B's binding stripped
175
179
  // B still holds its file: its row is intact, and its own removal deletes it (below).
176
180
  expect((await row(t, b.id))!.files!.map((file) => file.storageId)).toEqual([b.file])
177
181
  })
@@ -221,7 +225,34 @@ describe('EC-750 probe — removing a submission deletes only the files THAT sub
221
225
  await forgeSibling(t, 'submission_legacy', [{ field: 'cv', storageId: legacyFile, contentType: 'application/pdf', size: 300 }])
222
226
  await editor(t).remove(PROJECT_ID, 'submission_legacy')
223
227
  expect(await survives(t, legacyFile)).toBe(true) // base: false — any bound id the row named was deleted
224
- expect(await bindings(t)).toEqual([{ storageId: legacyFile, owner: undefined }])
228
+ expect(await bindings(t)).toEqual([{ storageId: legacyFile, ownerKind: undefined, owner: undefined }])
229
+ })
230
+
231
+ it('EC-762: a KIND-LESS binding (its owner’s id alone, as EC-750 wrote it) is never deleted by its submission’s removal — residue, never loss', async () => {
232
+ const t = await backend()
233
+ const kindlessFile = await storeFile(t, 300, 'application/pdf')
234
+ const id = 'submission_kindless'
235
+ // The binding a claim wrote between EC-750 and EC-762: the owner's id in `key1`, no kind, no `key2`.
236
+ await t.run(async (ctx) => {
237
+ await ctx.db.insert('records', {
238
+ kind: 'ingestBinding',
239
+ recordId: kindlessFile,
240
+ data: { uploadId: 'upload_kindless', target: 'contact', owner: id, boundAt: T0 },
241
+ key1: id,
242
+ at: T0,
243
+ createdAt: T0,
244
+ updatedAt: T0,
245
+ })
246
+ })
247
+ await forgeSibling(t, id, [{ field: 'cv', storageId: kindlessFile, contentType: 'application/pdf', size: 300 }])
248
+ await editor(t).remove(PROJECT_ID, id)
249
+ expect(await row(t, id)).toBeUndefined()
250
+ // No owner-only fallback (it would re-open F51): the bytes stay, and the binding keeps holding them.
251
+ expect(await survives(t, kindlessFile)).toBe(true)
252
+ expect(await bindings(t)).toEqual([{ storageId: kindlessFile, ownerKind: undefined, owner: id }])
253
+ await promote(t, 'kindless-asset', kindlessFile)
254
+ expect(await asAdmin(t).mutation(deleteBlob, { storageKey: kindlessFile })).toEqual({ deleted: false, heldBy: 'ingest' })
255
+ expect(await survives(t, kindlessFile)).toBe(true)
225
256
  })
226
257
 
227
258
  it('assets:deleteBlob still keeps bytes a binding holds — after the forged A’s removal, B’s binding still protects f', async () => {