Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .changeset/client-json-erasure-in-repo-families.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
---
"@objectstack/client": minor
---

fix(client): bind the five in-repo `return res.json()` methods, whose published type was `Promise< any >` (part of #12104)

**Return-type narrowing on a published SDK (clause-②).** No runtime change: the
value each method resolves to is byte-identical before and after. Only the
DECLARED type moved, off `any` — which is exactly why a runtime test cannot
observe it and the pins for it are type-level.

> ⓘ Angle brackets are spaced throughout (`Promise< any >`) on purpose —
> GitHub's body sanitizer strips tag-shaped spans, backticks and fenced code
> included.

Each of the five carried no return annotation and ended `return res.json()`, so
its published type was `Promise< any >`, inherited from `lib.dom`'s
`Response.json(): Promise< any >`. The method text names neither `any` nor
`Promise` nor `unwrapResponse`, which is why the class was invisible to the
greps two earlier censuses used.

## What each method declares now

| method | declared before | declares now | why |
|---|---|---|---|
| `client.analytics.query` | `any` | `BaseResponse & { data: AnalyticsResult }` | dispatcher-served; `deps.success(v)` wraps and `res.json()` strips nothing |
| `client.analytics.meta` | `any` | `AnalyticsMetadataResponse` | same envelope; `data` is the bare `CubeMeta[]` projection |
| `client.analytics.explain` | `any` | `AnalyticsSqlResponse` | same envelope; `data` is `{ sql, params }` |
| `client.automation.trigger` | `any` | `BaseResponse & { data: AutomationResult }` | same envelope, over the payload its sibling `automation.execute` unwraps |
| `client.analytics.queryDataset` | `any` | `AnalyticsResult` | served by `@objectstack/rest`, which ends `res.json(result)` — no envelope |

`any` is assignable to everything and admits every property read, so a
consumer's code can stop compiling where it previously did not. Concretely:

- **Reading a payload key off one of the four ENVELOPED results.**
`(await client.analytics.query(q)).rows` compiled and was `undefined` at
runtime; the read the wire always required is `.data.rows`. Same for
`.data` on `meta` / `explain`, and `.data.runId` / `.data.screen` on
`automation.trigger`.
- **Reading `.data` off `queryDataset`**, which is served bare — likewise
`undefined` today, likewise refused now.
- Assigning any of the five results to an unrelated annotation, or forwarding
one to a differently-typed parameter.

That break is the point: those call sites are already wrong at runtime and the
`any` is what hid it. The compiler is the channel that reaches every affected
consumer, and it is strictly more precise than a release note.

## How the shapes were established

By DRIVING the real producers — a real `AnalyticsService`, a real
`AutomationEngine`, the real `HttpDispatcher` and the real `RestServer`, with
only the socket stood in for — not by reading source and not by asserting
against a mock. Two spec response types that look like the right binding are
NARROWER than the contract their route relays
(`AnalyticsResultResponseSchema.data.fields` and
`TriggerFlowResponseSchema.data`), so those two annotations bind the producer's
contract instead; the near-miss is pinned so a later sweep cannot retarget them.

## Scope

The five families whose producers live in this repo. The 38 better-auth-backed
`auth.*` / `organizations.*` / `oauth.*` methods of the same class are untouched
and keep their erased `any` — they are exactly as permissive as before, and no
consumer loses anything by that.

No ADR-0087 ledger entry: nothing here is a metadata surface. No Zod schema, no
`packages/spec` declaration and no stored representation changed — the erasure
lived only in a TypeScript return annotation — so `objectstack migrate meta` has
nothing to rewrite and an entry would have no artifact to project into. This is
the disposition #8140, #11925 and #12034 recorded for the same class of SDK
return-type narrowing.
5 changes: 0 additions & 5 deletions packages/client/exported-any-returns.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,6 @@
"$comment": "Exported callables of @objectstack/client whose AWAITED return type resolves to `any` (#11927). Judged against the BUILT dist by `pnpm --filter @objectstack/client check:exported-any-returns`, because the erasure is invisible in source text when a method carries no return annotation. SHRINK-ONLY and EXACT in both directions: a site here that no longer resolves to `any` is RED until its entry is deleted, and a site NOT here that resolves to `any` is RED — that unlisted case is the everyday one and the reason this file exists. There is deliberately NO --update flag: every entry is debt with a name on it, and a reason a tool wrote is a silencer rather than a worklist. SCOPE, and the one exclusion worth stating out loud: a return type that CONTAINS `any` (`{ packages: any[]; total: number }`, `Promise<Record<string, any>>`) is not listed, because it is not flagged — the gate asks whether the type IS `any`, the same line packages/spec's check:exported-any draws, and admitting the broader question costs the gate its zero-false-positive property. That is why 21 of #11925's 38 unannotated methods are absent here: they are `any`-CONTAINING, and they remain #11925's to close. Nothing is silently absorbed in either direction. A caller-supplied `<T = any>` is likewise never listed: the record type and the action payload really are the caller's, and flagging them is the pressure that turns a correct generic into a wrong concrete type.",
"entries": {
"ObjectStackClient.meta.migrateStored": "#11925 — no return annotation; the published type comes from `this.unwrapResponse<any>(res)`. Invisible to a `Promise<` grep because the text never appears in the method. Bind the contract the route actually answers, minding the envelope.",
"ObjectStackClient.analytics.query": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
"ObjectStackClient.analytics.meta": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
"ObjectStackClient.analytics.explain": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
"ObjectStackClient.analytics.queryDataset": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
"ObjectStackClient.organizations.create": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
"ObjectStackClient.organizations.update": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
"ObjectStackClient.organizations.setActive": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
Expand DownExpand Up@@ -45,7 +41,6 @@
"ObjectStackClient.auth.twoFactor.disable": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
"ObjectStackClient.auth.twoFactor.verifyBackupCode": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
"ObjectStackClient.auth.accounts.unlink": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
"ObjectStackClient.automation.trigger": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.",
"ObjectStackClient.automation.create": "#11924 — DELIBERATE `Promise<any>`: `POST /automation` ends `deps.success(body)`, echoing the caller's own unvalidated bytes, and `IAutomationService.registerFlow` returns nothing, so the service contract has no return shape to relay. This needs a DECISION (keep echoing, or answer the registered `FlowParsed`), not an annotation.",
"ObjectStackClient.automation.update": "#11924 — DELIBERATE `Promise<any>`: `PUT /automation/:name` ends `deps.success(definition)` where `definition = body.definition ?? body`. Same missing contract as `automation.create`, and the two should be answered together since they are one route class."
}
Expand Down
3 changes: 3 additions & 0 deletions packages/client/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,10 @@
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/plugin-hono-server": "workspace:*",
"@objectstack/rest": "workspace:*",
"@objectstack/runtime": "workspace:*",
"@objectstack/service-analytics": "workspace:*",
"@objectstack/service-automation": "workspace:*",
"tsx": "^4.23.12",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
Expand Down
Loading
Loading