Skip to content

Commit 955f094

Browse files
committed
Merge origin/main into claude/datasource-admin-error-code-y1eels
Resolves the #3973/#4229 overlap: the per-module sendOk/sendError pairs this branch had annotated were consolidated into @objectstack/types on main, so the local definitions go and the #4249 notes move to the module docs. With the shared sendError typing `code` as the closed ErrorCode union, SERVICE_ERROR_CODE is now Record<ServiceName, ErrorCode> — the mis-attribution class this branch fixes is a compile error going forward. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Di9hefxJMFD3rxiP2G2Eug
2 parents 140193e + 8675db6 commit 955f094

64 files changed

Lines changed: 1951 additions & 478 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
feat(migrate,objectql): the upgrade path names the data migrations that are still open here (#3438, ADR-0104 2026-07-30)
7+
8+
Both value-shape gates fail toward leniency: a deployment that never runs its
9+
migration keeps warning instead of rejecting, and keeps every released file
10+
forever. That default is right — and completely silent, so the gate could sit
11+
open for the life of a deployment without anyone learning that one command ends
12+
it. A gate nobody is told about is served by nobody.
13+
14+
Two announcements, each where an upgrade actually looks:
15+
16+
- **`os migrate meta --from 16`** now ends by naming the data migrations a
17+
chain crossing into 17 leaves behind — `files-to-references`, `value-shapes`
18+
— with what each unlocks, scoped to the field classes the author's own
19+
metadata declares (an app with no media field is never told about the file
20+
migration). `--json` carries the same list as `dataMigrations`. The command
21+
reads no database, so it reports what remains *to do*, never what a given
22+
deployment has *done*.
23+
- **The server logs one line per open gate at boot**, naming the command that
24+
closes it. Only the lax posture announces itself — a verified gate already
25+
logs that it is enforcing, and an app declaring neither class of field costs
26+
nothing and says nothing. This is the half that can speak to a deployment's
27+
actual data, because it is the half with the database.
28+
29+
Nothing about enforcement changes: same gates, same flags, same fail-toward-
30+
leniency default. The advisory runs on `kernel:bootstrapped` rather than
31+
`kernel:ready`, deliberately — the answer depends on the storage service's own
32+
ready handler, which registers `sys_migration` and may attest a store it just
33+
created, and racing it would tell a brand-new deployment its gates are open
34+
moments after they closed.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/formula": patch
3+
"@objectstack/sdui-parser": patch
4+
"@objectstack/connector-mcp": patch
5+
"@objectstack/connector-openapi": patch
6+
"@objectstack/connector-rest": patch
7+
"@objectstack/connector-slack": patch
8+
"@objectstack/embedder-openai": patch
9+
"@objectstack/knowledge-memory": patch
10+
"@objectstack/knowledge-ragflow": patch
11+
"@objectstack/plugin-approvals": patch
12+
"@objectstack/plugin-email": patch
13+
"@objectstack/plugin-pinyin-search": patch
14+
"@objectstack/plugin-reports": patch
15+
"@objectstack/plugin-sharing": patch
16+
"@objectstack/plugin-webhooks": patch
17+
"@objectstack/service-cluster": patch
18+
"@objectstack/service-cluster-redis": patch
19+
"@objectstack/service-datasource": patch
20+
"@objectstack/service-sms": patch
21+
"@objectstack/trigger-api": patch
22+
---
23+
24+
chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248)
25+
26+
These 20 packages declared no `files` field, so npm fell back to packing the
27+
whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks`
28+
listed **21 files** — 15 under `src/`, three of them unit tests
29+
(`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the
30+
build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at
31+
publish time rather than instead of it, so consumers were installing the
32+
TypeScript sources and the test suite alongside the artifact they asked for.
33+
34+
Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages
35+
that already did. Nothing a consumer imports moves: every `main` / `types` /
36+
`exports` target in all 20 already resolved inside `dist/`, which the new
37+
`check:published-files` guard verifies rather than assumes. The visible change
38+
is a smaller install and a smaller dependency-scanning surface — `npm pack` on
39+
`@objectstack/plugin-webhooks` now yields 2 files plus `dist/`.
40+
41+
The other half of the fix is the gate. Half the packages declaring `files` and
42+
half not was the #3786 shape — a hand-copied convention with nothing enforcing
43+
it, where whoever forgets the line gets no signal at all. `check:published-files`
44+
(new, wired into the always-required `lint` job) holds every non-private
45+
workspace package to four invariants: `files` is **declared**; it is
46+
**sufficient** (covers every entry point, so tightening a whitelist cannot ship
47+
a package that fails to resolve); it is **minimal** (admits no test, test-harness
48+
config or build script); and anything beyond `dist` + `README.md` is
49+
**registered** with a reason, reconciled in both directions so a stale exemption
50+
is an error rather than dead text. `@objectstack/spec` is the one package with
51+
registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and
52+
`CHANGELOG.md` are product, not build input.
53+
54+
This also closes an assumption #4206 was resting on. Excluding `<pkg>/scripts/**`
55+
from the docs-drift implementation test is sound only while no package publishes
56+
`scripts/` as runtime code; that held, but it held because someone read all three
57+
offenders by hand. It is now checked on every PR.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/objectql": patch
4+
"@objectstack/metadata-protocol": patch
5+
"@objectstack/driver-mongodb": patch
6+
"@objectstack/driver-sql": patch
7+
"@objectstack/driver-memory": patch
8+
---
9+
10+
refactor(data)!: a select-list entry is a field name — the nested-select object form is removed (#4196)
11+
12+
`FieldNode` declared two forms for one entry of `QueryAST['fields']`:
13+
14+
```ts
15+
type FieldNode =
16+
| string // "name"
17+
| { field: string; fields?: FieldNode[]; alias?: string }; // nested select
18+
```
19+
20+
The object form was **declared-but-inert**. Nothing produced it, and nothing
21+
read `.fields` or `.alias` — every consumer on the path treats the list as
22+
`string[]`: `objectql`'s formula projection and its two known-field filters,
23+
`driver-sql`'s `select()`, `driver-memory`'s `projectFields`. `driver-mongodb`
24+
keyed its projection with the entry itself, so an object entry asked for a
25+
column literally named `"[object Object]"`, and the REST ingress stringified
26+
each entry before comparing it to the field map, so the same entry came back as
27+
`400 INVALID_FIELD: Unknown field '[object Object]'` — a rejection naming
28+
something the caller never wrote. An author who wrote
29+
`fields: [{ field: 'owner', fields: ['name'] }]` got it accepted by validation
30+
and then dropped or mangled, depending on the driver (ADR-0078 silently-inert
31+
declaration; ADR-0049 enforce-or-remove).
32+
33+
The capability the object form described is already served, by a different key.
34+
Removing the second spelling rather than lowering it into the first is Prime
35+
Directive #12: one capability, one contract.
36+
37+
**FROM → TO**
38+
39+
| Was | Now |
40+
| :--- | :--- |
41+
| `fields: [{ field: 'owner', fields: ['name'] }]` | `expand: { owner: { object: 'user', fields: ['name'] } }` |
42+
| `fields: [{ field: 'owner' }]` | `fields: ['owner']` |
43+
| `fields: [{ field: 'owner', fields: ['name'] }]`, one column only | `fields: ['owner.name']` (dotted path) |
44+
| `fields: [{ field: 'total', alias: 't' }]` | `aggregations` / `windowFunctions` — they carry the live `alias` |
45+
46+
The one-line fix: **a `fields[]` entry is a string.** Move nested selection to
47+
`expand`, which the engine resolves through batch `$in` queries (default max
48+
depth 3).
49+
50+
There is no `os migrate meta` step, and deliberately so: `QueryAST` is a request
51+
shape, never stored in stack metadata, so the chain has no source to rewrite. It
52+
is registered as an ADR-0087 D3 **semantic** migration
53+
(`query-field-node-object-form-retired`) on the protocol-18 step instead — the
54+
`EnhancedApiError.fieldErrors` / `BatchOptions.validateOnly` precedent. Callers
55+
move their own select lists, and both channels tell them how:
56+
57+
- **The parse.** `FieldNodeSchema` narrows to `z.string()` with an error map that
58+
answers an object entry with the prescription above, not "expected string,
59+
received object". `z.input` becomes `string`, so `tsc` fails at the authoring
60+
site first.
61+
- **The ingress.** `assertProjectionFieldsExist` judges the entry's *shape*
62+
before consulting the object's field map — it is wrong about the shape, not
63+
about this object, and a registry-less host would otherwise pass it to a driver
64+
that cannot read it. The 400 now names the retired form instead of the field
65+
`"[object Object]"`.
66+
67+
No runtime behaviour changes for anything that ever worked; the defensive
68+
unwrapping the drivers had grown against a shape nothing sends goes with it.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
---
2+
"@objectstack/types": minor
3+
"@objectstack/rest": patch
4+
"@objectstack/service-storage": patch
5+
"@objectstack/service-settings": patch
6+
"@objectstack/service-datasource": patch
7+
"@objectstack/service-i18n": patch
8+
"@objectstack/plugin-sharing": patch
9+
---
10+
11+
refactor(types,rest,services,plugin-sharing): one shared writer for the response envelope, and `error.code` is enforced at compile time (#3973)
12+
13+
`BaseResponseSchema` declares one envelope for every REST body the platform
14+
emits. It declared it once; the code that *wrote* it was copied per route
15+
module. After #3843 and #3983 converted the last drifting one, seven modules
16+
each carried their own two-line `sendOk` / `sendError` pair — so the envelope's
17+
shape lived in fourteen places rather than one.
18+
19+
`pnpm check:route-envelope` proved those seven copies agreed, which is why this
20+
is a cleanup rather than a bug fix. But a guard proves agreement; it does not
21+
create it. An eighth module starts by copying the pair again — not
22+
hypothetically: `share-link-routes.ts` was found already drifting by the
23+
repo-wide scan, and its drift had broken `client.shareLinks.create()` and
24+
`.list()` through `unwrapResponse` (#3983).
25+
26+
## What moved
27+
28+
`sendOk` / `sendError` now live once, in `@objectstack/types`
29+
(`response-envelope.ts`), and all seven modules import them:
30+
31+
| Module |
32+
|---|
33+
| `service-storage/storage-routes.ts` |
34+
| `service-settings/settings-routes.ts` |
35+
| `service-datasource/admin-routes.ts` |
36+
| `rest/external-datasource-routes.ts` |
37+
| `rest/package-routes.ts` |
38+
| `service-i18n/i18n-service-plugin.ts` |
39+
| `plugin-sharing/share-link-routes.ts` |
40+
41+
Placement was the open question in #3973, not design. `packages/spec` is
42+
schemas-only (Prime Directive #2), and the callers span `rest`, four
43+
`services/*` and one `plugins/*`, which rules out anything depending on them.
44+
`@objectstack/types` depends on nothing but `@objectstack/spec`, so every caller
45+
can reach it, and it is already where the repo puts a helper the HTTP boundaries
46+
share — `looksLikeInternalErrorLeak` (#3867) sits one file over and made the
47+
same argument first.
48+
49+
The builders take a structural `{ status(n), json(body) }`, so the package
50+
imports no HTTP contract at all: `IHttpResponse` satisfies it, and so does the
51+
`any`-typed `res` the older modules carry.
52+
53+
## `error.code` is now checked by the compiler
54+
55+
All seven copies typed the parameter `code: string`. ADR-0112 (#3841) closed the
56+
vocabulary — `ErrorCode` is `StandardErrorCode ∪ ERROR_CODE_LEDGER` — but an
57+
invented code was still caught only at runtime, by a conformance suite parsing a
58+
driven body, i.e. only on routes some test happened to drive.
59+
60+
The shared `sendError` types `code` as `ErrorCode`, so an unregistered code now
61+
fails to compile, at every call site at once:
62+
63+
```ts
64+
sendError(res, 400, 'NOT_A_REGISTERED_CODE', 'invented');
65+
// Argument of type '"NOT_A_REGISTERED_CODE"' is not assignable to parameter of type 'ErrorCode'.
66+
```
67+
68+
This cost no call-site churn: every code the seven modules emit was already
69+
registered.
70+
71+
## `extra` is closed at the same place
72+
73+
`sendError`'s last parameter is `Pick<ApiError, 'category' | 'httpStatus' |
74+
'details' | 'requestId'>` — exactly what `ApiErrorSchema` declares beside `code`
75+
and `message`.
76+
77+
It was `Record<string, unknown>` while `settings-routes` still hung `namespace` /
78+
`key` / `reason` / `fields` beside `code`. Those bodies passed every gate anyway:
79+
`ApiErrorSchema` is a plain `z.object`, so unknown keys were STRIPPED rather than
80+
rejected, and `envelopeViolations` inspects only the body's top level —
81+
conformant *by stripping* rather than by declaration. #4224 moved that module
82+
onto `details`, which is what lets the parameter close here. Closing it at the
83+
shared builder is the part that lasts: an undeclared sibling is now a compile
84+
error in every module at once, rather than a key that quietly evaporates in
85+
whichever module reintroduces it.
86+
87+
## Nothing changes on the wire
88+
89+
The seven pairs were identical modulo the optional `status` and `extra`
90+
parameters this one unions, and each module's driven conformance suite still
91+
parses its real bodies against the real spec schemas. One internal call site was
92+
rewritten: `package-routes` passed `details` positionally and now passes
93+
`{ details }`, producing the same `error.details` it always did.
94+
95+
## The guard got stronger
96+
97+
`scripts/check-route-envelope.mjs` counts response write sites per module. A
98+
module that routes everything through the shared pair builds **none** itself, so
99+
the seven now declare `0 / 0 / 0` where they used to declare `2 / 1 / 1`, and the
100+
shared pair is pinned separately at `2 / 1 / 1` so the invariant stays total for
101+
the surface rather than per-module. What the count asserts is no longer "your two
102+
builders are the enveloped ones" but "you have no builders" — and a new route
103+
that hand-rolls a body still moves it off zero and fails.

.github/workflows/lint.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,22 @@ jobs:
166166
- name: Node-version drift guard
167167
run: pnpm check:node-version
168168

169+
# #4248 packaging-hygiene guard. Without a `files` whitelist npm packs the
170+
# whole package directory, and 20 of the 49 publishable packages declared
171+
# none — so consumers installed TypeScript sources, unit tests and build
172+
# tooling, with dist/ landing on top of them rather than instead of them
173+
# (@objectstack/plugin-webhooks: 21 files, three of them unit tests). The
174+
# other 29 did declare it, so this was a hand-copied line with no gate —
175+
# the #3786 shape, where whoever forgets it gets no signal at all. Also
176+
# checks the whitelist is SUFFICIENT (covers every entry point, so
177+
# tightening one cannot ship a package that fails to resolve) and MINIMAL
178+
# (admits no test or build script), which keeps #4206's "`<pkg>/scripts/**`
179+
# is never runtime code" assumption continuously verified instead of
180+
# hand-checked. Runs its own --self-test first: the pattern semantics can
181+
# be wrong while every package is right.
182+
- name: Published-files whitelist guard
183+
run: pnpm check:published-files
184+
169185
typecheck:
170186
name: TypeScript Type Check
171187
runs-on: ubuntu-latest

content/docs/deployment/cli.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,22 @@ them temporarily, `OS_ALLOW_LAX_MEDIA_VALUES=1` (media) and
745745
per class, and re-running the corresponding migration re-establishes its flag
746746
from the data itself.
747747

748+
#### How you find out a gate is open
749+
750+
You are told, in the two places an upgrade actually looks.
751+
752+
**`os migrate meta --from 16`** — the metadata half of the upgrade — ends by
753+
naming the data migrations that remain, scoped to the field classes *your*
754+
metadata declares. It reads no database, so it reports what is left to do,
755+
never what this deployment has already done. The machine-readable output
756+
carries the same list under `dataMigrations`.
757+
758+
**The server, once per boot**, logs one line per gate that is still open here
759+
and the command that closes it. Only the lax posture announces itself: a
760+
closed gate logs that it is enforcing, and an app that declares neither class
761+
of field says nothing at all. So a running deployment always tells you the
762+
state of its own data — which is the question `os migrate meta` cannot answer.
763+
748764
### Scaffolding
749765

750766
| Command | Alias | Description |

content/docs/kernel/contracts/data-engine.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Defined by `EngineQueryOptionsSchema` in `@objectstack/spec`:
8585
```typescript
8686
interface EngineQueryOptions {
8787
where?: FilterCondition; // WHERE clause — MongoDB-style $op
88-
fields?: FieldNode[]; // SELECT fields
88+
fields?: FieldNode[]; // SELECT — field names ('name', 'owner.name')
8989
orderBy?: SortNode[]; // ORDER BY
9090
limit?: number; // LIMIT
9191
offset?: number; // OFFSET
@@ -387,7 +387,7 @@ The following legacy parameter names are accepted by the RPC layer for backward
387387
| Legacy (Deprecated) | Canonical | Notes |
388388
|:---------------------|:----------|:------|
389389
| `filter` | `where` | FilterCondition object |
390-
| `select` | `fields` | Array of FieldNode |
390+
| `select` | `fields` | Array of FieldNode (field-name strings) |
391391
| `sort` | `orderBy` | Array of `{ field, order }` |
392392
| `skip` | `offset` | Number |
393393
| `populate` | `expand` | Record of field → QueryAST |

content/docs/protocol/objectql/query-syntax.mdx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ import type { QueryAST } from '@objectstack/spec/data';
6262
// QueryAST — full structure
6363
interface QueryAST {
6464
object: string; // Target object (required)
65-
fields?: FieldNode[]; // Projection (SELECT)
65+
fields?: FieldNode[]; // Projection (SELECT) — field names
6666
where?: FilterCondition; // Filtering (WHERE) — MongoDB-style $op
6767
search?: FullTextSearch; // Full-text search ($search)
6868
orderBy?: SortNode[]; // Ordering (ORDER BY)
@@ -97,7 +97,6 @@ on the `find()` path:
9797
| `cursor` | Accepted by `EngineQueryOptions`, but no driver implements keyset pagination |
9898
| `distinct` | Not applied by `find()`; the SQL and in-memory drivers expose a separate `distinct(object, field, filters?)` method instead |
9999
| `windowFunctions` | Not applied by `find()`; `SqlDriver.findWithWindowFunctions()` is a separate method that is not on the `IDataDriver` contract |
100-
| Object `FieldNode`s (`{ field, fields }`) | The engine drops non-string entries from `fields` — use `expand` to project related records |
101100
| `search.fuzzy` / `boost` / `operator` / `minScore` / `language` / `highlight` | Parsed by the schema but ignored: only `query` and `fields` drive the expansion |
102101

103102
`top` is the exception that *is* honored — the engine normalises it to `limit`.
@@ -122,12 +121,16 @@ interface AggregationNode {
122121
filter?: FilterCondition; // FILTER WHERE clause — protocol only, never applied
123122
}
124123

125-
// FieldNode — field selection
126-
type FieldNode = string | {
127-
field: string;
128-
fields?: FieldNode[]; // nested select — protocol only, dropped by the engine
129-
alias?: string;
130-
};
124+
// FieldNode — one entry of the select list. A field name, optionally dotted to
125+
// reach through a relationship ('owner.name'). Related *records* come from
126+
// `expand`, not from inside this list.
127+
//
128+
// The `{ field, fields, alias }` nested-select member this union used to carry
129+
// was REMOVED in protocol 18 (#4196): nothing produced it and nothing read
130+
// `.fields`/`.alias`, so it was dropped by the SQL and memory drivers,
131+
// projected as a column named "[object Object]" by MongoDB, and refused as an
132+
// unknown field by the REST ingress. `expand` is the one spelling.
133+
type FieldNode = string;
131134

132135
// GroupByNode — GROUP BY target
133136
type GroupByNode = string | {
@@ -980,7 +983,7 @@ Similarly, the following legacy field names should not be used in new code:
980983
| `aggregate` (object map) | `aggregations` (array) | Array of `AggregationNode` objects |
981984
| `expand` (string array) | `expand` (Record) | Map of field name → nested QueryAST |
982985
| `skip` | `offset` | Number |
983-
| `select` | `fields` | Array of FieldNode |
986+
| `select` | `fields` | Array of FieldNode (field-name strings) |
984987
| `populate` | `expand` | Map of field name → nested QueryAST |
985988
</Callout>
986989

0 commit comments

Comments
 (0)