@ultimat3/query 11.1.0 → 11.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -18,6 +18,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
18
18
  | `http.ts` | route projection (`GET /_x/query/<kebab>`, `enforcedBy: 'handler'`) |
19
19
  | `mcp-tool.ts` | MCP read descriptor, same `sourceFor` |
20
20
  | `client.ts` | typed read client (browser-safe: no server imports) |
21
+ | — | opt-in flight control is **`@ultimat3/core`**'s `client-flight.ts` + `client-wire.ts`, re-exported from `src/index.ts`. There is no local copy and must not be one |
21
22
  | `naming.ts` | export name → `/_x/query/<kebab>`. Pure string math. **Paths only** — no tool name |
22
23
  | `registry.ts` | export-name registration, `describeQueries()`, and the `registerPrimitiveRegistrar('query', …)` announcement |
23
24
  | `live.ts` | `LiveQuery` descriptor + cursor arithmetic |
@@ -162,6 +163,50 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
162
163
  - `registry.ts` announces `registerQueries` in core's registrar table at import. That is how
163
164
  `defineApi({ queries })` in `@ultimat3/action` registers a read without importing this package
164
165
  sideways. Never remove the announcement: `defineApi` would then throw `X_REGISTRAR_MISSING`.
166
+ - **Flight control is `@ultimat3/core`'s, and this package RE-EXPORTS it.** `client-flight.ts` and
167
+ `client-wire.ts` shipped here and in the other tier-3 client package as byte-identical copies —
168
+ 288 and 85 lines — kept in step by a `client-twin.test.ts` in each. A test that makes drift LOUD
169
+ is not the same as a file that cannot drift, and this package's own thesis is that duplication is
170
+ the defect. Both files import nothing but tier 0, which was always the argument for where they
171
+ belong; the one blocker was `isJsonObject`, now `@ultimat3/core`'s `json-object.ts`, re-exported
172
+ from `./stable` here. `createClientFlight`, `DEFAULT_CLIENT_RETRY`, `isTransientFailure`,
173
+ `isSuperseded`, `ClientFlight`, `ClientFlightOptions`, `ClientRetry`, `FlightKeyOptions`,
174
+ `FlightPlan` and `WireAnswer` are all still importable from `@ultimat3/query` — the same names,
175
+ and now literally the same objects the other package exports. Never re-declare one here; the
176
+ fix for anything wrong with the pipeline is an edit in `packages/core/src/client-flight.ts`.
177
+ - **Every mechanism underneath the flight is `@ultimat3/core`'s, the pipeline included.**
178
+ `createSingleFlight` for dedup, `createFence`/`isSuperseded` for supersession, `createFlightGate`
179
+ for the ceiling, `retry` + `backoffDelay` for the schedule, `isRetryableStatus` for the status
180
+ table, `X_TIMEOUT` for the deadline — and `createClientFlight`, which composes them. This package
181
+ declares NO new error code for any of it; never add a second curve, a second fence or a private
182
+ retry loop here, and `bun run flight-copies` is what says so.
183
+ - **`isTransientFailure` INVERTS `retryDecision`'s unclassified default, and the inversion must
184
+ survive** (`As of 2026-08-23`). `retryDecision` sends a throw nobody classified again until the
185
+ attempts run out; `@ultimat3/ai` and `@ultimat3/db` each refused the executor outright over it.
186
+ The client keeps the executor and supplies a predicate instead: a declared
187
+ `retryable`/`retry-after`, plus a dispatch that produced no response at all (`fetch` rejecting
188
+ with a plain `TypeError`), and nothing else — a caller's own `AbortError` and a foreign value are
189
+ terminal. The loop is stopped by RESOLVING to a private sentinel rather than by throwing, so the
190
+ original value still reaches the caller unwrapped, which is the property `retry`'s own header
191
+ promises. It lives in `packages/core/src/client-flight.ts` now; the tests that pin it from this
192
+ side still drive it through this package's own client.
193
+ - **`ClientFlight` is a TYPE inside `client.ts` and never a value.** That erasure is the entire
194
+ tree-shaking story: `rpc` alone is 14,759 B minified for the browser and `queryClient` alone is
195
+ 12,755 B, against 20,292 B / 17,912 B with `createClientFlight` imported beside them — ±376 B run
196
+ to run, which is `Bun.build` 1.4.0 dropping core's `schema-error-codes.ts` (issue #273). A caller
197
+ who wants a plain typed fetch must not pay for the fence, the dedup map or the retry loop —
198
+ `packages/cli/src/templates/resource-form-island.ts` and `examples/dummy`'s contact-sales island
199
+ both write a bare `fetch` today because that bill used to be unavoidable. Never import
200
+ `createClientFlight` for a VALUE from `client.ts` — `ClientFlight` and `ClientRetry` are
201
+ `import type` from `@ultimat3/core` and must stay that way.
202
+ - **The `sideEffects` array is what makes the barrel shakable, and it is load-bearing** (`As of
203
+ 2026-08-23`). Declaring nothing meant a bundler had to assume every module ran at import, so
204
+ `import { rpc } from '@ultimat3/action'` was 43,104 B and `import { queryClient } from
205
+ '@ultimat3/query'` was 40,859 B — three times the deep-import cost, through the ONLY specifier
206
+ the `exports` map offers. The arrays are the ones `bun run scripts/side-effects.ts --explain
207
+ --json` measures, and they must stay that: `errors.ts` runs `registerErrorCodes` at import in both
208
+ packages, and query's `registry.ts` runs `registerPrimitiveRegistrar('query', …)` — drop either
209
+ and a bundled app loses its error titles or throws `X_REGISTRAR_MISSING`. Never `false`.
165
210
  - Policy runs per subscriber for live queries. Never cache a decision across actors.
