Skip to content

Commit c83097a

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14508-adr-org-management-open-basics
2 parents d9954a5 + a5eccf9 commit c83097a

470 files changed

Lines changed: 38948 additions & 2348 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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
A `min`/`max` over a string-valued field is described as `string`, not `number` (#16098)
6+
7+
The sibling population of the temporal fix. `min` and `max` return a value **of the aggregated field's own type**, so a `min` over a `text` / `select` / `lookup` / `autonumber` column carries a string — and `POST /api/v1/analytics/dataset/query` described every one of those columns as `type: "number"`, exactly as it did for the temporal family before the temporal half landed.
8+
9+
What changed:
10+
11+
- **`measureResultType` now answers `string` for the string-valued field types too**, in the same one table it already answered `time` from. No second mechanism and no new call site: the rule still answers `undefined` for "no correction", and `queryDataset`'s ADR-0021 result-column enrichment still applies it once, downstream of all four producers of the shape.
12+
- **The corrected spelling is `string`**, the `DimensionType` word a `lookup` or `string` DIMENSION column in the same response already carries (`dataset-compiler.dimensionType`). A textual measure spelled `text` would have been a sixth word in a five-word wire vocabulary, leaving every existing consumer branch unreached — the same argument that chose `time` over `datetime`.
13+
- **Membership is composed from `@objectstack/spec`'s own value classes** (`STRING_VALUE_TYPES`, `SINGLE_OPTION_TYPES`, `REFERENCE_VALUE_TYPES`) rather than re-listed, so what the platform says a field type STORES and what this rule says a `min` over it RETURNS cannot drift.
14+
15+
Corrected: `text`, `textarea`, `email`, `url`, `phone`, `password`, `secret`, `markdown`, `html`, `richtext`, `code`, `color`, `signature`, `qrcode`, `select`, `radio`, `lookup`, `master_detail`, `tree`, `user`, `autonumber` — twenty-one members, each verdict read off the two shipped statements of what the type stores (the spec value contract and `driver-sql`'s DDL column switch).
16+
17+
Deliberately NOT corrected, with the measurement recorded rather than a guess shipped as a declaration:
18+
19+
- **`boolean` / `toggle`** — Postgres has no `min(boolean)` at all, SQLite answers `0`/`1` as numbers, and the driver seam has been recorded answering `false`/`true`. Three readings that disagree about whether a value exists and what kind it is. `DimensionType` does carry a `boolean` word, so the correction is spellable; it is not made.
20+
- **The JSON-column classes** (`multiselect` / `checkboxes` / `tags`, `composite` / `repeater` / `record` / `location` / `address` / `vector`, `json`) — no `min` over `jsonb` on Postgres, serialized TEXT on SQLite.
21+
- **The file types** (`image` / `file` / `avatar` / `video` / `audio`) — their stored form is mid-migration under ADR-0104 D3: the value contract already says an opaque `sys_file` id while the DDL still gives them a JSON column.
22+
- **`formula`** — its result type IS declared, on `FieldSchema.returnType`, but that key is not on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape and is itself optional.
23+
- **`summary`** — measured NUMERIC on both shipped statements (the spec's `NUMERIC_VALUE_TYPES`, and `driver-sql`'s `table.float` column), so the `number` it already carried is correct rather than merely unexamined.
24+
25+
Every member of `FieldType` now carries an explicit verdict, pinned by a test that walks the enum: a field type added to the spec fails that pin instead of silently inheriting the flat `number`.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
A draft-preview `min`/`max` answers the operand's own type instead of `0`, and a preview dimension column is described by its own type
6+
7+
`POST /api/v1/analytics/dataset/query` has two producers of one response: the engine, and — when the request renders the as-if-published world over a pending seed draft (ADR-0037 P3) — `evaluateAnalyticsQueryOverRows`. The second one coerced every aggregate operand with `Number()` and dropped the non-finite ones, so a `min` / `max` over a non-numeric field answered `0`. Measured on one dataset and one row set, with two services differing only in whether a pending seed draft exists:
8+
9+
```
10+
live {"category":"travel","latest_spend":"2026-05-12"}
11+
preview {"category":"travel","latest_spend":0}
12+
```
13+
14+
That is not a mislabelled column: it is a different, wrong answer to the same query, with no refusal and no warning, on the path an author is looking at *while* authoring the dataset.
15+
16+
What changed, per member of the closed `AggregationFunction` vocabulary:
17+
18+
- **`min` / `max` return the winning operand in its own type.** Ordering goes through this file's shared `compare` — so an ISO date orders as a date, a BSON `Date` orders as its instant against wire text, and text orders the way `MIN(text_col)` does on a SQL face — with a numeric arm so a numeric column written as text (`'800'`) still orders numerically. `cross-object-rebucket.ts` settled the identical question for the recombination path: the value these two pick is a value OF the column, so it must come back in the shape the row carried.
19+
- **A group whose operand is null throughout answers `null`, not `0`**`emptyGroupValueFor` (`@objectstack/spec/data`) rules `min` / `max` over nothing unanswerable, and `0` reads as a measurement nobody made.
20+
- **`count_distinct` answers a cardinality again.** Its arm was spelled `countDistinct`, a word no producer mints (`dataset-compiler` copies the spec's `count_distinct` through), so it was unreachable and the measure fell to the numeric default — answering a row count under the author's `count_distinct` name (measured: `3` where the live path says `2`).
21+
- **`count` stays a row count and `sum` / `avg` stay arithmetic.** Counting dates is still counting.
22+
- **`sum` / `avg` over a TEMPORAL operand is deliberately unchanged.** There is no defined answer — the SQL faces do not agree on one either — and refusing an incoherent aggregate/field-type pair is an open decision, not this fix's to invent.
23+
- **A dimension column is typed from the cube dimension**, the same expression both live producers use (`d?.type || 'string'`), so a `date` dataset dimension is `time` on the preview path as it already was on the live one. A MEASURE column keeps the `number` every producer mints; correcting that is the ADR-0021 descriptor pass's one rule, not a second copy here.
24+
25+
Derived measures are untouched: `computeDerived` still coerces with `Number()` and answers `null` for a non-finite operand — but a derived ratio over a temporal `min` / `max` now sees a date instead of the spurious `0`, so it answers `null` on the preview path exactly as it already did on the live one.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): the audit binder stamps `created_at` from the system clock on an ordinary create, so a caller-supplied value no longer survives a plain `POST` (#15964)
6+
7+
The `beforeInsert` audit stamp was `record.created_at = record.created_at ?? now` — client-preferred on every insert, with no flag and no privilege required. Since the static-`readonly` strip moved INSIDE `engine.insert` (2026-09-03 ruling, option C) it runs AFTER the before-phase hooks, and its guard treats a key a `beforeInsert` hook ASSIGNED as the hook's write rather than a caller forgery. The `??` therefore laundered the caller's bytes past that strip: a normal authenticated `POST /api/v1/data/OBJECT` carrying `created_at: '1999-01-01T00:00:00.000Z'` stored exactly that on an object declaring `created_at` as `readonly: true`, while `id`, `updated_at` and every other author-declared readonly datetime in the same payload were taken. `created_at` is the audit anchor, so a forgeable one makes after-the-fact attribution untrustworthy.
8+
9+
The stamp now takes the same shape as `updated_at`:
10+
11+
```ts
12+
record.created_at = preserveAudit ? (record.created_at ?? now) : now;
13+
```
14+
15+
**What changes for a caller.** An ordinary create no longer preserves a supplied `created_at` — the value is overwritten with the server instant rather than deleted, so the column is still a real stamp and no `defaultValue` re-derivation is involved. This narrows the accept set to the `readonly` contract the field already documents; no exported symbol, schema or config key moves.
16+
17+
**The historical-import channel is unchanged and pinned.** `runImport({ treatAsHistorical: true })` sets `preserveAudit: true` on the write context (`@objectstack/rest`), and that branch still reinstates an original `created_at`, exactly as it has reinstated `updated_at`/`updated_by` since #3493. This is why the fix is the `preserveAudit` ternary rather than a bare `= now`. The create-side strip still does not read `preserveAudit` (2026-08-08 ruling, untouched): the preservation is the audit binder's, and it always was.
18+
19+
**A creator that back-dated rows through the old `??` must now ask for it.** Any insert path that supplied a historical `created_at` without `preserveAudit` now gets the server instant. The remedy is one context key on the write (`preserveAudit: true`), the same one `treatAsHistorical` sets.
20+
21+
Ruled by the maintainer on 2026-09-06 (decision batch #54, option A).
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
The `/auth` dispatcher domain no longer claims sibling namespaces such as `/authx` and `/authentication/foo`.
6+
7+
`createAuthDomain` registered `{ prefix: '/auth' }` without a `match`, and `DomainRoute.match` defaults to `'prefix'` — a bare `path.startsWith('/auth')` with no segment boundary. Every path whose first segment merely *began* with the five characters `auth` was therefore claimed by the auth domain and forwarded to the auth service, instead of falling through to the dispatcher's `ROUTE_NOT_FOUND`. Measured on a real boot (a real kernel with `AuthPlugin`, served through `createHonoApp({ kernel, prefix: '/api/v1' })`), `GET /api/v1/authx`, `/api/v1/authx/foo` and `/api/v1/authentication/foo` were all claimed; `/api/v1/aut/foo` and `/api/v1/zzz/foo` were not, which is what located the boundary at the `auth` prefix.
8+
9+
The route now declares `match: 'segment'` — the spelling the registry's other boundary-correct domains (`/keys`, `/mcp`, `/mcp/skill`) already use. It claims `/auth` exactly and everything under `/auth/`, and nothing else.
10+
11+
**What does not change.** `/auth/me/permissions` and `/auth/me/localization` still reach `dispatch()`. Neither is a better-auth endpoint, so the adapter's `/auth/*` mount disclaims them and they arrive at this domain; `'segment'` keeps claiming them, which the accompanying test pins as an overshoot control alongside the three narrowed rows.
12+
13+
**If you mounted a namespace under `/authx`, `/authentication`, or any other first segment starting with `auth`,** it was previously shadowed by the auth domain and answered by the auth service. It is now reachable — register a domain handler for it, or expect `ROUTE_NOT_FOUND`.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
A run whose nodes all succeeded is no longer reported `stranded`, journalled for repair, or re-armed because its terminal run-history write threw — which made the "repair" re-run every node after the pause.
6+
7+
`AutomationEngine.resumeInternal` called `recordLog({ status: 'completed' })` from inside the `try` whose `catch` exists for **node** failures, so a throw out of a history write on a run that had already finished successfully was handled as though a node had thrown. The arm journalled a repair snapshot, stamped `status: 'stranded'`, and answered `success: false`; `restoreConsumedSuspension` then correctly honoured that snapshot and put the pause back, so the next resume drove the downstream nodes a **second** time. The defence that normally prevents a re-armed run from becoming double-runnable reads the durable terminal row first, and on this path the durable terminal row is precisely what failed to land — so it could not fire.
8+
9+
Two statements inside `recordLog` reach that `catch`, and both are host-supplied surfaces rather than in-repo ones. `store.recordTerminal` escapes when it throws **synchronously**: the `void write.catch(...)` beneath the call only ever sees a returned promise's rejection, and a store returning a non-thenable makes `write.catch` itself a synchronous `TypeError`. Both stores shipped in this package are `async` methods and so cannot reach it, but `SuspendedRunStore` is an exported, optional-method interface a host may implement. The second statement is the run-summary line `logger.info(...)`, which is on by default (`runSummaryLog: 'info'`) and calls a host-injected `Logger`, so it needs no store at all.
10+
11+
- **The completion-path history write is now guarded at its own site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. `resume` answers the truthful `success: true`, no snapshot is journalled, `restoreConsumedSuspension` refuses with `RUN_COMPLETED`, and the downstream node runs exactly once.
12+
- **The lost history row is still reported**, at `error`, naming in its first line that the run completed, that its terminal row never landed and nothing retries it, that the run must not be repaired or re-run, and that the driver's own failure is in the record's structured slot.
13+
- **`restoreConsumedSuspension` is unchanged.** It judged correctly on the evidence it was handed; the evidence was what was wrong, and a completed run now journals none. A genuine node failure still journals, still reports `stranded`, and is still repairable.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`os generate migration`: the builtin `created_at` / `updated_at` columns now match `driver-sql` on nullability and default text, and the SQL format declares itself PostgreSQL-only.
6+
7+
Both formats emitted `NOT NULL` on the two audit-stamp columns while the driver creates them nullable, and the SQL format spelled their default `now()` while both knex producers emit `CURRENT_TIMESTAMP`. Nothing failed either way, but `information_schema` kept the pair textually apart forever, so a schema diff between a generated table and a platform-created one was permanently noisy. Both generators now follow the driver — the same rule the `id` column already follows — and `--format sql` states in its help text and its docblock that it targets PostgreSQL only and makes no MySQL or SQLite claim.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
fix(client)!: `oauth.applications.delete` resolves on the zero-byte 200 its route answers, instead of rejecting on every successful delete (#15451)
6+
7+
**BREAKING** on two independent axes, and it makes a published method usable for the first time. Before this change `client.oauth.applications.delete(id)` **rejected on every successful delete** — there was no success path a caller could observe. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs`); the version number is not the migration signal here, this entry is.
8+
9+
<!-- adr-0087: registered client-oauth-applications-delete-void -->
10+
11+
The fifth and last method of the `oauth.*` family, and the one #14312 / PR #15445 deliberately could not close: its ruling fenced that card to *narrowing published return types*, and no declared return type could be true while the `res.json()` call stood.
12+
13+
## The defect, measured end to end
14+
15+
Real `betterAuth` + real `@better-auth/oauth-provider` over the real ObjectQL adapter on real SQLite, a real signed-up user and a real session, driven through the **real** `ObjectStackClient` with only the socket stood in for:
16+
17+
```
18+
POST /api/v1/auth/oauth2/delete-client -> 200 · 0 bytes
19+
content-type: application/json
20+
content-length: (absent)
21+
through the client, BEFORE -> REJECTED: SyntaxError | Unexpected end of JSON input
22+
the row, server-side -> ALREADY GONE (get-client answers 404 not_found)
23+
through the client, AFTER -> RESOLVED | undefined
24+
```
25+
26+
The handler returns nothing and the vendor declares the endpoint `void`. `res.json()` had nothing to parse, so the method rejected — *after* the delete had committed. A caller who did the obvious thing saw a failure, retried, and the retry failed **differently**, because the row no longer existed.
27+
28+
## What changes for a caller
29+
30+
| | before | now |
31+
|:--|:--|:--|
32+
| a successful delete | rejects `SyntaxError` | resolves |
33+
| the resolved value | `any` (unreachable — the promise never resolved) | `void` |
34+
| deleting a client that is not there | rejects `not_found` | rejects `not_found` — unchanged |
35+
| a malformed non-empty body | rejects `SyntaxError` | rejects `SyntaxError` — unchanged |
36+
37+
⚠️ **The `catch` you wrote around this call stops firing on success.** Code shaped like
38+
39+
```ts
40+
try { await client.oauth.applications.delete(id); }
41+
catch { /* the delete probably worked anyway */ }
42+
```
43+
44+
still compiles and still runs, but its catch block was executing on **every** successful delete and now executes only on a real failure. Any workaround that lived in there is now inert and can be deleted. And because the promise never used to resolve, a read off its resolved value — `(await …delete(id)).deleted` — was dead code that has never executed; it now stops compiling (TS2339), which is the compiler delivering the change at the call site.
45+
46+
## Why `void`, and not `{ deleted: boolean }`
47+
48+
"Deleted" and "was already gone" **are** distinguished by the route, but on the error channel: a missing client answers 404 `{ error: 'not_found' }`, which the client already raises as a throw. The 200 answer carries zero bytes and therefore zero information, so a synthesised `{ deleted: true }` would be a shape the wire never sends and strictly less informative than the 404 a caller already receives.
49+
50+
## Why the emptiness is detected by reading the body
51+
52+
Both shortcuts were measured against the real route and both are unusable: the status is **200**, not the `204` five other delete surfaces in this client key off, and the response carries **no `content-length` header at all** — so a header test would never fire and would leave the defect in place while looking like a fix. The body itself is the only thing that answers.
53+
54+
A non-empty body is still parsed and its failure still thrown, so **the only behaviour this change moves is the zero-byte case**: a malformed response stays loud, and the day this route grows a payload, surfacing it is a deliberate widening of the return type rather than a silent change of shape.
55+
56+
`packages/client/exported-any-returns.json` loses this method's entry in the same change — the ledger is shrink-only, so the entry goes **with** the binding. Its last `oauth.*` entry is now gone; 35 sites remain open.

.changeset/config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@objectstack/plugin-dev",
4242
"@objectstack/plugin-email",
4343
"@objectstack/plugin-hono-server",
44+
"@objectstack/organizations",
4445
"@objectstack/mcp",
4546
"@objectstack/plugin-pinyin-search",
4647
"@objectstack/plugin-reports",

0 commit comments

Comments
 (0)