From 0857b7430f091b3d47a580886ca9abbafdda0c41 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:29:04 +0000 Subject: [PATCH 1/2] wip(objectql): release a deleted record's sys_secret handles Claude-Session: https://claude.ai/code/session_01L6bhgUFEohazYphk3zDAum Co-authored-by: Claude --- .../src/engine-secret-internal-fields.ts | 27 ++- packages/objectql/src/engine.ts | 34 +++- .../src/secret-field-write-commit.test.ts | 176 +++++++++++++++++- 3 files changed, 225 insertions(+), 12 deletions(-) diff --git a/packages/objectql/src/engine-secret-internal-fields.ts b/packages/objectql/src/engine-secret-internal-fields.ts index 163a38d28da..621c5ec3c95 100644 --- a/packages/objectql/src/engine-secret-internal-fields.ts +++ b/packages/objectql/src/engine-secret-internal-fields.ts @@ -20,7 +20,8 @@ * bodies joined it — `persistSecretRows` writes the sealed rows immediately * before the record's own driver write, and `releaseSecretHandles` removes a * handle once a write that did not land, or a committed one that moved the - * column off it, has left it unreferenced. + * column off it, has left it unreferenced. [#27581] A committed delete hands + * the same release every handle the deleted rows held (`deletedSecretSlots`). * * ## How the moved bodies reach the class * @@ -228,6 +229,30 @@ export function retiredSecretSlots( return out; } +/** + * [#27581] The `secret` columns a delete reads off the rows it removes, before + * its statement takes them away; empty for an object that declares none, so a + * delete anywhere else reads nothing more. + */ +export function deletedSecretColumns(schema: Parameters[0]): string[] { + return collectSecretFields(schema); +} + +/** + * [#27581] The handles a committed delete let go of: every handle the deleted + * rows held in a `secret` column. A delete lets go of each column's value at + * once, so the candidates are exactly what a `null` clear of every one of + * those columns retires ({@link retiredSecretSlots}), and the same + * `releaseSecretHandles` checks decide each one: there is one release rule. + */ +export function deletedSecretSlots( + schema: Parameters[0], + deletedRows: readonly (Record | null | undefined)[], +): SecretHandleSlot[] { + const clearEvery = Object.fromEntries(deletedSecretColumns(schema).map((field) => [field, null])); + return retiredSecretSlots(schema, deletedRows, clearEvery); +} + /** The body of `ObjectQL#persistSecretRows`; the method's docblock there is its contract. */ export async function persistSecretRows( this: Pick, diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index cb830b1eb88..ab2b884a737 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -108,6 +108,8 @@ import { publishBulkDataEvent, publishDataEvent } from './engine-realtime-publis // [#25522 cut 5] The secret and internal-field bodies; the methods of the same // names below hand each body the members it reads (`SecretInternalFieldsHost`). import { + deletedSecretColumns, + deletedSecretSlots, encryptSecretFields, maskSecretFields, omitInternalFields, @@ -10615,7 +10617,9 @@ export class ObjectQL implements IObjectQLEngine { * Two occasions reach it. `'refused'`: a write that sealed and wrote a row * did not complete, so the row may name nothing. `'retired'`: a committed * update cleared the field (`null`) or replaced its value, so the handle the - * column named before may name nothing. The same three checks decide both, + * column named before may name nothing; [#27581] a committed delete reaches + * it the same way, for every handle the removed rows held. The same three + * checks decide both, * and every one that cannot pass KEEPS the row, the recoverable direction * (deleting a handle a row still names destroys that credential, and nothing * can name what was lost): @@ -19811,6 +19815,11 @@ export class ObjectQL implements IObjectQLEngine { // delete (`null` when no roll-up aggregates this object). let predicateMatchedRows: Record[] | null = null; let summaryPriorRows: Record[] | null = null; + // [#27581] The `secret` columns this object declares, and the handles the + // removed rows held in them: the by-id pre-image, or the predicate + // path's matched rows, read before the statement takes them away. + const secretColumns = deletedSecretColumns(deleteSchema); + let deletedSecrets: SecretHandleSlot[] = []; // [#5574] The dispatch ladder, resolved BEFORE the before phase — see // update()'s twin for the full reasoning. Keyed on the SAME falsy-`id` @@ -20032,6 +20041,8 @@ export class ObjectQL implements IObjectQLEngine { if (plan === 'split') this.warnCascadeNotAtomic(object); result = await runByIdDelete(opCtx.context); } + // [#27581] The handles the removed row held, off its pre-image. + deletedSecrets = deletedSecretSlots(deleteSchema, [priorRecord]); } else { // [#2982] The AST asserted present and already used for the // pre-phase row read above. @@ -20066,6 +20077,14 @@ export class ObjectQL implements IObjectQLEngine { summaryPriorRows = predicateMatchedRows ?? (((await driver.find(object, { ...opCtx.ast!, fields: summaryKeyColumns }, hookContext.input.options as Parameters[2])) ?? []) as Record[]); } + // [#27581] The handles the matched rows hold, read BEFORE the + // statement removes them: the per-row hook read when it was + // made, otherwise only `id` and the secret columns, through + // this write's AST and options. No secret column, no read. + if (secretColumns.length > 0) { + deletedSecrets = deletedSecretSlots(deleteSchema, predicateMatchedRows + ?? (((await driver.find(object, { ...opCtx.ast!, fields: ['id', ...secretColumns] }, hookContext.input.options as Parameters[2])) ?? []) as Record[])); + } result = await driver.deleteMany!(object, opCtx.ast!, hookContext.input.options as any); } else { // Otherwise: resolve the matched rows through the composed, @@ -20081,9 +20100,12 @@ export class ObjectQL implements IObjectQLEngine { // branch's, for the same reasons (#7413). const runPredicateDelete = async (writeContext?: ExecutionContext): Promise => { const writeOptions = this.buildDriverOptions(object, writeContext, hookContext.input.options as any); - const matched = ((await driver.find(object, { ...opCtx.ast!, fields: ['id', ...summaryKeyColumns] }, writeOptions)) ?? []) as Array>; + // [#27581] The same read carries the secret columns, so the + // handles released are those of the rows deleted. + const matched = ((await driver.find(object, { ...opCtx.ast!, fields: ['id', ...summaryKeyColumns, ...secretColumns] }, writeOptions)) ?? []) as Array>; const matchedIds = matched.map((row) => row?.id).filter((v): v is string | number => v != null); if (summaryKeyColumns.length > 0) summaryPriorRows = withoutDeletedSelfParents(object, matched, matchedIds, this.getSummaryDescriptors(object)); + deletedSecrets = deletedSecretSlots(deleteSchema, matched); // [#27004] One walk, rooted at the whole matched set. await this.cascadeDeleteRelations(object, matchedIds, writeContext); // The id set rides an `$in`; batched so one statement never @@ -20146,6 +20168,14 @@ export class ObjectQL implements IObjectQLEngine { }); } + // [#27581] The delete is complete: release the handles the removed + // rows held, through the release a committed update uses, so each one + // goes only once storage confirms no row of its column still names it, + // inside this write's unit. After the `afterDelete` chain, so a hook + // can still dereference the value and a refusal that rolls the delete + // back never reaches here. + await this.releaseSecretHandles(object, deletedSecrets, opCtx.context, 'retired'); + // Roll-up: recompute the parent summary now that the child is gone, // from the row's FK values captured BEFORE deletion. [#5272] That // capture is now the same single pre-image read `previous` rides on diff --git a/packages/objectql/src/secret-field-write-commit.test.ts b/packages/objectql/src/secret-field-write-commit.test.ts index d9d93b4f465..2453f9a2559 100644 --- a/packages/objectql/src/secret-field-write-commit.test.ts +++ b/packages/objectql/src/secret-field-write-commit.test.ts @@ -21,6 +21,10 @@ * before, once storage confirms no row of that column still names it and * the handle is this column's own. A handle a predicate write shared across * rows outlives every row but the last. + * - **Released when the record goes** ([#27581]). A committed delete, by id, + * by predicate or by cascade, releases every handle the removed rows held, + * through the same release and its same checks; a delete that is refused or + * rolled back keeps the row. */ import { describe, it, expect, beforeEach } from 'vitest'; @@ -32,7 +36,13 @@ import { SECRET_MASK, isSecretRef, parseSecretRef } from './secret-fields.js'; type Row = Record; -/** A stub driver whose stores can be counted, rolled back and told to refuse a statement. */ +/** + * A stub driver whose stores can be counted, rolled back and told to refuse a statement. + * + * A rollback restores the snapshot the unit began with, unless `isolation.on`: then it undoes + * only the writes made under that unit's handle, as a database does, so a write that escaped + * the unit survives the rollback ([#27581]). + */ function makeDriver() { let stores = new Map>(); const storeFor = (o: string) => stores.get(o) ?? (stores.set(o, new Map()), stores.get(o)!); @@ -48,6 +58,10 @@ function makeDriver() { /** Objects whose `count` throws, so a release cannot confirm anything. */ const countFails = new Set(); const snapshots = new Map(); + const isolation = { on: false }; + const undoLogs = new Map void>>(); + /** Record how to take back one write, when it was made under an open unit's handle. */ + const undoable = (opts: any, take: () => void) => { undoLogs.get(opts?.transaction)?.push(take); }; let n = 0; const refused = (o: string) => { if (refuse.has(o)) throw Object.assign(new Error(`statement refused on ${o}`), { code: 'ETIMEDOUT' }); @@ -62,29 +76,46 @@ function makeDriver() { return page.map(copy); }, async findOne(o: string, ast: any) { return (await this.find(o, ast))[0] ?? null; }, - async create(o: string, data: Row) { + async create(o: string, data: Row, opts?: any) { refused(o); const row = { ...data, id: (data.id as string) ?? `${o}_${++n}` }; storeFor(o).set(String(row.id), row); + undoable(opts, () => storeFor(o).delete(String(row.id))); return copy(row); }, - async update(o: string, id: string, data: Row) { + async update(o: string, id: string, data: Row, opts?: any) { refused(o); - const row = { ...storeFor(o).get(String(id)), ...data, id }; + const prior = storeFor(o).get(String(id)); + const row = { ...prior, ...data, id }; storeFor(o).set(String(id), row); + undoable(opts, () => (prior ? storeFor(o).set(String(id), prior) : storeFor(o).delete(String(id)))); return copy(row); }, - async updateMany(o: string, ast: any, data: Row) { + async updateMany(o: string, ast: any, data: Row, opts?: any) { refused(o); let k = 0; for (const [id, row] of storeFor(o)) { if (!matches(row, ast?.where)) continue; storeFor(o).set(id, { ...row, ...data, id }); + undoable(opts, () => storeFor(o).set(id, row)); + k += 1; + } + return k; + }, + async delete(o: string, id: string, opts?: any) { + const prior = storeFor(o).get(String(id)); + if (prior) undoable(opts, () => storeFor(o).set(String(id), prior)); + return storeFor(o).delete(String(id)); + }, + async deleteMany(o: string, ast: any, opts?: any) { + let k = 0; + for (const [id, row] of Array.from(storeFor(o))) { + if (!matches(row, ast?.where)) continue; + await this.delete(o, id, opts); k += 1; } return k; }, - async delete(o: string, id: string) { return storeFor(o).delete(String(id)); }, async count(o: string, ast: any) { if (countFails.has(o)) throw new Error(`count unavailable on ${o}`); return (await this.find(o, ast)).length; @@ -94,13 +125,19 @@ function makeDriver() { async beginTransaction() { const handle = { trx: snapshots.size + 1 }; snapshots.set(handle, new Map(Array.from(stores, ([o, s]) => [o, new Map(Array.from(s, ([k, v]) => [k, { ...v }]))]))); + undoLogs.set(handle, []); return handle; }, - async commit(h: unknown) { snapshots.delete(h); }, - async rollback(h: unknown) { stores = snapshots.get(h) ?? stores; snapshots.delete(h); }, + async commit(h: unknown) { snapshots.delete(h); undoLogs.delete(h); }, + async rollback(h: unknown) { + if (isolation.on) for (const take of (undoLogs.get(h) ?? []).reverse()) take(); + else stores = snapshots.get(h) ?? stores; + snapshots.delete(h); + undoLogs.delete(h); + }, }; return { - driver, refuse, countFails, + driver, refuse, countFails, isolation, rowsOf: (o: string) => Array.from(storeFor(o).values()), }; } @@ -145,6 +182,16 @@ const other = { }; const SYSTEM = { context: { isSystem: true } } as any; +/** [#27581] A detail of the vault, holding its own secret: a cascade target. */ +const KEY = 'qa_vault_key'; +const vaultKey = { + name: KEY, label: 'Vault key', + fields: { + id: { name: 'id', type: 'text' }, name: { name: 'name', type: 'text' }, + vault: { name: 'vault', type: 'master_detail', reference: VAULT }, + key_token: { name: 'key_token', type: 'secret' }, + }, +}; describe('[#27495] a sys_secret row exists only while a committed row names it', () => { let engine: ObjectQL; @@ -371,4 +418,115 @@ describe('[#27495] a sys_secret row exists only while a committed row names it', expect(errors[0]).not.toContain(before); }); }); + + describe('[#27581] a committed delete releases the handles the removed rows held', () => { + it('a by-id delete removes the row its record named', async () => { + const gone = await seed('r1', 'gone'); + const kept = await seed('r2', 'kept'); + await expect(engine.delete(VAULT, { where: { id: 'r1' } })).resolves.toBe(true); + expect(secrets().map((s) => s.id)).toEqual([kept]); + expect(secrets().some((s) => s.id === gone)).toBe(false); + expect(await engine.resolveSecretField(VAULT, 'r2', 'token')).toBe('kept'); + expect(errors).toEqual([]); + }); + + it('a predicate delete removes every matched row\'s handle and no other', async () => { + await seed('r1', 'one'); + await seed('r2', 'two'); + await engine.update(VAULT, { id: 'r2', tier: 'silver' }); + const silver = handleOf('r2'); + await seed('r3', 'three'); + await expect(engine.delete(VAULT, { where: { tier: 'gold' }, multi: true } as any)).resolves.toBe(2); + expect(secrets().map((s) => s.id)).toEqual([silver]); + }); + + it('a predicate delete with a per-row hook releases from the rows that hook read', async () => { + const seen: unknown[] = []; + engine.registerHook('afterDelete', (ctx: any) => { seen.push(ctx.previous?.id); }, { object: VAULT }); + await seed('r1', 'one'); + await seed('r2', 'two'); + await engine.delete(VAULT, { where: { tier: 'gold' }, multi: true } as any); + expect(seen.sort()).toEqual(['r1', 'r2']); + expect(secrets()).toHaveLength(0); + }); + + it('a cascade removes the detail rows\' handles with the parent\'s, by id and by predicate', async () => { + engine.registry.registerObject(vaultKey as any); + await seed('r1', 'parent-1'); + await seed('r2', 'parent-2'); + await engine.insert(KEY, { id: 'k1', name: 'k1', vault: 'r1', key_token: 'child-1' }, SYSTEM); + await engine.insert(KEY, { id: 'k2', name: 'k2', vault: 'r2', key_token: 'child-2' }, SYSTEM); + expect(secrets()).toHaveLength(4); + await engine.delete(VAULT, { where: { id: 'r1' } }); + expect(d.rowsOf(KEY).map((r) => r.id)).toEqual(['k2']); + expect(secrets().map((s) => s.key).sort()).toEqual(['key_token', 'token']); + await engine.delete(VAULT, { where: { tier: 'gold' }, multi: true } as any); + expect(d.rowsOf(KEY)).toHaveLength(0); + expect(secrets()).toHaveLength(0); + }); + + it('a handle another row still names outlives the delete of every row but the last', async () => { + await seed('r1', 'one'); + await seed('r2', 'two'); + await engine.update(VAULT, { token: 'shared' }, { where: { tier: 'gold' }, multi: true } as any); + const shared = handleOf('r1'); + expect(handleOf('r2')).toBe(shared); + + await engine.delete(VAULT, { where: { id: 'r1' } }); + expect(secrets().map((s) => s.id)).toEqual([shared]); + expect(await engine.resolveSecretField(VAULT, 'r2', 'token')).toBe('shared'); + + await engine.delete(VAULT, { where: { id: 'r2' } }); + expect(secrets()).toHaveLength(0); + }); + + it('deleting a row that holds another column\'s handle releases nothing', async () => { + const foreign = await seed('r1', 'theirs'); + await engine.insert(OTHER, { id: 'o1', name: 'copy', other_token: `secret:${foreign}` }, SYSTEM); + await engine.delete(OTHER, { where: { id: 'o1' } }); + expect(secrets().map((s) => s.id)).toEqual([foreign]); + expect(await engine.resolveSecretField(VAULT, 'r1', 'token')).toBe('theirs'); + }); + + it('a delete rolled back with its unit keeps the row, though the record write was undone alone', async () => { + d.isolation.on = true; + const before = await seed('r1', 'stays'); + await expect(engine.transaction(async (trx: any) => { + await engine.delete(VAULT, { where: { id: 'r1' }, context: trx }); + throw new Error('roll the unit back'); + }, SYSTEM.context)).rejects.toThrow(/roll the unit back/); + expect(stored('r1')).toBeDefined(); + expect(secrets().map((s) => s.id)).toEqual([before]); + expect(await engine.resolveSecretField(VAULT, 'r1', 'token')).toBe('stays'); + }); + + it('a delete refused by a beforeDelete hook, or rolled back by an aborting afterDelete, keeps the row', async () => { + const before = await seed('r1', 'stays'); + engine.registerHook('beforeDelete', () => { throw Object.assign(new Error('hook says no'), { code: 'HOOK_REFUSED', status: 400 }); }, { object: VAULT }); + await expect(engine.delete(VAULT, { where: { id: 'r1' } })).rejects.toMatchObject({ code: 'HOOK_REFUSED', status: 400 }); + expect(secrets().map((s) => s.id)).toEqual([before]); + + const other = await seed('o1', 'also-stays'); + engine.registry.registerObject({ ...vault, name: 'qa_vault_two' } as any); + await engine.insert('qa_vault_two', { id: 't1', name: 't1', token: 'two-stays' }, SYSTEM); + const twoHandle = parseSecretRef(d.rowsOf('qa_vault_two')[0].token); + bindHooksToEngine(engine, [ + { name: 'veto', object: 'qa_vault_two', events: ['afterDelete'], handler: () => { throw new Error('refused by the hook'); } } as unknown as Hook, + ], { packageId: 'app:t', logger }); + await expect(engine.delete('qa_vault_two', { where: { id: 't1' } })).rejects.toThrow(/refused by the hook/); + expect(d.rowsOf('qa_vault_two')).toHaveLength(1); + expect(secrets().map((s) => s.id).sort()).toEqual([before, other, twoHandle].sort()); + }); + + it('a release that cannot confirm the column let go keeps the row and says so', async () => { + const before = await seed('r1', 'stays-stored'); + d.countFails.add(VAULT); + await engine.delete(VAULT, { where: { id: 'r1' } }); + expect(d.rowsOf(VAULT)).toHaveLength(0); + expect(secrets().map((s) => s.id)).toEqual([before]); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain(`${VAULT}.token`); + expect(errors[0]).not.toContain(before); + }); + }); }); From 54efb0b84bd1598153105db0e4aa5bbf255d7d63 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:37:59 +0000 Subject: [PATCH 2/2] wip(dogfood): pin the delete release at the REST data door Claude-Session: https://claude.ai/code/session_01L6bhgUFEohazYphk3zDAum Co-authored-by: Claude --- .../27581-secret-field-delete-release.md | 12 ++ ...ecret-field-delete-release.dogfood.test.ts | 147 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 .changeset/27581-secret-field-delete-release.md create mode 100644 packages/qa/dogfood/test/secret-field-delete-release.dogfood.test.ts diff --git a/.changeset/27581-secret-field-delete-release.md b/.changeset/27581-secret-field-delete-release.md new file mode 100644 index 00000000000..e46c0445660 --- /dev/null +++ b/.changeset/27581-secret-field-delete-release.md @@ -0,0 +1,12 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): deleting a record releases the `sys_secret` row its `secret` field named (#27581) + +Clause-②: no +Metadata-protocol: none + +- **Released when the record goes.** A committed delete now removes the `sys_secret` row each deleted record's `secret` field named: a delete by id, a predicate delete (`multi: true`, and so the bulk `deleteMany` door, which deletes by id), and every detail record a cascade deletes, since those go through the by-id delete. The handles come from the rows the delete already reads before its statement (the by-id pre-image, the predicate path's matched rows), plus one narrow `id` + secret-columns read on a predicate delete that read no rows otherwise; an object with no `secret` field reads nothing new. +- **One release rule.** The removal goes through the same verified release a committed update uses when it clears a `secret` field: no remaining row of that column may still name the handle (a predicate update shares one handle across every row it matched), the handle must be that column's own, and the removal rides the record's transaction where one is open. It runs after the `afterDelete` hooks, so a hook can still read the value and a delete that is refused or rolled back keeps the row. A removal that cannot be confirmed keeps the row and logs one `error` line naming the column; `os secret orphans` reports such rows. +- **Unchanged.** What a delete answers, and what reads return. There is no soft-delete or recycle-bin path to cover. Credential rows left behind before this release are not swept; they stay report-only through `os secret orphans`. diff --git a/packages/qa/dogfood/test/secret-field-delete-release.dogfood.test.ts b/packages/qa/dogfood/test/secret-field-delete-release.dogfood.test.ts new file mode 100644 index 00000000000..25feccb63cf --- /dev/null +++ b/packages/qa/dogfood/test/secret-field-delete-release.dogfood.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#27581] Deleting a record releases the `sys_secret` row its `secret` field +// named — pinned at the REST data door of a booted stack, on a provider-wired +// boot (`bootStack` wires one LocalCryptoProvider, as `objectstack dev` / +// `serve` do), counting the credential rows at the driver. +// `packages/objectql/src/secret-field-write-commit.test.ts` drives each delete +// path at the engine; this file holds the halves a client can reach: +// +// - `DELETE /data/:object/:id` and the bulk `deleteMany` door remove the +// credential row each deleted record named, and no other; +// - a handle another record still names is kept until the last one goes; +// - an atomic `deleteMany` that rolls back keeps the record and its row. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +const VAULT = 'sfd_vault'; + +const sfdStack = defineStack({ + manifest: { + id: 'com.dogfood.secret-field-delete-release', namespace: 'sfd', version: '0.0.0', type: 'app', + name: 'Secret field delete-release fixture', description: 'A secret field on a record that gets deleted.', + }, + objects: [ + ObjectSchema.create({ + name: VAULT, label: 'Vault', pluralLabel: 'Vaults', sharingModel: 'public_read_write', + fields: { + name: Field.text({ label: 'Name', required: true }), + tier: Field.select({ label: 'Tier', options: [{ label: 'Gold', value: 'gold' }, { label: 'Silver', value: 'silver' }] }), + token: Field.secret({ label: 'Token' }), + }, + }), + ], +} as never); + +type Answer = { status: number; json: any; text: string }; + +describe('[#27581] secret field: deleting a record releases its sys_secret row — over REST', () => { + let stack: VerifyStack; + let ql: any; + let token = ''; + + const call = async (method: string, path: string, body?: unknown): Promise => { + const res = await stack.apiAs(token, method, path, body); + const text = await res.text(); + let json: any = {}; + try { json = JSON.parse(text); } catch { /* not JSON */ } + return { status: res.status, json, text }; + }; + /** The ids of every credential row the store holds, read at the driver (no engine read path between). */ + const secretIds = async (): Promise => + (((await ql.getDriver('sys_secret').find('sys_secret', {})) ?? []) as Array<{ id: string }>).map((r) => String(r.id)); + /** The handle the business row holds, as the driver stores it. */ + const handleOf = async (id: string): Promise => { + const [row] = await ql.getDriver(VAULT).find(VAULT, { where: { id } }); + return String(row?.token ?? '').slice('secret:'.length); + }; + const create = async (name: string, value: string, tier = 'gold') => { + const res = await call('POST', `/data/${VAULT}`, { name, tier, token: value }); + expect(res.status, res.text).toBe(201); + const [row] = await stack.rows(VAULT, { name }); + return String(row.id); + }; + + beforeAll(async () => { + stack = await bootStack(sfdStack); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('DELETE by id removes the credential row the record named, and no other', async () => { + const gone = await create('sfd-gone', 'sfd-gone-value'); + const kept = await create('sfd-kept', 'sfd-kept-value'); + const goneHandle = await handleOf(gone); + const keptHandle = await handleOf(kept); + expect(await secretIds()).toEqual(expect.arrayContaining([goneHandle, keptHandle])); + const before = (await secretIds()).length; + + const res = await call('DELETE', `/data/${VAULT}/${gone}`); + expect(res.status, res.text).toBe(200); + expect(await stack.rows(VAULT, { name: 'sfd-gone' })).toHaveLength(0); + const after = await secretIds(); + expect(after).not.toContain(goneHandle); + expect(after).toContain(keptHandle); + expect(after.length).toBe(before - 1); + expect(await ql.resolveSecretField(VAULT, kept, 'token')).toBe('sfd-kept-value'); + }); + + it('the bulk deleteMany door removes the credential row of every record it deletes', async () => { + const a = await create('sfd-many-a', 'sfd-many-a-value'); + const b = await create('sfd-many-b', 'sfd-many-b-value'); + const handles = [await handleOf(a), await handleOf(b)]; + const before = (await secretIds()).length; + + const res = await call('POST', `/data/${VAULT}/deleteMany`, { ids: [a, b] }); + expect(res.status, res.text).toBe(200); + expect(res.json).toMatchObject({ succeeded: 2, failed: 0 }); + const after = await secretIds(); + for (const h of handles) expect(after).not.toContain(h); + expect(after.length).toBe(before - 2); + }); + + it('a handle another record still names is kept until the last record that names it is deleted', async () => { + const a = await create('sfd-shared-a', 'sfd-shared-a-value', 'silver'); + const b = await create('sfd-shared-b', 'sfd-shared-b-value', 'silver'); + // One predicate write seals ONE handle for every row it matches. + await ql.update(VAULT, { token: 'sfd-shared-value' }, { where: { tier: 'silver' }, multi: true, context: { isSystem: true } }); + const shared = await handleOf(a); + expect(await handleOf(b)).toBe(shared); + expect(await secretIds()).toContain(shared); + + const first = await call('DELETE', `/data/${VAULT}/${a}`); + expect(first.status, first.text).toBe(200); + expect(await secretIds()).toContain(shared); + expect(await ql.resolveSecretField(VAULT, b, 'token')).toBe('sfd-shared-value'); + + const last = await call('DELETE', `/data/${VAULT}/${b}`); + expect(last.status, last.text).toBe(200); + expect(await secretIds()).not.toContain(shared); + }); + + it('an atomic deleteMany that rolls back keeps the record and its credential row', async () => { + const id = await create('sfd-rollback', 'sfd-rollback-value'); + const handle = await handleOf(id); + const before = await secretIds(); + + // The second id names no record, so the batch rolls the first delete back. + const res = await call('POST', `/data/${VAULT}/deleteMany`, { ids: [id, 'sfd-no-such-record'], options: { atomic: true } }); + expect(res.status, res.text).toBe(200); + expect(res.json).toMatchObject({ success: false, succeeded: 0 }); + expect(res.json.results.map((r: any) => [r.id, r.success, r.errors?.[0]?.code])).toEqual([ + [id, false, 'ROLLED_BACK'], + ['sfd-no-such-record', false, 'RECORD_NOT_FOUND'], + ]); + expect(await stack.rows(VAULT, { name: 'sfd-rollback' })).toHaveLength(1); + expect(await secretIds()).toEqual(before); + expect(await secretIds()).toContain(handle); + expect(await ql.resolveSecretField(VAULT, id, 'token')).toBe('sfd-rollback-value'); + }); +});