@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.
|
|
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.
|
|
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.
|
|
37
|
-
"@elytracms/studio": "^0.0.
|
|
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.
|
|
63
|
-
"@elytracms/studio": "^0.0.
|
|
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:
|
|
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:
|
|
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:
|
|
213
|
+
'✓ ec750: 12/12 expectation(s) held',
|
|
213
214
|
]),
|
|
214
215
|
)
|
|
215
216
|
expect(await leftovers(t)).toEqual({ submissions: 0, bindings: 0, assets: 0 })
|
package/src/convex/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
143
|
-
* this row's claim bound, read by owner, never the row's
|
|
144
|
-
* put any id there, another row's file included, or clear
|
|
145
|
-
* file an editor has since made an asset of (the asset owns
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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 () => {
|