Skip to content

Commit 3b78f35

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16431-filter-operator-schema-projection
2 parents 198857e + 854639b commit 3b78f35

41 files changed

Lines changed: 1822 additions & 113 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
`id_field` now gets a named answer instead of a bare refusal: `FIELD_KEY_GUIDANCE` declares it a retirement with **no successor**, which is the spec-side fact objectui's ingestion choke point needs before it can canonicalise the key (objectui#7650 ruling A — retired spellings are folded once, at ingestion, never at the consumer).
6+
7+
The direction was a factual finding, not a preference, and it went the way the cheaper branch happens to point — so here is the evidence rather than the verdict alone. A lookup stores the referenced record's id, and which field holds that value is not an authored per-field choice: the picker resolves record identity itself. Nothing on `FieldSchema` names it, nothing in `objectql` / `runtime` / `metadata-protocol` reads a per-field id key, and the two places the platform does let a reference be stored by something other than an id are declared elsewhere — `APPROVER_VALUE_BINDINGS.valueField` (per approver type, e.g. `position` routing by `sys_position.name`) and a seed dataset's `externalId`, the channel lookup references already resolve through. So there is no member to fold onto, and the prescription says what to reach for instead: `displayField` for the candidate's label, a dataset `externalId` for a portable natural key.
8+
9+
**The entry is keyed `id_field`, in snake_case, and that is deliberate.** The two channels this table feeds disagree about the key face. A `to` becomes a `strictObject` alias, matched through `aliasProbe` — case folded, separators stripped — so one camelCase row covers every spelling. A `why` becomes strict guidance, matched exactly and case-sensitively on the authored spelling. A camelCase row would therefore never be reached by the key authors write, and every existing test in the file would still pass, because none of them asks whether an entry is ever consulted.
10+
11+
That gap is closed too. Three assertions read the channel that actually answers an authored field key — `FieldSchema.safeParse`, since the schema is strict and the authoring-key walker stays silent on a strict surface by its own posture rule — and pin that the refusal carries this table's sentence verbatim, that a retirement suppresses the rename channel, and that the same-named `idField` on the `inlineColumns` GridColumn mirror is a different schema that stays live.

.github/workflows/lint.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3029,6 +3029,19 @@ jobs:
30293029
- name: Changeset-family gate self-tests
30303030
run: pnpm check:changeset-gate-self-tests
30313031

3032+
# Lockstep package-count guard (#17039). `.changeset/config.json`'s
3033+
# `fixed` array is the one true count of packages one stray `major`
3034+
# promotes; check-changeset-fixed.mjs (release/RC workflows) already
3035+
# gates that array against the real workspace. Nothing gated the THREE
3036+
# prose restatements of the array's length — two sentences in
3037+
# content/docs/protocol/backward-compatibility.mdx and one header
3038+
# comment in scripts/publish-smoke-pack.mjs — so all three drifted
3039+
# silently (69 written, 70 true) the day a package joined the group.
3040+
# Full-repo state, not diff-shaped, so — unlike the self-tests above —
3041+
# this runs its real check every time, unconditionally.
3042+
- name: Lockstep package-count guard
3043+
run: pnpm check:lockstep-package-count
3044+
30323045
# Release-notes drift guard: the platform is one version-locked train, so
30333046
# every released @objectstack/spec major must have a curated, navigable
30343047
# release page at content/docs/releases/v<major>.mdx. Catches the gap that

content/docs/protocol/backward-compatibility.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ Read the **Breaking?** column, not the version number: during the launch window
111111
1. **RFC (Request for Comments)** — Breaking changes are proposed as GitHub issues with the `protocol:breaking` label.
112112
2. **Deprecation** — The old behavior is deprecated in a MINOR release (see timeline above).
113113
3. **Migration Guide** — A detailed migration guide is published before the removal lands, in the release notes for the version that carries it.
114-
4. **Release** — During the launch window the breaking change ships in the next **MINOR** version, carrying a changeset entry marked `**BREAKING**`. `scripts/check-changeset-no-major.mjs` fails any pull request that declares a `major` bump, because under lockstep one `major` would promote all 69 published packages.
114+
4. **Release** — During the launch window the breaking change ships in the next **MINOR** version, carrying a changeset entry marked `**BREAKING**`. `scripts/check-changeset-no-major.mjs` fails any pull request that declares a `major` bump, because under lockstep one `major` would promote all 70 published packages.
115115

116116
---
117117

@@ -189,7 +189,7 @@ The `@objectstack/spec` package provides additional stability guarantees:
189189

190190
### Which surfaces this covers
191191

192-
**All of them.** This is not scoped to an experimental corner or a pre-release channel: all **69** packages published from this repository belong to a single Changesets `fixed` group, so they share one version number and one policy. No published surface is exempt.
192+
**All of them.** This is not scoped to an experimental corner or a pre-release channel: all **70** packages published from this repository belong to a single Changesets `fixed` group, so they share one version number and one policy. No published surface is exempt.
193193

194194
The convention is enforced rather than informal — `scripts/check-changeset-no-major.mjs` fails any pull request that introduces a `major` bump, because under lockstep a single `major` on one package would promote the entire stack.
195195

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`

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
"check:empty-changeset": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-empty-changeset.mjs",
128128
"check:adr-0087-registration": "node scripts/check-adr-0087-registration.mjs --self-test && node scripts/check-adr-0087-registration.mjs",
129129
"check:changeset-gate-self-tests": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-adr-0087-registration.mjs --self-test && node scripts/check-changeset-no-major.mjs --self-test",
130+
"check:lockstep-package-count": "node scripts/check-lockstep-package-count.mjs --self-test && node scripts/check-lockstep-package-count.mjs",
130131
"check:override-consistency": "node scripts/check-override-consistency.mjs --self-test && node scripts/check-override-consistency.mjs",
131132
"check:vendor-export-contract": "node scripts/check-vendor-export-contract.mjs --self-test && node scripts/check-vendor-export-contract.mjs",
132133
"check:vendor-export-contract-resolve": "node scripts/check-vendor-export-contract.mjs --self-test && node scripts/check-vendor-export-contract.mjs --resolve",

0 commit comments

Comments
 (0)