166
211
  - The matcher patches from `QueryShape`, never from SQL text.
167
212
  - `paginate` has no `offset` parameter and must never grow one, and it is reachable **only** as
@@ -304,7 +349,8 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
304
349
  date window shared one live query id. The hash form tags a `Date`, a `Map` and a `Set`
305
350
  (`Date(<epoch>)`, `Map(…)`, `Set(…)`) beside the bare `NaN` / `±Infinity` / `-0` tokens it
306
351
  already emitted, all for the reason a bare token exists: `'null'` collided with JSON `null` and
307
- `String(-0)` is `"0"`. `stable.ts` keeps `isJsonObject`/`columnOf` and nothing else. Ordinary
352
+ `String(-0)` is `"0"`. `stable.ts` keeps `columnOf` and a re-export of core's `isJsonObject` —
353
+ that predicate went down to tier 0 the same way, when `client-wire.ts` did. Ordinary
308
354
  inputs are byte-identical, so the durable-key cost is confined to reads whose input carries a
309
355
  `Date`, a `Map` or a `Set`: those cursors answer `X_CURSOR_INVALID` once, and their cache entries
310
356
  are cold once. `query-hash.test.ts` is the pin, at `queryHash` and at `cacheKeyFor`.
package/README.md CHANGED
@@ -53,6 +53,54 @@ const [post] = await queries.publicPost({ slug }); // typed input, typed rows
53
53
 
54
54
  Both spellings run `queryClientMethodFor`, so a read has one URL however it is addressed.
55
55
 
