Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/27581-secret-field-delete-release.md
Original file line number Diff line number Diff line change
@@ -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`.
27 changes: 26 additions & 1 deletion packages/objectql/src/engine-secret-internal-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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<typeof collectSecretFields>[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<typeof collectSecretFields>[0],
deletedRows: readonly (Record<string, unknown> | 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<SecretInternalFieldsHost, 'getDriver'>,
Expand Down
34 changes: 32 additions & 2 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -19811,6 +19815,11 @@ export class ObjectQL implements IObjectQLEngine {
// delete (`null` when no roll-up aggregates this object).
let predicateMatchedRows: Record<string, unknown>[] | null = null;
let summaryPriorRows: Record<string, unknown>[] | 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`
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -20066,6 +20077,14 @@ export class ObjectQL implements IObjectQLEngine {
summaryPriorRows = predicateMatchedRows
?? (((await driver.find(object, { ...opCtx.ast!, fields: summaryKeyColumns }, hookContext.input.options as Parameters<IDataDriver['find']>[2])) ?? []) as Record<string, unknown>[]);
}
// [#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<IDataDriver['find']>[2])) ?? []) as Record<string, unknown>[]));
}
result = await driver.deleteMany!(object, opCtx.ast!, hookContext.input.options as any);
} else {
// Otherwise: resolve the matched rows through the composed,
Expand All @@ -20081,9 +20100,12 @@ export class ObjectQL implements IObjectQLEngine {
// branch's, for the same reasons (#7413).
const runPredicateDelete = async (writeContext?: ExecutionContext): Promise<unknown> => {
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<Record<string, unknown>>;
// [#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<Record<string, unknown>>;
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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading