Skip to content

Commit 5693a3e

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/rest-read-path-field-degradation-4240d5
2 parents 36ec14e + 366105c commit 5693a3e

87 files changed

Lines changed: 2331 additions & 1297 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.

.changeset/console-pin-pr-gate.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
---
3+
4+
ci: build the pinned objectui SHA on the PR that moves it (#4290)
5+
6+
`.objectui-sha` is the single source of truth for the vendored Console SPA —
7+
release.yml reads it, clones objectui at that commit, builds
8+
`@object-ui/console` and copies the dist into `packages/console/`. Editing that
9+
one line replaces the entire frontend the platform ships, and nothing on the PR
10+
side ever built it.
11+
12+
The pin appeared in exactly two workflows, neither a gate: release.yml
13+
(post-merge, on the main push) and showcase-smoke.yml (manual + nightly, whose
14+
own header says "it never gates PRs"). It is also a root dotfile, so it matched
15+
neither of ci.yml's `core` and `docs` filters — #4288 moved the pin 76 commits
16+
with six of fourteen checks skipped, including Build Core, Test Core, Build Docs
17+
and the Dogfood gate. A SHA that cannot build — a typo, a commit that was
18+
force-pushed away, a broken objectui, a dead bundle canary — reached `main`
19+
green and would have surfaced at publish time. The only thing between the two
20+
was the author remembering to run `scripts/build-console.sh` locally.
21+
22+
Adds ci.yml's `Console Pin Gate`: a `console` paths filter over `.objectui-sha`,
23+
`scripts/build-console.sh` and the two files that gate them, and a job that
24+
builds `@object-ui/console` at the pin against this tree's `@objectstack/client`
25+
and then runs `pnpm check:console-sha`.
26+
27+
Two details that keep it honest rather than merely green:
28+
29+
- It restores `packages/console/dist` under **release.yml's existing cache key**
30+
(`hashFiles('.objectui-sha', 'scripts/build-console.sh')`), so only a pin the
31+
repo has never built misses — and a miss is exactly when the gate has work to
32+
do. Watching the workflow and the drift guard therefore costs about a minute,
33+
not a full vite build.
34+
- The restore/save pair is **split**, where release.yml uses the combined
35+
action, because the combined post-step saves even when the job failed.
36+
`build-console.sh` writes the SHA stamp before it asserts the bundle canary,
37+
so a canary failure leaves a stamped-but-broken dist that would be cached,
38+
restored, and then waved through the stamp check. Saving only once every
39+
assertion is green is what lets a restored dist carry the same guarantees as a
40+
freshly built one. A separate step asserts a real dist is on disk first, since
41+
`check:console-sha` deliberately exits 0 when there is no dist at all.
42+
43+
Scope, stated plainly: this proves the pinned SHA builds. It does not cover a
44+
`packages/client` change breaking the injected-client bundle — that input lives
45+
under `packages/**`, and watching it here would rebuild the console on a large
46+
fraction of every PR. release.yml remains the only check for that direction.
47+
48+
CI and docs only; this releases nothing.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
---
3+
4+
test-only: the D12 honesty gate iterates the known-fake inventory — every
5+
`CORE_FALLBACK_FACTORIES` product registered slot-by-slot must come out of
6+
both discovery builders as `degraded`, never `available` (#3898 suggestion 4;
7+
`cache`/`queue`/`job` had no per-slot pin before). Releases nothing.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/service-datasource": patch
3+
"@objectstack/rest": patch
4+
---
5+
6+
fix(service-datasource,rest): the last three uncovered datasource routes answer their registered refusal code (#4264)
7+
8+
#4249 (fixed in #4263) gave the rest surface's two introspection routes a
9+
failure contract; this closes the same gap on the three sibling routes it left
10+
uncovered. Each had no `catch` around its service call, so a service throw was
11+
swallowed by the adapter and surfaced as the pre-#3675 non-envelope
12+
`500 { error: 'No response from handler' }` — no `success` flag, no
13+
`error.message`, no code to switch on, real cause lost.
14+
15+
Wire-visible changes — each route now answers `400` in the declared envelope,
16+
under the refusal code registered (ADR-0112) for the service it dispatches to,
17+
with the service's own message at `error.message`:
18+
19+
- `GET /api/v1/datasources` (`listDatasources` throw) →
20+
`400 DATASOURCE_ADMIN_ERROR` — matching its eight siblings in
21+
`service-datasource/admin-routes.ts`, which already answer their catches this
22+
way.
23+
- `POST /api/v1/datasources/:name/external/refresh-catalog` (`refreshCatalog`
24+
throw) and `POST /api/v1/datasources/:name/external/validate` (`validateAll`
25+
throw) → `400 EXTERNAL_DATASOURCE_ERROR` — the same code #4249 gave the two
26+
introspection routes one block above them.
27+
28+
The issue left the code choice open (`INTERNAL_ERROR` was the alternative);
29+
the registered per-service codes win on consistency: every other catch in both
30+
modules — including pure reads — already answers 400 with the service-attributed
31+
code, and `refreshCatalog`'s dominant throw class (unknown datasource,
32+
unreachable remote, no such schema) is the one #4249 already adjudicated as a
33+
400 refusal on `listRemoteTables`. A 500 here would fork the failure contract
34+
within a module — the drift #4249 removed.
35+
36+
No new codes: both were registered in the error-code ledger by #4263. The
37+
envelope-conformance suites and the `REFUSALS` pin table gain one row per
38+
route.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
"@objectstack/plugin-dev": patch
3+
"@objectstack/types": minor
4+
---
5+
6+
fix(plugin-dev,types): the production escape hatch stops being silent (#3900)
7+
8+
`DevPlugin.init()` refuses to run under `NODE_ENV=production` (ADR-0115 D6), and
9+
`OS_ALLOW_DEV_PLUGIN` overrides that refusal. As shipped, the override returned
10+
early with **no output at all**: the process ran the development assembly while
11+
every log line and the ready banner read like an ordinary production start.
12+
13+
That reproduces, one level up, the defect the guard exists to close. The guard's
14+
own precedent says so — `OS_ALLOW_DEGRADED_TENANCY` boots degraded *and brands
15+
it everywhere an operator looks*, and `OS_ALLOW_DRIVER_CONNECT_FAILURE`'s
16+
contract is "logged loudly at startup". An escape hatch that says nothing leaves
17+
the operator's only evidence of a degraded state in an env var they may not have
18+
set themselves.
19+
20+
**The override now brands itself, twice.** A warning at `init()` — emitted
21+
before any assembly work, so it survives an assembly step that later throws —
22+
and a repeat on the ready banner, which is the surface an operator actually
23+
reads:
24+
25+
```
26+
⚠ DEV ASSEMBLY UNDER NODE_ENV=production (OS_ALLOW_DEV_PLUGIN is set) — the boot
27+
guard was explicitly overridden. This process is running the DEVELOPMENT
28+
assembly, which is not hardened for production traffic (ADR-0115 D6).
29+
• Auth secret is the default published inside @objectstack/plugin-dev. It is
30+
public, so anyone can mint a session this stack accepts. Pass `authSecret`
31+
explicitly.
32+
• Data goes to the in-memory driver with persistence disabled — every record
33+
is lost when this process exits.
34+
```
35+
36+
Only hazards that are live for *that* configuration are named: the secret line
37+
is suppressed when the operator passed their own `authSecret`, and the driver
38+
line when the `driver` toggle is off. The dev-admin seed is deliberately absent
39+
`plugin-auth`'s `maybeSeedDevAdmin` is hard-gated to
40+
`NODE_ENV === 'development'` and cannot fire on this path, so warning about it
41+
would spend the attention the real hazards need.
42+
43+
**New export — `resolveAllowDevPlugin()` (`@objectstack/types`).** The flag moves
44+
off a bare `process.env['OS_ALLOW_DEV_PLUGIN'] === '1'` and joins the
45+
`OS_ALLOW_*` family's shared truthy vocabulary, next to
46+
`resolveAllowDegradedTenancy` / `resolveAllowDriverConnectFailure`.
47+
48+
FROM → TO for operators: `OS_ALLOW_DEV_PLUGIN=1` keeps working unchanged.
49+
`OS_ALLOW_DEV_PLUGIN=true` (and `on` / `yes`, case-insensitive, surrounding
50+
whitespace ignored) **now takes effect** where the strict comparison previously
51+
ignored it and failed the boot. That is a widening, in the direction an operator
52+
setting the flag already intended; falsy and unrecognised values still refuse to
53+
boot, and unset still means "fail fast". If you were relying on
54+
`OS_ALLOW_DEV_PLUGIN=true` being inert as a way to keep the guard armed, unset
55+
the variable instead.
56+
57+
No change to the refusal path, which this issue re-verified end to end:
58+
`kernel.use()` only registers, `initPluginWithTimeout` does not catch,
59+
`bootstrap()` rethrows, and `os serve`'s outer handler prints the message and
60+
exits `1`. The `throw` is genuinely fatal here, so it needs none of the
61+
`process.exit(1)` the tenancy guard required for sitting inside a broad `catch`.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
fix(dx): remove the per-package `lint` scripts (`eslint src`) from `@objectstack/verify`, `@objectstack/cli` and `@objectstack/lint` (#4276). A standalone run resolves the root flat config but honors the inline directives the root gate's `--no-inline-config` exists to ignore, so it fails on rules the config never registers — verify was red on a clean HEAD. Lint only from the root; the scoped recipe is documented in `eslint.config.mjs`. Dev scripts only; releases nothing.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/plugin-hono-server": major
3+
---
4+
5+
feat(plugin-hono-server)!: delete the CRUD/discovery convenience surface and the `registerStandardEndpoints` flag — the plugin is a transport adapter (#4073)
6+
7+
Completes the retirement. `HonoServerPlugin` now owns the socket, the middleware
8+
and the three current-user endpoints, and nothing else. The data and discovery
9+
APIs have one owner each: `@objectstack/rest` and the runtime dispatcher
10+
(ADR-0076 D11).
11+
12+
**Removed**
13+
14+
- `POST/GET /api/v1/data/:object` and `GET /api/v1/data/:object/:id` — the raw
15+
C+R surface that delegated straight to ObjectQL.
16+
- `GET /api/v1/discovery` and `GET /.well-known/objectstack` — this plugin's
17+
third discovery payload, which predated `DiscoverySchema` and could not
18+
satisfy it (no `services`, the ADR-0076 D12 source of truth).
19+
- The `registerStandardEndpoints` option. It is gone, not defaulted off: passing
20+
it is now a type error, and passing it via `as never` mounts nothing.
21+
22+
**Unaffected**
23+
24+
- `/auth/me/permissions`, `/auth/me/localization` and `/me/apps` — this plugin
25+
is the platform's only supply and they register unconditionally (#4144).
26+
- Every composed host: `os serve`, `objectstack dev`, cloud's objectos and every
27+
documented composition mount REST and/or the dispatcher, which already served
28+
these routes and answered byte-identically with the flag on or off (#4260).
29+
30+
**Migration** — only a host that mounts `HonoServerPlugin` with neither owner is
31+
affected. It now has no data or discovery API, and the boot warns once naming
32+
both remedies. Mount `createRestApiPlugin` from `@objectstack/rest` for full
33+
CRUD behind the gate stack, or `createDispatcherPlugin` from
34+
`@objectstack/runtime`. There is no flag to opt back in.
35+
36+
**Why** — the surface was duplicate and lesser supply (C+R only, a subset of the
37+
gates, a non-conforming discovery payload), and it charged rent: #2567, #3298
38+
and #4018 each had to re-implement a platform invariant on it after the fact,
39+
because a second implementation of a route is a second place every future
40+
invariant must be remembered.

.changeset/query-cursor-removed.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/client": major
4+
---
5+
6+
refactor(data)!: `query.cursor` is removed — no driver ever implemented keyset pagination (#4286 step 4)
7+
8+
`cursor` promised keyset pagination and nothing served it: the key was accepted
9+
and ignored, so every page came back identical — a caller looping "until
10+
`hasMore` is false" never terminated. It was Tier A of the #4286 inventory: a
11+
shipped public producer (`QueryBuilder.cursor()`) minting a key no executor
12+
read.
13+
14+
**FROM → TO**
15+
16+
| Was | Now |
17+
| :--- | :--- |
18+
| `cursor: { created_at: last.created_at }` | `where: { created_at: { $gt: last.created_at } }` + the matching `orderBy` |
19+
| `QueryBuilder.cursor({...})` | `.where({ created_at: { $gt: ... } }).orderBy('created_at')` |
20+
21+
The one-line fix: **delete the key and seek with `where` on your sort key**
22+
every driver already executes that, with canonicalised temporal comparands.
23+
24+
Mechanics: `retiredKey()` tombstones on both declaration sites
25+
(`QuerySchema.cursor` and `EngineQueryOptionsSchema.cursor`, one shared
26+
prescription), so authoring the key fails `tsc` and a query still carrying it
27+
fails to parse with the fix. `QueryBuilder.cursor()` is deleted. Registered as
28+
the protocol-18 semantic migration `query-cursor-retired` (request surface —
29+
nothing stored to rewrite). The caller-built `Record<string, unknown>` shape
30+
would not survive a real keyset design anyway: a first-class cursor, if ever
31+
built, will be a response-minted opaque token (the pattern the
32+
metadata-revision / flow-run / notification list endpoints already use — those
33+
`cursor` params are unrelated and unchanged).
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/client": major
4+
"@objectstack/metadata-protocol": minor
5+
---
6+
7+
refactor(data)!: `query.distinct` is removed, and with it the mis-wired REST count suppression (#4286 step 4)
8+
9+
`distinct` promised `SELECT DISTINCT` and no driver ever rendered it — but it
10+
was **mis-wired rather than merely dead** (#4286 finding 2, the harsher
11+
ADR-0078 class): its only observable effect platform-wide was that the REST
12+
list path treated a distinct query as *not countable*, silently degrading
13+
`total`/`hasMore` to a page-local estimate while still returning duplicate
14+
rows. A caller — or a self-verifying agent — saw the response change and
15+
concluded the flag worked. It had a shipped public producer
16+
(`QueryBuilder.distinct()`).
17+
18+
**FROM → TO**
19+
20+
| Was | Now |
21+
| :--- | :--- |
22+
| `distinct: true` for unique combinations | `groupBy: ['category']` |
23+
| `distinct: true` + count | `aggregations: [{ function: 'count_distinct', field: 'category', alias: '...' }]` |
24+
| one column's distinct values | the SQL/memory drivers' `distinct(object, field)` door (driver-level) |
25+
26+
The one-line fix: **delete the key**; deduplicate with `groupBy` /
27+
`count_distinct`.
28+
29+
Mechanics: `retiredKey()` tombstones on both declaration sites
30+
(`QuerySchema.distinct` and `EngineQueryOptionsSchema.distinct`, one shared
31+
prescription); `QueryBuilder.distinct()` is deleted; registered as the
32+
protocol-18 semantic migration `query-distinct-retired`. **Observable REST
33+
change (`@objectstack/metadata-protocol`):** the count-suppression branch is
34+
deleted — a list request that used to carry `distinct` now gets a real
35+
`total`/`hasMore` again (that restoration is the point, not a side effect).
36+
The per-aggregation `distinct` flag (`AggregationNode.distinct`) is a
37+
different, live member and is untouched.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/metadata-protocol": minor
5+
---
6+
7+
feat(objectql)!: `query.having` is enforced — the engine applies it after aggregation (#4286 step 3, ADR-0049 resolved to enforce)
8+
9+
`having` had been declared on the request surface since AST v2 and executed by
10+
nothing. #4286 finding 1 showed the gap was structural: `engine.aggregate()`
11+
rebuilt the driver AST with exactly `object`/`where`/`groupBy`/`aggregations`,
12+
so even a driver that *did* implement HAVING could never have received it, and
13+
the one wire path (`findData`'s aggregate branch) dropped the clause too. It
14+
was the strongest enforce candidate of the #4286 set — the clause every
15+
SQL-literate author (human or model) expects to work next to
16+
`groupBy`/`aggregations` — and it is now live end to end:
17+
18+
- **Engine-owned, both paths.** `applyHaving()`
19+
(`packages/objectql/src/having-filter.ts`) runs AFTER aggregation on the
20+
native-driver path and the in-memory fallback alike — the same
21+
correct-first / optimize-later two-tier shape date bucketing uses. Native
22+
SQL `HAVING` pushdown can come later behind a driver capability flag without
23+
changing semantics.
24+
- **Namespace: the aggregated row's own columns** — aggregation aliases
25+
(`order_count`, `total`) and groupBy projections — with the ordinary
26+
FilterCondition operators plus `$and`/`$or`/`$not`.
27+
- **An unknown operator rejects loudly.** Ignoring one (as tolerant matchers
28+
do) would silently return unfiltered aggregates — the exact ADR-0078
29+
silently-inert failure enforcement exists to end.
30+
- **The wire path forwards it.** `findData`'s aggregate branch passes
31+
`having` through, and `EngineAggregateOptionsSchema` now declares it.
32+
- The FLS predicate guard already walked `having` references
33+
(`predicate-guard.ts`), which is what made enforcement safe to turn on.
34+
35+
No migration needed: queries that carried `having` before were silently
36+
returning every group; they now filter as written. A caller who depended on
37+
the clause being *ignored* (sending `having` and expecting unfiltered
38+
results) sees the corrected behavior — that is the enforcement, not a
39+
regression.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(data): a dotted-path `sort` (`?sort=account.company_name`) is rejected with `400 INVALID_SORT`, not silently unapplied (#4256)
6+
7+
The one sort shape #4226 deliberately left open is now closed. A dotted path
8+
passed the sort gate on its head segment (`account` is a real field) and was
9+
then unusable by every driver: `SqlDriver` handed it to Knex, which rendered
10+
`"account"."company_name"` against a table that was never joined, and the
11+
#3821 unknown-column backstop retried **without the sort**; Mongo and the
12+
memory driver resolved the path against the row itself, where a foreign key is
13+
a scalar id. Result: `200`, every row present, arbitrary order — and since
14+
`sort` + `top` is how a caller asks for "the latest N", an arbitrary N with
15+
nothing in the response to reveal it.
16+
17+
The rejection distinguishes the two mistakes a dotted path can be:
18+
19+
- a head that IS a relationship (`project_id.name`) — the message names the
20+
relationship it tried to cross and prescribes the supported alternative:
21+
denormalise the value onto the queried object (formula or rollup field) and
22+
sort by that;
23+
- a head that is not (`title.length`) — the message states the contract: sort
24+
reaches only whole columns of the queried object, not values inside them.
25+
26+
An unknown head (`no_such.title`) keeps the existing typo-shaped answer, and a
27+
list carrying both mistakes reports the typo first — the same precedence the
28+
expand gate uses.
29+
30+
**What changes for callers:** requests whose sort crosses a relationship now
31+
fail loudly instead of receiving an ordinary-looking 200 over unordered rows.
32+
A survey of framework, objectui and cloud found zero callers emitting a dotted
33+
sort (objectui's column-header sort keys lookup columns by their flat field
34+
name and loads relations via `$expand`), so the practical blast radius is
35+
hand-authored requests — exactly the callers the silent degradation was
36+
misleading. Internal callers reaching `engine.find()` directly are unaffected,
37+
the same tiering every #4226 gate uses.

0 commit comments

Comments
 (0)