56
+ ### Flight control — `createClientFlight`, opt-in
57
+
58
+ A typed read is one `fetch` and nothing else until a `flight` is installed. With one, N concurrent
59
+ identical reads become ONE dispatch, a fence can retire everything issued before now, and a failure
60
+ worth sending again is sent again on `@ultimat3/core`'s one backoff curve.
61
+
62
+ ```ts
63
+ import { createClientFlight, isSuperseded, queryClient } from '@ultimat3/query';
64
+
65
+ declare const session: () => { userId: string };
66
+ declare const baseUrl: string;
67
+ type Api = { queries: Record<string, never> };
68
+
69
+ const flight = createClientFlight({
70
+ principal: () => session().userId, // dedup is OFF without this — see below
71
+ retry: { attempts: 3 }, // default is `attempts: 1`, i.e. no retry
72
+ deadlineMs: 10_000,
73
+ limit: { maxConcurrent: 6, maxQueued: 12 },
74
+ });
75
+
76
+ export const queries = queryClient<Api['queries']>({ baseUrl, flight });
77
+
78
+ // A route change, a sign-out, a tenant switch: everything already issued stops being addressed
79
+ // to anybody, and every read still open is aborted.
80
+ flight.bump();
81
+ ```
82
+
83
+ | Rule | Why it is that way |
84
+ |---|---|
85
+ | the dedup key is `[principal, url]`, and **no principal means no dedup** | a key that is only the URL lets one caller join another's still-open read across a sign-in or a tenant switch |
86
+ | a caller-supplied `signal` is **never** shared | the leader owns the request, so one caller's abort would cancel every other caller's read; refcounting joiners is the "correct" fix and this is the one that cannot be wrong |
87
+ | `{ fresh: true }` opts one read out | the case it exists for is "this read exists BECAUSE something just changed", which must not join a dispatch that left before the change did |
88
+ | every joiner parses its own rows | what is shared is the immutable body TEXT; handing two callers one array hands each of them the other's edits |
89
+ | a fenced answer is `X_SUPERSEDED`, never a failure | `isSuperseded(error)` is the read; a caller that cannot tell it from a refusal retries a request its own context has already replaced |
90
+ | an **unclassified** throw is not retried | core's `retryDecision` retries anything nobody classified — a caller's own `AbortError` included. A client retries a declared `retryable`/`retry-after`, plus a dispatch that produced no response at all |
91
+ | a deadline is `X_TIMEOUT` | core's code, already classified `retryable`, so a caller's own loop needs no table |
92
+
93
+ `createClientFlight` is **`@ultimat3/core`'s**, re-exported here: it is the same object
94
+ `@ultimat3/action` re-exports, because both packages are tier 3 and neither may import the other.
95
+ It shipped as a byte-identical copy in each; the copies are gone and every name is importable from
96
+ this package exactly as before.
97
+
98
+ Bundle cost is why `ClientFlight` is a TYPE inside `client.ts` and never a value: importing
99
+ `queryClient` alone from this package is **12,755 B** minified for the browser, and adding
100
+ `createClientFlight` is **17,912 B**. A caller who wants a plain typed fetch pays for none of it.
101
+ Expect ±376 B run to run — `Bun.build` 1.4.0 drops `@ultimat3/core`'s `schema-error-codes.ts` from
102
+ some builds even though `sideEffects` names it (issue #273).
103
+
56
104
  The declaration is lifted too: `.input`, `.policy`, `.cache`, `.mcp`, `.isLive`. `sql` is not
57
105
  among them — it lives in a private store inside `read.ts`, so `sourceFor` is the only thing
58
106
  that can build a source and there is nowhere for a second authz path to hide. Something that
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@ultimat3/query",
3
- "version": "11.1.0",
3
+ "version": "11.3.0",
4
4
  "description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
+ "sideEffects": [
8
+ "./src/errors.ts",
9
+ "./src/registry.ts"
10
+ ],
7
11
  "repository": {
8
12
  "type": "git",
9
13
  "url": "git+https://github.com/developerz-ai/ultimate.git",
@@ -31,10 +35,10 @@
31
35
  "test": "bun test"
32
36
  },
33
37
  "dependencies": {
34
- "@ultimat3/cache": "11.1.0",
35
- "@ultimat3/core": "11.1.0",
36
- "@ultimat3/http": "11.1.0",
37
- "@ultimat3/policy": "11.1.0",
38
- "@ultimat3/schema": "11.1.0"
38
+ "@ultimat3/cache": "11.3.0",
39
+ "@ultimat3/core": "11.3.0",
40
+ "@ultimat3/http": "11.3.0",
41
+ "@ultimat3/policy": "11.3.0",
42
+ "@ultimat3/schema": "11.3.0"
39
43
  }
40
44
  }
