Skip to content

Commit 3f72809

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16849-agents-changelog-guardrail-row
2 parents 0410c22 + 854639b commit 3f72809

31 files changed

Lines changed: 1273 additions & 96 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/metadata": minor
5+
"@objectstack/metadata-protocol": minor
6+
"@objectstack/plugin-auth": minor
7+
---
8+
9+
feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves. No spec key, no authored metadata property, no config field, no accepted request shape and no stored artifact changes spelling or shape; `objectstack migrate meta` has nothing to rewrite, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain. What moves is the declared RETURN TYPE of three TypeScript methods (`packages/spec/src/contracts/data-engine.ts`, its `scoped-context.ts` mirrors, and `ObjectQL` itself) plus three new registered ADR-0112 error codes. The rewrite this ships — add the null check the type now demands — is addressed to a TYPESCRIPT CONSUMER and is delivered by the compiler at their own call site, which is the audience the ADR-0087 ledger explicitly does not serve. `type-surface-only` is the category built for exactly this class and it is NOT claimed here, because its predicate 2 (`no-spec-diff`) is mechanically false for this PR: the surface the maintainer ruling names IS `packages/spec/src/contracts/**`. That gap is reported on the card rather than worked around, and the `**BREAKING**` banner below is carried rather than dropped. -->
12+
13+
**BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise<any>` and now declare the answers they have always given:
14+
15+
- `findOne` → `Promise<Record<string, any> | null>`
16+
- `update` → `Promise<Record<string, any> | number | null>`
17+
- `delete` → `Promise<boolean | number>`
18+
19+
`any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own.
20+
21+
**Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise<any[]>` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise<any>` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused).
22+
23+
The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record<string, unknown> | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record<string, any>`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise<any[]>`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`.
24+
25+
**What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives.
26+
27+
**Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here.
28+
29+
**What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`:
30+
31+
- `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise<Record<string, unknown> | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`.
32+
- `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise<Record<string, unknown> | null>` / `Promise<number>`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`.
33+
- `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise<boolean>` / `Promise<number>`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers.
34+
35+
The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler.

‎content/docs/references/api/contract.mdx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
2727

2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
30-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +322 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
30+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +325 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3131
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3232
| **message** | `string` | ✅ | Readable error message |
3333
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -134,6 +134,7 @@ const result = ApiErrorSchema.parse(data);
134134
* `DATASET_INVALID`
135135
* `DATASOURCE_ADMIN_ERROR`
136136
* `DELEGABLE_SCOPE_FAILED`
137+
* `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`
137138
* `DELIVERY_NEVER_SENT`
138139
* `DELIVERY_NOT_ELIGIBLE`
139140
* `DESTRUCTIVE_CHANGE`
@@ -185,6 +186,7 @@ const result = ApiErrorSchema.parse(data);
185186
* `FILTER_TOKEN_UNKNOWN`
186187
* `FILTER_TOKEN_UNRESOLVED`
187188
* `FIND_HOOK_RESULT_NOT_ARRAY`
189+
* `FIND_ONE_HOOK_RESULT_NOT_RECORD`
188190
* `FLOW_CONVERSION_CONFLICT`
189191
* `FLOW_DISABLED`
190192
* `FLOW_FAILED`
@@ -356,6 +358,7 @@ const result = ApiErrorSchema.parse(data);
356358
* `UNSUPPORTED`
357359
* `UNSUPPORTED_QUERY_PARAM`
358360
* `UNSUPPORTED_TRANSFORM`
361+
* `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`
359362
* `UPDATE_ID_MISMATCH`
360363
* `UPLOAD_SESSION_EXPIRED`
361364
* `UPLOAD_SESSION_NOT_FOUND`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ const result = ErrorCode.parse(data);
293293
* `DATASET_INVALID`
294294
* `DATASOURCE_ADMIN_ERROR`
295295
* `DELEGABLE_SCOPE_FAILED`
296+
* `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`
296297
* `DELIVERY_NEVER_SENT`
297298
* `DELIVERY_NOT_ELIGIBLE`
298299
* `DESTRUCTIVE_CHANGE`
@@ -344,6 +345,7 @@ const result = ErrorCode.parse(data);
344345
* `FILTER_TOKEN_UNKNOWN`
345346
* `FILTER_TOKEN_UNRESOLVED`
346347
* `FIND_HOOK_RESULT_NOT_ARRAY`
348+
* `FIND_ONE_HOOK_RESULT_NOT_RECORD`
347349
* `FLOW_CONVERSION_CONFLICT`
348350
* `FLOW_DISABLED`
349351
* `FLOW_FAILED`
@@ -515,6 +517,7 @@ const result = ErrorCode.parse(data);
515517
* `UNSUPPORTED`
516518
* `UNSUPPORTED_QUERY_PARAM`
517519
* `UNSUPPORTED_TRANSFORM`
520+
* `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`
518521
* `UPDATE_ID_MISMATCH`
519522
* `UPLOAD_SESSION_EXPIRED`
520523
* `UPLOAD_SESSION_NOT_FOUND`

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,42 @@ export function zodIssuesToMetadataIssues(issues: unknown): MetadataIssueEntry[]
869869
*/
870870
export { recordNotFoundError };
871871

872+
/**
873+
* The RECORD limb of `IDataEngine.update`'s declared result, for the four
874+
* ingresses in this file whose call is BY-ID by construction (#16231).
875+
*
876+
* `update()` used to declare `Promise<any>` and now declares the union its two
877+
* dispatch paths actually answer: a record (or `null`) from the by-id exit
878+
* (`driver.update`), or the affected-row COUNT from the predicate exit
879+
* (`driver.updateMany`, #4639). Every call site below hands the engine a
880+
* `where` naming exactly one primary key — or folds the row's id into the
881+
* payload — with no `multi`, which `resolveEngineUpdateDispatch` resolves
882+
* `by-id`. So the count limb is unreachable from here, and it is REFUSED
883+
* loudly rather than cast away: a dispatch change that started routing these
884+
* calls through `updateMany` would otherwise drop an affected count into a
885+
* per-row receipt's `record` / `data` slot, where every consumer reads it as a
886+
* row.
887+
*
888+
* The `null` limb is NOT refused and NOT narrowed away — it is passed through
889+
* exactly as it was while the declaration said `any`. `update()`'s by-id exit
890+
* answers `null` when the post-write readback leaves the caller's row scope
891+
* (see `updateData`'s own note on that), while `DataProtocol`'s row receipts
892+
* declare `record` / `data` non-null. That disagreement pre-dates this change,
893+
* and this file preserves it rather than widening a shipped response shape as
894+
* a rider; the assertion below is the one place it is written down.
895+
*/
896+
function byIdUpdateRecord(result: Record<string, any> | number | null): Record<string, any> {
897+
if (typeof result === 'number') {
898+
throw new Error(
899+
`A by-id update resolved an affected-row count (${result}) instead of a record. ` +
900+
`This ingress addresses exactly one row, so the engine's predicate dispatch ` +
901+
`(driver.updateMany) is not reachable from it — the dispatch ladder or this ` +
902+
`call site changed.`,
903+
);
904+
}
905+
return result as Record<string, any>;
906+
}
907+
872908
/**
873909
* A 400 for a `$filter` ARRAY that looks like a filter AST but is not one.
874910
*
@@ -11096,7 +11132,7 @@ export class ObjectStackProtocolImplementation implements
1109611132
)
1109711133
? { ...(request.data as Record<string, unknown>), id: request.id }
1109811134
: request.data;
11099-
const result = await this.engine.update(request.object, writeData, opts);
11135+
const result = byIdUpdateRecord(await this.engine.update(request.object, writeData, opts));
1110011136
// [#7823] The PATCH 200 body is the surface #7728's fourth measurement
1110111137
// caught: a client revoking a `sys_api_key` (apiMethods keeps `update`
1110211138
// open, #7727) got the stored hash back in this response. That closure
@@ -12132,7 +12168,7 @@ export class ObjectStackProtocolImplementation implements
1213212168
await this.assertRecordExists(object, record.id);
1213312169
// [#3455] Collect the engine's LEGAL write strips per row.
1213412170
const dropped: DroppedFieldsEvent[] = [];
12135-
const updated = await this.engine.update(object, record.data || {}, { where: { id: record.id }, onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); }, ...ctxOpt } as any);
12171+
const updated = byIdUpdateRecord(await this.engine.update(object, record.data || {}, { where: { id: record.id }, onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); }, ...ctxOpt } as any));
1213612172
omitInternalFieldsFromWriteResponse(batchSchema, updated); // [#7823]
1213712173
results.push({ id: record.id, success: true, data: updated, index, ...(dropped.length > 0 ? { droppedFields: dropped } : {}) });
1213812174
succeeded++;
@@ -12164,7 +12200,7 @@ export class ObjectStackProtocolImplementation implements
1216412200
const existing = await this.probeRecord(object, record.id);
1216512201
if (existing) {
1216612202
const dropped: DroppedFieldsEvent[] = [];
12167-
const updated = await this.engine.update(object, record.data || {}, { where: { id: record.id }, onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); }, ...ctxOpt } as any);
12203+
const updated = byIdUpdateRecord(await this.engine.update(object, record.data || {}, { where: { id: record.id }, onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); }, ...ctxOpt } as any));
1216812204
omitInternalFieldsFromWriteResponse(batchSchema, updated); // [#7823]
1216912205
results.push({ id: record.id, success: true, data: updated, index, ...(dropped.length > 0 ? { droppedFields: dropped } : {}) });
1217012206
} else {
@@ -12547,7 +12583,7 @@ export class ObjectStackProtocolImplementation implements
1254712583
const dropped: DroppedFieldsEvent[] = [];
1254812584
const opts: any = { where: { id: record.id }, onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); } };
1254912585
if (context !== undefined) opts.context = context;
12550-
const updated = await this.engine.update(object, record.data || {}, opts);
12586+
const updated = byIdUpdateRecord(await this.engine.update(object, record.data || {}, opts));
1255112587
omitInternalFieldsFromWriteResponse(updateManySchema, updated); // [#7823]
1255212588
results.push({ id: record.id, success: true, data: updated, index, ...(dropped.length > 0 ? { droppedFields: dropped } : {}) });
1255312589
succeeded++;

‎packages/metadata/src/loaders/database-loader.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,16 @@ export class DatabaseLoader implements MetadataLoader {
430430
// nothing to compare against: a `data.id` spread over the id parameter
431431
// would retarget the write to a row no caller resolved. The separate
432432
// `id` parameter is the row address — do not let a payload outrank it.
433-
return this.engine.update(table, { ...data, id });
433+
// [#16231] `IDataEngine.update` now declares its dispatch union
434+
// (`record | affected-count | null`). This call passes NO `where`, so
435+
// the payload id is the only address the dispatch ladder sees and it
436+
// resolves `by-id` — the limb that answers a record or `null`. The
437+
// `number` limb is the predicate path (`driver.updateMany`'s affected
438+
// count, #4639), which this call cannot reach; it is narrowed away here
439+
// rather than cast, so a future dispatch change surfaces at THIS line
440+
// instead of as a wrong-shaped row at the caller.
441+
const updated = await this.engine.update(table, { ...data, id });
442+
return typeof updated === 'number' ? null : updated;
434443
}
435444
return this.driver!.update(table, id, data);
436445
}

‎packages/objectql/src/batch-row-authoring-feedback.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,13 @@ describe('[#8502] a REAL validation refusal keeps its sentence on a batch row',
140140
expect(res.results[0].errors[0].message).toContain('Reason');
141141
expect(res.results[0].errors[0].message).not.toContain('The reason is in the server log');
142142
// The stored row is untouched: the refusal happened before the write.
143-
expect((await engine.findOne('bf_leave_request', { where: { id: 'lr1' } })).reason).toBe('ok');
143+
// [#16231] `findOne` declares record-or-null now, so the read is
144+
// narrowed before the field is asserted — the row's presence IS half of
145+
// what this case measures ("the stored row is untouched"), and reading
146+
// it through `?.` would have let a vanished row pass as `undefined`.
147+
const untouched = await engine.findOne('bf_leave_request', { where: { id: 'lr1' } });
148+
expect(untouched).not.toBeNull();
149+
expect(untouched!.reason).toBe('ok');
144150
});
145151

146152
it('the refusal carries no `status`, so a status-only rule WOULD have blanked it', async () => {

0 commit comments

Comments
 (0)