package/src/client.ts CHANGED
@@ -8,9 +8,16 @@
8
8
  * output schema — row types come from the `SqlSource` its `sql:` returns — so there is nothing
9
9
  * here to rehydrate a `Date` with, and an instant reaches a caller as the ISO string
10
10
  * `JSON.stringify` wrote. A surface that formats one converts at its own edge.
11
+ *
12
+ * `ClientFlight` is a TYPE here and never a value: dedup, retry, the deadline and the fence are
13
+ * `@ultimat3/core`'s `client-flight.ts`, and a caller that never calls `createClientFlight` does
14
+ * not pay a byte for any of them — an `import type` is erased and the value import would not be.
15
+ * That erasure is the whole reason two islands in this repo write a bare `fetch` instead of
16
+ * importing a typed client.
11
17
  */
12
18
 
13
- import { currentSpanContext, traceparent } from '@ultimat3/core';
19
+ import type { ClientFlight, ClientRetry, WireAnswer } from '@ultimat3/core';
20
+ import { problemOf, traceHeaders } from '@ultimat3/core';
14
21
  import type { InferInput, StandardSchemaV1 } from '@ultimat3/schema';
15
22
  import { QueryRequestFailedError } from './errors';
16
23
  import { derivePath } from './naming';
@@ -23,10 +30,24 @@ export interface QueryClientOptions {
23
30
  readonly baseUrl: string;
24
31
  readonly fetch?: FetchLike;
25
32
  readonly headers?: Readonly<Record<string, string>>;
33
+ /**
34
+ * Opt-in flight control for every read this client makes — `createClientFlight({ principal })`.
35
+ * Absent, a read is one `fetch` and nothing else, which is what every caller written before this
36
+ * option existed already gets.
37
+ */
38
+ readonly flight?: ClientFlight;
26
39
  }
27
40
 
28
41
  export interface QueryCallOptions {
29
42
  readonly signal?: AbortSignal;
43
+ /**
44
+ * Refuse to join an identical read already in flight. The case it exists for: this read exists
45
+ * BECAUSE something just changed, so an answer dispatched before the change is the wrong one.
46
+ * Named for `read.ts`'s `fresh`, which means the same thing one layer down — do not join.
47
+ */
48
+ readonly fresh?: boolean;
49
+ /** Overrides the flight's retry policy for this one read. Ignored with no `flight` installed. */
50
+ readonly retry?: ClientRetry;
30
51
  }
31
52
 
32
53
  /** `feed({ orgId })` with the input schema and the row type both inferred. */
@@ -107,41 +128,50 @@ async function read(
107
128
  ): Promise<unknown> {
108
129
  const search = searchOf(input);
109
130
  const url = `${base}${derivePath(name)}${search === '' ? '' : `?${search}`}`;
131
+ const dispatch = (signal: AbortSignal | undefined): Promise<WireAnswer> =>
132
+ fetchOnce(doFetch, url, options, name, signal ?? callOptions.signal);
133
+
134
+ const flight = options.flight;
135
+ const answer =
136
+ flight === undefined
137
+ ? await dispatch(undefined)
138
+ : await flight.run({
139
+ key: flight.keyFor(url, callOptions),
140
+ // A caller holding its own signal owns this read's lifecycle: it is neither shared nor
141
+ // aborted by a fence bump, and its signal is the only one that reaches the wire.
142
+ abortable: callOptions.signal === undefined,
143
+ ...(callOptions.retry === undefined ? {} : { retry: callOptions.retry }),
144
+ run: dispatch,
145
+ });
146
+ // Parsed per CALLER, never once per dispatch: N joiners of one deduped read may not be handed
147
+ // one mutable array between them, and the shared value is the immutable body TEXT for that
148
+ // reason alone.
149
+ return JSON.parse(answer.text) as unknown;
150
+ }
151
+
152
+ /** One dispatch. Everything above it decides how many times this happens; it decides none. */
153
+ async function fetchOnce(
154
+ doFetch: FetchLike,
155
+ url: string,
156
+ options: QueryClientOptions,
157
+ name: string,
158
+ signal: AbortSignal | undefined,
159
+ ): Promise<WireAnswer> {
110
160
  const init: RequestInit = {
111
- method: 'GET',
112
161
  // `traceHeaders()` before the caller's, so an explicit `traceparent` still wins. Without it a
113
162
  // service-to-service read started a fresh root trace on the other side, and "which of my
114
163
  // downstreams is slow" was unanswerable across every Ultimate-to-Ultimate hop.
115
164
  headers: { accept: 'application/json', ...traceHeaders(), ...options.headers },
116
- ...(callOptions.signal === undefined ? {} : { signal: callOptions.signal }),
165
+ method: 'GET',
166
+ ...(signal === undefined ? {} : { signal }),
117
167
  };
118
168
 
119
169
  const response = await doFetch(url, init);
120
- if (!response.ok)
121
- throw new QueryRequestFailedError(name, response.status, await problemOf(response));
122
- const body: unknown = await response.json();
123
- return body;
124
- }
125
-
126
- /** A `traceparent` is `00-<32 hex>-<16 hex>-<2 hex>`, and nothing else may be sent as one. */
127
- const TRACE_ID = /^[0-9a-f]{32}$/;
128
- const SPAN_ID = /^[0-9a-f]{16}$/;
129
-
130
- /**
131
- * The current trace, as the W3C header — or nothing at all. `currentSpanContext()` answers with
132
- * an empty `spanId` when a request context exists but no span is active, and `00-<trace>--01` is
133
- * a header every collector drops, so an incomplete context sends none. In a browser there is no
134
- * ambient context and this is always empty, which is also what keeps a cross-origin read from
135
- * acquiring a CORS preflight it did not have.
136
- *
137
- * `@ultimat3/action`'s client carries the twin of this function: both are tier 3, so neither may
138
- * import the other — the same reason `naming.ts` is ported rather than shared.
139
- */
140
- function traceHeaders(): Record<string, string> {
141
- const context = currentSpanContext();
142
- if (context === undefined) return {};
143
- if (!TRACE_ID.test(context.traceId) || !SPAN_ID.test(context.spanId)) return {};
144
- return { traceparent: traceparent(context) };
170
+ // Read as TEXT once: a `Response` body is a single-use stream, so the failure path and the row
171
+ // path cannot both have it, and a shared answer has to be something a joiner can re-read.
172
+ const text = await response.text();
173
+ if (!response.ok) throw new QueryRequestFailedError(name, response.status, problemOf(text));
174
+ return { status: response.status, text };
145
175
  }
146
176
 
147
177
  /**
@@ -160,9 +190,3 @@ function searchOf(input: unknown): string {
160
190
  }
161
191
  return params.toString();
162
192
  }
163
-
164
- /** `application/problem+json`, or nothing when a proxy answered instead of the app. */
165
- async function problemOf(response: Response): Promise<Record<string, unknown>> {
166
- const body: unknown = await response.json().catch(() => null);
167
- return isJsonObject(body) ? body : {};
168
- }
package/src/errors.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Every failure @ultimat3/query can produce, one subclass per stable code. */
2
- import { assertNever, registerErrorCodes, UltimateError } from '@ultimat3/core';
2
+ import { assertNever, registerErrorCodes, retryForStatus, UltimateError } from '@ultimat3/core';
3
3
  import type { SurfaceDenial } from '@ultimat3/policy';
4
4
 
5
5
  // No `docs:` on the classes below, with one exception noted at `QueryRequestFailedError`.
@@ -279,6 +279,11 @@ export class QueryRequestFailedError extends UltimateError {
279
279
  const served = text(problem.docs);
280
280
  super({
281
281
  code,
282
+ // The status is what says "send it again", and only where nobody has classified the code:
283
+ // `UltimateError` otherwise fills `retry` from `retryFor(code)`, which fails closed, so a
284
+ // 502 out of a typed read announced itself as `terminal` on the one field the framework
285
+ // promises a client never has to infer.
286
+ retry: retryForStatus(code, status),
282
287
  cause: text(problem.cause) ?? text(problem.detail) ?? `${name} returned HTTP ${status}`,
283
288
  fix:
284
289
  text(problem.fix) ??
package/src/index.ts CHANGED
@@ -6,6 +6,32 @@
6
6
  * authorize or execute on its own. One authz system, structurally.
7
7
  */
8
8
 
9
+ /**
10
+ * Flight control for the typed client, and OPT-IN by construction: `client.ts` names `ClientFlight`
11
+ * as a TYPE only, so a caller that never mentions `createClientFlight` pays nothing for the fence,
12
+ * the dedup map or the retry loop. Every mechanism underneath is `@ultimat3/core`'s — one fence,
13
+ * one flight map, one gate, one backoff curve for the whole framework — and so is the pipeline
14
+ * itself: it shipped as a byte-identical copy here and in `@ultimat3/action`, and
15
+ * two tier-3 packages may not import each other, so the one copy lives at tier 0.
16
+ *
17
+ * Re-exported rather than re-declared, so every name is importable from this package exactly as
18
+ * before. `isSuperseded` is core's too: reading a fenced answer is the point of installing a
19
+ * flight, and it should not cost a second import.
20
+ */
21
+ export type {
22
+ ClientFlight,
23
+ ClientFlightOptions,
24
+ ClientRetry,
25
+ FlightKeyOptions,
26
+ FlightPlan,
27
+ WireAnswer,
28
+ } from '@ultimat3/core';
29
+ export {
30
+ createClientFlight,
31
+ DEFAULT_CLIENT_RETRY,
32
+ isSuperseded,
33
+ isTransientFailure,
34
+ } from '@ultimat3/core';
9
35
  /** Re-exported so a `query` file needs one import, not two. Same object as schema's. */
10
36
  export type { Infer } from '@ultimat3/schema';
11
37
  export { t } from '@ultimat3/schema';
package/src/stable.ts CHANGED
@@ -1,16 +1,18 @@
1
1
  /**
2
- * What a JSON object IS to this package, and how a column is read off a row that declares no index
3
- * signature. Two small predicates the read path needs everywhere.
2
+ * How a column is read off a row that declares no index signature — one predicate the read path
3
+ * needs everywhere, and one re-export beside it.
4
4
  *
5
- * The deterministic-JSON half used to live here and no longer does: `canonicalJson` and
6
- * `fingerprint` are `@ultimat3/core`'s, because `@ultimat3/action` and `@ultimat3/realtime` need
7
- * the identical function and all three are tier 3 — so a copy in any of them was a second answer
8
- * for the other two, and the copies had already diverged. This one rendered every `Date` as `{}`.
5
+ * Both halves used to be declared here and no longer are. `canonicalJson` and `fingerprint` are
6
+ * `@ultimat3/core`'s, because `@ultimat3/action` and `@ultimat3/realtime` need the identical
7
+ * function and all three are tier 3 — so a copy in any of them was a second answer for the other
8
+ * two, and the copies had already diverged. This one rendered every `Date` as `{}`. `isJsonObject`
9
+ * went the same way for the same reason, when `client-wire.ts` moved to tier 0.
9
10
  */
10
11
 
11
- export function isJsonObject(value: unknown): value is Record<string, unknown> {
12
- return typeof value === 'object' && value !== null && !Array.isArray(value);
13
- }
12
+ import { isJsonObject } from '@ultimat3/core';
13
+
14
+ /** Re-exported, not re-declared: `./stable` stays this package's one import path for it. */
15
+ export { isJsonObject };
14
16
 
15
17
  /** Column read that works for interfaces without an index signature. */
16
18
  export function columnOf(row: object, column: string): unknown {