@ultimat3/query 20.2.1 → 22.0.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 +158 -544
- package/README.md +65 -8
- package/package.json +9 -7
- package/src/client.ts +39 -63
- package/src/deprecation.ts +0 -5
- package/src/errors.ts +25 -49
- package/src/facade.ts +1 -0
- package/src/http.ts +40 -39
- package/src/index.ts +5 -7
- package/src/naming.ts +10 -22
- package/src/openapi.ts +26 -14
- package/src/page-controls.ts +2 -4
- package/src/page-keys.ts +9 -0
- package/src/pagination.ts +23 -4
- package/src/query.ts +13 -0
- package/src/record-answer.ts +68 -0
- package/src/shape.ts +15 -4
- package/src/source.ts +15 -0
package/README.md
CHANGED
|
@@ -53,9 +53,44 @@ 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
|
+
Every read goes through `@ultimat3/core`'s **`clientTransport`** — the one browser HTTP function,
|
|
57
|
+
`As of 2026-09-22` (21.0.0). There is no `fetch` call in this package: the `fetch` option is handed
|
|
58
|
+
on as the transport's `fetchImpl`. The transport owns `Accept`, the trace/budget headers, the
|
|
59
|
+
principal fence, and the error decode — a non-`problem+json` failure (a gateway's HTML) is core's
|
|
60
|
+
`X_CLIENT_TRANSPORT_FAILED`, a `problem+json` one is the server's own code, verbatim.
|
|
61
|
+
|
|
62
|
+
### Records — `rows:` puts a read's answer in the page's store
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { entity, text, uuid } from '@ultimat3/entity';
|
|
66
|
+
import { allow } from '@ultimat3/policy';
|
|
67
|
+
import { from, query, t } from '@ultimat3/query';
|
|
68
|
+
|
|
69
|
+
const Post = entity('post', {
|
|
70
|
+
columns: { id: uuid().primaryKey(), orgId: uuid(), title: text({ max: 120 }) },
|
|
71
|
+
});
|
|
72
|
+
type PostRow = { id: string; orgId: string; title: string };
|
|
73
|
+
declare const list: (orgId: string) => Promise<readonly PostRow[]>;
|
|
74
|
+
|
|
75
|
+
export const postList = query({
|
|
76
|
+
input: t.object({ orgId: t.uuid }),
|
|
77
|
+
policy: allow('public'),
|
|
78
|
+
rows: Post.$schema, // the entity's row schema — typed against what `sql:` answers
|
|
79
|
+
sql: ({ orgId }) => from<PostRow>('posts', () => list(orgId)).where({ orgId }).orderBy('id'),
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
With `rows:` naming an **entity's** row schema, the route answers `x-ultimate-records: 1` and the
|
|
84
|
+
record envelope `{ data, records }` on EVERY answer — `records` is `{}` when no row came back, so
|
|
85
|
+
the operation has one body shape and one OpenAPI schema; the typed client adopts
|
|
86
|
+
`records` into `pageClient().store` and returns `data` — the same rows, the same type. Without it,
|
|
87
|
+
or with a schema no `entity()` branded (a `t.object` look-alike, a `.pick()`ed partial), the wire
|
|
88
|
+
is the bare rows, byte-identical to before. `rows:` exists because a read's `sql:` names its table
|
|
89
|
+
as a STRING, so nothing else carries a schema the envelope could be derived from.
|
|
90
|
+
|
|
56
91
|
### Flight control — `createClientFlight`, opt-in
|
|
57
92
|
|
|
58
|
-
A typed read is one
|
|
93
|
+
A typed read is one dispatch and nothing else until a `flight` is installed. With one, N concurrent
|
|
59
94
|
identical reads become ONE dispatch, a fence can retire everything issued before now, and a failure
|
|
60
95
|
worth sending again is sent again on `@ultimat3/core`'s one backoff curve.
|
|
61
96
|
|
|
@@ -95,11 +130,12 @@ flight.bump();
|
|
|
95
130
|
It shipped as a byte-identical copy in each; the copies are gone and every name is importable from
|
|
96
131
|
this package exactly as before.
|
|
97
132
|
|
|
98
|
-
Bundle cost is why `ClientFlight` is a TYPE inside `client.ts` and never a value
|
|
99
|
-
`queryClient` alone
|
|
100
|
-
`
|
|
101
|
-
|
|
102
|
-
|
|
133
|
+
Bundle cost is why `ClientFlight` is a TYPE inside `client.ts` and never a value. Importing
|
|
134
|
+
`queryClient` alone through this package's barrel, `bun build --target=browser --minify`,
|
|
135
|
+
`As of 2026-09-22`: **19,026 B** before the move to `clientTransport` (20.2.1), **24,114 B** after.
|
|
136
|
+
The growth is core's transport and its graph (`client-transport.ts`, `client-problem.ts`,
|
|
137
|
+
`record-envelope.ts`, `client-scope.ts`, `record-sink.ts`); `client.ts` itself got smaller. The
|
|
138
|
+
12,755 B this page quoted before was no longer true at 20.2.1 — re-measure, never quote.
|
|
103
139
|
|
|
104
140
|
The declaration is lifted too: `.input`, `.policy`, `.cache`, `.mcp`, `.isLive`. `sql` is not
|
|
105
141
|
among them — it lives in a private store inside `read.ts`, so `sourceFor` is the only thing
|
|
@@ -479,12 +515,33 @@ router feature here.
|
|
|
479
515
|
| `X_INPUT_INVALID` | input failed the Standard Schema | `x queries describe <name> --json` |
|
|
480
516
|
| `X_QUERY_UNREGISTERED` | used before `registerQueries()` ran | register at boot |
|
|
481
517
|
| `X_QUERY_FOREIGN` | a look-alike was projected as a query | declare it with `query({ … })` |
|
|
482
|
-
| `
|
|
518
|
+
| `X_CLIENT_TRANSPORT_FAILED` | `.client()` got no response, or a non-`problem+json` failure (core's code, since 21.0.0; was `X_RPC_FAILED`) | check the gateway in front of the app: `x doctor --json` |
|
|
483
519
|
|
|
484
520
|
Denials re-throw the policy layer's own codes and keep the surface denial on
|
|
485
521
|
`QueryDeniedError.denial`, so a live socket closes with 4403 instead of guessing.
|
|
486
522
|
|
|
523
|
+
### Error classes
|
|
524
|
+
|
|
525
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
526
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
527
|
+
|
|
528
|
+
| Class | Code | Declared in |
|
|
529
|
+
|---|---|---|
|
|
530
|
+
| `CursorValueUnsupportedError` | `X_CURSOR_VALUE_UNSUPPORTED` | `src/errors.ts` |
|
|
531
|
+
| `MatcherUnsupportedError` | `X_MATCHER_UNSUPPORTED` | `src/errors.ts` |
|
|
532
|
+
| `QueryDeniedError` | the policy denial's own code (`X_FORBIDDEN`, `X_UNAUTHENTICATED`, …), kept on `.denial` | `src/errors.ts` |
|
|
533
|
+
| `QueryDeprecationInvalidError` | `X_QUERY_DEPRECATION_INVALID` | `src/errors.ts` |
|
|
534
|
+
| `QueryDuplicateError` | `X_QUERY_DUPLICATE` | `src/errors.ts` |
|
|
535
|
+
| `QueryForeignError` | `X_QUERY_FOREIGN` | `src/errors.ts` |
|
|
536
|
+
| `QueryInputInvalidError` | `X_INPUT_INVALID` | `src/errors.ts` |
|
|
537
|
+
| `QueryInputUnencodableError` | `X_QUERY_INPUT_UNENCODABLE` | `src/errors.ts` |
|
|
538
|
+
| `QueryNotPageableError` | `X_QUERY_NOT_PAGEABLE` | `src/errors.ts` |
|
|
539
|
+
| `QueryPolicyMissingError` | `X_QUERY_POLICY_MISSING` | `src/errors.ts` |
|
|
540
|
+
| `QuerySubscribesDriftError` | `X_QUERY_SUBSCRIBES_DRIFT` | `src/errors.ts` |
|
|
541
|
+
| `QuerySubscribesInvalidError` | `X_QUERY_SUBSCRIBES_INVALID` | `src/errors.ts` |
|
|
542
|
+
| `QueryUnregisteredError` | `X_QUERY_UNREGISTERED` | `src/errors.ts` |
|
|
543
|
+
|
|
487
544
|
## Boundaries
|
|
488
545
|
|
|
489
|
-
Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `policy`. Never imports `action`,
|
|
546
|
+
Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `entity`, `http`, `policy`. Never imports `action`,
|
|
490
547
|
`jobs` or `realtime` (same tier) — `realtime` consumes `LiveQuery` from here.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/query",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.0.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",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"provenance": true
|
|
19
19
|
},
|
|
20
20
|
"exports": {
|
|
21
|
-
".": "./src/index.ts"
|
|
21
|
+
".": "./src/index.ts",
|
|
22
|
+
"./client": "./src/client.ts"
|
|
22
23
|
},
|
|
23
24
|
"files": [
|
|
24
25
|
"src",
|
|
@@ -35,10 +36,11 @@
|
|
|
35
36
|
"test": "bun test"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
|
-
"@ultimat3/cache": "
|
|
39
|
-
"@ultimat3/core": "
|
|
40
|
-
"@ultimat3/
|
|
41
|
-
"@ultimat3/
|
|
42
|
-
"@ultimat3/
|
|
39
|
+
"@ultimat3/cache": "22.0.0",
|
|
40
|
+
"@ultimat3/core": "22.0.0",
|
|
41
|
+
"@ultimat3/entity": "22.0.0",
|
|
42
|
+
"@ultimat3/http": "22.0.0",
|
|
43
|
+
"@ultimat3/policy": "22.0.0",
|
|
44
|
+
"@ultimat3/schema": "22.0.0"
|
|
43
45
|
}
|
|
44
46
|
}
|
package/src/client.ts
CHANGED
|
@@ -4,30 +4,29 @@
|
|
|
4
4
|
* in a Solid component rather than a 404 at runtime. Browser-safe on purpose: no
|
|
5
5
|
* server imports, nothing here touches a context, a policy or a database.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Every read goes through `@ultimat3/core`'s `clientTransport`, the one browser HTTP function —
|
|
8
|
+
* there is no `fetch` call here, only the injected `fetch` option handed on as `fetchImpl`. Rows
|
|
9
|
+
* are handed back as parsed: an instant reaches a caller as the ISO string `JSON.stringify` wrote,
|
|
10
|
+
* and a surface that formats one converts at its own edge. A record envelope is unwrapped by the
|
|
11
|
+
* transport, its rows adopted into the page's store, so the return type is the rows either way.
|
|
11
12
|
*
|
|
12
13
|
* `ClientFlight` is a TYPE here and never a value: dedup, retry, the deadline and the fence are
|
|
13
14
|
* `@ultimat3/core`'s `client-flight.ts`, and a caller that never calls `createClientFlight` does
|
|
14
15
|
* 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.
|
|
17
16
|
*/
|
|
18
17
|
|
|
19
|
-
import type { ClientFlight, ClientRetry
|
|
20
|
-
import {
|
|
18
|
+
import type { ClientFlight, ClientRetry } from '@ultimat3/core';
|
|
19
|
+
import type { FetchLike, RecordEnvelope } from '@ultimat3/core/page';
|
|
20
|
+
import { clientTransport, isJsonObject } from '@ultimat3/core/page';
|
|
21
21
|
import type { InferInput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
22
|
-
import { QueryRequestFailedError } from './errors';
|
|
23
22
|
import { derivePath } from './naming';
|
|
24
23
|
import type { PageControls } from './page-controls';
|
|
25
|
-
import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-
|
|
24
|
+
import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-keys';
|
|
26
25
|
import type { Page } from './pagination';
|
|
27
26
|
import type { Query } from './query';
|
|
28
|
-
import { isJsonObject } from './stable';
|
|
29
27
|
|
|
30
|
-
|
|
28
|
+
/** Core's, re-exported under the name this package always exported it by — one declaration. */
|
|
29
|
+
export type { FetchLike } from '@ultimat3/core/page';
|
|
31
30
|
|
|
32
31
|
export interface QueryClientOptions {
|
|
33
32
|
readonly baseUrl: string;
|
|
@@ -35,8 +34,8 @@ export interface QueryClientOptions {
|
|
|
35
34
|
readonly headers?: Readonly<Record<string, string>>;
|
|
36
35
|
/**
|
|
37
36
|
* Opt-in flight control for every read this client makes — `createClientFlight({ principal })`.
|
|
38
|
-
* Absent, a read is one
|
|
39
|
-
* option existed already gets.
|
|
37
|
+
* Absent, a read is one dispatch and nothing else, which is what every caller written before
|
|
38
|
+
* this option existed already gets.
|
|
40
39
|
*/
|
|
41
40
|
readonly flight?: ClientFlight;
|
|
42
41
|
}
|
|
@@ -51,6 +50,11 @@ export interface QueryCallOptions {
|
|
|
51
50
|
readonly fresh?: boolean;
|
|
52
51
|
/** Overrides the flight's retry policy for this one read. Ignored with no `flight` installed. */
|
|
53
52
|
readonly retry?: ClientRetry;
|
|
53
|
+
/**
|
|
54
|
+
* The decoded record envelope, after its rows were adopted — only for a read declaring an
|
|
55
|
+
* entity's `rows:`. The store is keyed and unordered; this is where a caller reads the ORDER.
|
|
56
|
+
*/
|
|
57
|
+
readonly onEnvelope?: (envelope: RecordEnvelope) => void;
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
/**
|
|
@@ -126,21 +130,25 @@ export function queryClientMethodFor<TInput extends StandardSchemaV1, TRow exten
|
|
|
126
130
|
name: string,
|
|
127
131
|
options: QueryClientOptions,
|
|
128
132
|
): QueryClientMethod<TInput, TRow> {
|
|
129
|
-
const doFetch: FetchLike = options.fetch ?? ((input, init) => fetch(input, init));
|
|
130
133
|
const base = options.baseUrl.replace(/\/+$/, '');
|
|
131
134
|
// Erased at the wire seam; the row type is this query's by construction.
|
|
132
135
|
const rows = (input: InferInput<TInput>, callOptions: QueryCallOptions = {}) =>
|
|
133
|
-
read(
|
|
136
|
+
read(base, options, name, input, callOptions) as Promise<readonly TRow[]>;
|
|
134
137
|
const page = (
|
|
135
138
|
input: InferInput<TInput>,
|
|
136
139
|
args: PageControls,
|
|
137
140
|
callOptions: QueryCallOptions = {},
|
|
138
|
-
) => read(
|
|
141
|
+
) => read(base, options, name, input, callOptions, args) as Promise<Page<TRow>>;
|
|
139
142
|
return Object.assign(rows, { page });
|
|
140
143
|
}
|
|
141
144
|
|
|
142
|
-
|
|
143
|
-
|
|
145
|
+
/**
|
|
146
|
+
* One read, through `@ultimat3/core`'s `clientTransport` — the one browser HTTP function. It owns
|
|
147
|
+
* the wire: `Accept`, the error decode, the principal fence, and the record envelope — whose rows it adopts
|
|
148
|
+
* into the page's store before handing back `data`, so the return type here never changed. The
|
|
149
|
+
* `fetch` option is the transport's injected `fetchImpl`, never a call made here.
|
|
150
|
+
*/
|
|
151
|
+
function read(
|
|
144
152
|
base: string,
|
|
145
153
|
options: QueryClientOptions,
|
|
146
154
|
name: string,
|
|
@@ -149,51 +157,19 @@ async function read(
|
|
|
149
157
|
page?: PageControls,
|
|
150
158
|
): Promise<unknown> {
|
|
151
159
|
const search = searchOf(input, page);
|
|
152
|
-
|
|
153
|
-
const dispatch = (signal: AbortSignal | undefined): Promise<WireAnswer> =>
|
|
154
|
-
fetchOnce(doFetch, url, options, name, signal ?? callOptions.signal);
|
|
155
|
-
|
|
156
|
-
const flight = options.flight;
|
|
157
|
-
const answer =
|
|
158
|
-
flight === undefined
|
|
159
|
-
? await dispatch(undefined)
|
|
160
|
-
: await flight.run({
|
|
161
|
-
key: flight.keyFor(url, callOptions),
|
|
162
|
-
// A caller holding its own signal owns this read's lifecycle: it is neither shared nor
|
|
163
|
-
// aborted by a fence bump, and its signal is the only one that reaches the wire.
|
|
164
|
-
abortable: callOptions.signal === undefined,
|
|
165
|
-
...(callOptions.retry === undefined ? {} : { retry: callOptions.retry }),
|
|
166
|
-
run: dispatch,
|
|
167
|
-
});
|
|
168
|
-
// Parsed per CALLER, never once per dispatch: N joiners of one deduped read may not be handed
|
|
169
|
-
// one mutable array between them, and the shared value is the immutable body TEXT for that
|
|
170
|
-
// reason alone.
|
|
171
|
-
return JSON.parse(answer.text) as unknown;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** One dispatch. Everything above it decides how many times this happens; it decides none. */
|
|
175
|
-
async function fetchOnce(
|
|
176
|
-
doFetch: FetchLike,
|
|
177
|
-
url: string,
|
|
178
|
-
options: QueryClientOptions,
|
|
179
|
-
name: string,
|
|
180
|
-
signal: AbortSignal | undefined,
|
|
181
|
-
): Promise<WireAnswer> {
|
|
182
|
-
const init: RequestInit = {
|
|
183
|
-
// `traceHeaders()` before the caller's, so an explicit `traceparent` still wins. Without it a
|
|
184
|
-
// service-to-service read started a fresh root trace on the other side, and "which of my
|
|
185
|
-
// downstreams is slow" was unanswerable across every Ultimate-to-Ultimate hop.
|
|
186
|
-
headers: { accept: 'application/json', ...traceHeaders(), ...options.headers },
|
|
160
|
+
return clientTransport({
|
|
187
161
|
method: 'GET',
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
162
|
+
url: `${base}${derivePath(name)}${search === '' ? '' : `?${search}`}`,
|
|
163
|
+
// The trace and budget are the transport's, from its server-side outbound slot, placed
|
|
164
|
+
// before these so an explicit `traceparent` still wins; a browser bundles none of it.
|
|
165
|
+
...(options.headers === undefined ? {} : { headers: options.headers }),
|
|
166
|
+
...(callOptions.signal === undefined ? {} : { signal: callOptions.signal }),
|
|
167
|
+
...(options.flight === undefined ? {} : { flight: options.flight }),
|
|
168
|
+
...(callOptions.fresh === undefined ? {} : { fresh: callOptions.fresh }),
|
|
169
|
+
...(callOptions.retry === undefined ? {} : { retry: callOptions.retry }),
|
|
170
|
+
...(callOptions.onEnvelope === undefined ? {} : { onEnvelope: callOptions.onEnvelope }),
|
|
171
|
+
...(options.fetch === undefined ? {} : { fetchImpl: options.fetch }),
|
|
172
|
+
});
|
|
197
173
|
}
|
|
198
174
|
|
|
199
175
|
/**
|
package/src/deprecation.ts
CHANGED
|
@@ -74,8 +74,3 @@ export function renderDeprecation(
|
|
|
74
74
|
};
|
|
75
75
|
return { ok: true, headers, meta };
|
|
76
76
|
}
|
|
77
|
-
|
|
78
|
-
/** Set on a response that already exists, so a problem document carries them too. */
|
|
79
|
-
export function applyHeaders(response: Response, headers: Readonly<Record<string, string>>): void {
|
|
80
|
-
for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);
|
|
81
|
-
}
|
package/src/errors.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/** Every failure @ultimat3/query can produce, one subclass per stable code. */
|
|
2
|
-
import { assertNever, registerErrorCodes,
|
|
2
|
+
import { assertNever, registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
3
3
|
import type { SurfaceDenial } from '@ultimat3/policy';
|
|
4
4
|
|
|
5
|
-
// No `docs:` on the classes below
|
|
5
|
+
// No `docs:` on the classes below.
|
|
6
6
|
// `UltimateError` fills it from `describeErrorCode(code).docs`, which is `@ultimat3/core`'s
|
|
7
7
|
// `ERROR_DOCS_URL` — one page for every code, never one per code, because `wiki/` is the
|
|
8
8
|
// framework's only public documentation surface and a code lives there in a TABLE ROW, which has
|
|
@@ -17,6 +17,7 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
|
17
17
|
X_CURSOR_VALUE_UNSUPPORTED: 'a sort value cannot be carried in a cursor',
|
|
18
18
|
X_MATCHER_UNSUPPORTED: 'live query shape cannot be patched incrementally',
|
|
19
19
|
X_QUERY_CACHE_TTL_INVALID: 'a query declares a cache ttlMs no tier can hold',
|
|
20
|
+
X_QUERY_COLUMN_UNSELECTED: 'a read filters or sorts on a column its rows do not carry',
|
|
20
21
|
X_QUERY_DEPRECATION_INVALID: 'a query declares a deprecation whose dates cannot be rendered',
|
|
21
22
|
X_QUERY_DUPLICATE: 'two queries are registered under one name',
|
|
22
23
|
X_QUERY_FOREIGN: 'a value that is not a query was projected as one',
|
|
@@ -29,11 +30,12 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
|
-
* Codes `@ultimat3/action` owns that this package only throws.
|
|
33
|
-
* enforcing an input schema
|
|
34
|
-
*
|
|
33
|
+
* Codes `@ultimat3/action` owns that this package only throws. It describes an action's job —
|
|
34
|
+
* enforcing an input schema — so action declares the title and query never re-declares it: two
|
|
35
|
+
* copies of a title are two titles, one of which is stale. `X_RPC_FAILED` left this list in
|
|
36
|
+
* 21.0.0: the typed read client's wire failures are `@ultimat3/core`'s `clientTransport`'s now.
|
|
35
37
|
*/
|
|
36
|
-
export const QUERY_BORROWED_ERROR_CODES = ['X_INPUT_INVALID'
|
|
38
|
+
export const QUERY_BORROWED_ERROR_CODES = ['X_INPUT_INVALID'] as const;
|
|
37
39
|
|
|
38
40
|
// One unconditional call: a presence guard would turn "another package claims one of these codes"
|
|
39
41
|
// from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
|
|
@@ -239,6 +241,23 @@ export class CursorValueUnsupportedError extends UltimateError {
|
|
|
239
241
|
}
|
|
240
242
|
}
|
|
241
243
|
|
|
244
|
+
/**
|
|
245
|
+
* A `where()`, `compare()` or `orderBy()` on a column the provider's rows do not carry. Absent is
|
|
246
|
+
* not "not equal": every row failed the filter and the read answered `[]` — a loader that selected
|
|
247
|
+
* `slug` and `updatedAt` and filtered on `status` built a blog with no article in it, under a
|
|
248
|
+
* green gate. Refused by name, because the repair is one edit to the loader's `select`.
|
|
249
|
+
*/
|
|
250
|
+
export class QueryColumnUnselectedError extends UltimateError {
|
|
251
|
+
constructor(entity: string, column: string) {
|
|
252
|
+
super({
|
|
253
|
+
code: 'X_QUERY_COLUMN_UNSELECTED',
|
|
254
|
+
cause: `the rows of "${entity}" carry no "${column}" column, so filtering or sorting on it matches nothing`,
|
|
255
|
+
fix: 'select it in the loader: db.posts.select({ slug: true, status: true, publishedAt: true }) — or drop the filter the loader already applied',
|
|
256
|
+
meta: { entity, column },
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
242
261
|
/** The honest fallback: the matcher refuses to guess rather than patch wrongly. */
|
|
243
262
|
export class MatcherUnsupportedError extends UltimateError {
|
|
244
263
|
constructor(name: string, feature: string) {
|
|
@@ -305,46 +324,3 @@ export class QueryInputInvalidError extends UltimateError {
|
|
|
305
324
|
});
|
|
306
325
|
}
|
|
307
326
|
}
|
|
308
|
-
|
|
309
|
-
/** The `problem+json` fields a failing read can send back. All optional: a proxy sends none. */
|
|
310
|
-
export interface QueryProblem {
|
|
311
|
-
readonly code?: unknown;
|
|
312
|
-
readonly cause?: unknown;
|
|
313
|
-
readonly detail?: unknown;
|
|
314
|
-
readonly fix?: unknown;
|
|
315
|
-
readonly docs?: unknown;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* The typed client's failure. A `problem+json` body is re-thrown verbatim — the
|
|
320
|
-
* server already said what broke and how to fix it, and inventing a second story
|
|
321
|
-
* here would bury it. Anything else answered instead of the app, so it is
|
|
322
|
-
* `X_RPC_FAILED` and the fix line points at the gateway.
|
|
323
|
-
*/
|
|
324
|
-
export class QueryRequestFailedError extends UltimateError {
|
|
325
|
-
constructor(name: string, status: number, problem: QueryProblem = {}) {
|
|
326
|
-
const code = text(problem.code) ?? 'X_RPC_FAILED';
|
|
327
|
-
const served = text(problem.docs);
|
|
328
|
-
super({
|
|
329
|
-
code,
|
|
330
|
-
// The status is what says "send it again", and only where nobody has classified the code:
|
|
331
|
-
// `UltimateError` otherwise fills `retry` from `retryFor(code)`, which fails closed, so a
|
|
332
|
-
// 502 out of a typed read announced itself as `terminal` on the one field the framework
|
|
333
|
-
// promises a client never has to infer.
|
|
334
|
-
retry: retryForStatus(code, status),
|
|
335
|
-
cause: text(problem.cause) ?? text(problem.detail) ?? `${name} returned HTTP ${status}`,
|
|
336
|
-
fix:
|
|
337
|
-
text(problem.fix) ??
|
|
338
|
-
`check the gateway in front of the app, then: x queries describe ${name} --json`,
|
|
339
|
-
// The one place a `docs` is passed: the SERVER's, re-thrown verbatim beside the cause and
|
|
340
|
-
// fix it came with. An app that documents its own codes somewhere else is entitled to say
|
|
341
|
-
// so, and overwriting it with this framework's page would bury the answer. Absent, the
|
|
342
|
-
// constructor resolves `ERROR_DOCS_URL` like every other error here.
|
|
343
|
-
...(served === undefined ? {} : { docs: served }),
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function text(value: unknown): string | undefined {
|
|
349
|
-
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
350
|
-
}
|
package/src/facade.ts
CHANGED
|
@@ -26,6 +26,7 @@ export function facadeFor<TInput extends StandardSchemaV1, TRow extends object>(
|
|
|
26
26
|
input: def.input,
|
|
27
27
|
policy: def.policy,
|
|
28
28
|
...(def.subscribes === undefined ? {} : { subscribes: def.subscribes }),
|
|
29
|
+
...(def.rows === undefined ? {} : { rows: def.rows }),
|
|
29
30
|
...(def.cache === undefined ? {} : { cache: def.cache }),
|
|
30
31
|
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
31
32
|
...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
|
package/src/http.ts
CHANGED
|
@@ -6,18 +6,18 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { tagKeys } from '@ultimat3/cache';
|
|
9
|
-
import { isUltimateError } from '@ultimat3/core';
|
|
10
9
|
import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
|
|
11
|
-
import {
|
|
10
|
+
import { toBucket } from '@ultimat3/http';
|
|
12
11
|
import { coerceQuery } from '@ultimat3/schema';
|
|
13
12
|
import type { Deprecation } from './deprecation';
|
|
14
|
-
import {
|
|
13
|
+
import { recordDeprecatedCall, renderDeprecation } from './deprecation';
|
|
15
14
|
import { QueryDeprecationInvalidError } from './errors';
|
|
16
15
|
import { derivePath } from './naming';
|
|
17
16
|
import { pageControlsOf } from './page-controls';
|
|
18
17
|
import { admitsAnonymous, policyCapability } from './policy-gate';
|
|
19
18
|
import type { AnyQuery } from './query';
|
|
20
19
|
import { queryName, runQuery } from './read';
|
|
20
|
+
import { recordAnswerFor } from './record-answer';
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* `liveFeed` -> `GET /_x/query/live-feed`. Named for the primitive rather than spelled
|
|
@@ -30,45 +30,46 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
30
30
|
// Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
|
|
31
31
|
// not a surprise on the first read.
|
|
32
32
|
const sunsetting = deprecationHeadersFor(name, target.deprecated);
|
|
33
|
+
const answer = recordAnswerFor(target.rows);
|
|
33
34
|
|
|
34
35
|
const handler = async (request: UltimateRequest): Promise<Response> => {
|
|
35
|
-
if (sunsetting !== undefined)
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// is the one that decides, exactly as it does for a direct server call.
|
|
44
|
-
//
|
|
45
|
-
// The two page controls come OUT first (`page-controls.ts`): they are the route's, not the
|
|
46
|
-
// read's, and a schema that refused unknown keys would otherwise refuse every paged call.
|
|
47
|
-
const { input: values, page } = pageControlsOf(name, request.queryRaw());
|
|
48
|
-
const input = coerceQuery(target.input, values);
|
|
49
|
-
// With a page control the answer is the `Page` envelope `query.page()` answers a server
|
|
50
|
-
// caller with — `{ rows, endCursor, hasNextPage }`, the same names, so a cursor read off the
|
|
51
|
-
// wire and one read off a direct call are the same string in the same field. Without one the
|
|
52
|
-
// answer is the bare array it has always been: every client written before the controls
|
|
53
|
-
// existed keeps reading rows, and `hasNextPage` never rides on a row where a page marker
|
|
54
|
-
// has no business being.
|
|
55
|
-
const response = json(
|
|
56
|
-
page === undefined
|
|
57
|
-
? await runQuery(target, input, { surface: 'http' })
|
|
58
|
-
: await target.page(input, { ...page, surface: 'http' }),
|
|
59
|
-
);
|
|
60
|
-
// On the failure path too, below: a client polling a deprecated read that is currently
|
|
61
|
-
// 403ing still has to learn the read is going away.
|
|
62
|
-
if (sunsetting !== undefined) applyHeaders(response, sunsetting);
|
|
63
|
-
return response;
|
|
64
|
-
} catch (error) {
|
|
65
|
-
// Framework errors carry their own code, status and fix line; anything else is
|
|
66
|
-
// a bug and belongs to the server's error boundary, not to this route.
|
|
67
|
-
if (!isUltimateError(error)) throw error;
|
|
68
|
-
const response = problem(error);
|
|
69
|
-
if (sunsetting !== undefined) applyHeaders(response, sunsetting);
|
|
70
|
-
return response;
|
|
36
|
+
if (sunsetting !== undefined) {
|
|
37
|
+
recordDeprecatedCall('query', name);
|
|
38
|
+
// On the CONTEXT, before anything can fail: the pipeline's `response` stage merges
|
|
39
|
+
// `ctx.headers` into whatever answers — this handler's rows and the `error-map` stage's
|
|
40
|
+
// problem or error page alike. A client polling a deprecated read that is currently 403ing
|
|
41
|
+
// still has to learn the read is going away.
|
|
42
|
+
for (const [header, value] of Object.entries(sunsetting))
|
|
43
|
+
request.ctx.headers.set(header, value);
|
|
71
44
|
}
|
|
45
|
+
// No `catch`: an error — framework or not — takes the path every route's error takes, the
|
|
46
|
+
// repair `@ultimat3/action`'s `toRoute` got. This answered `problem(error)` itself, so the
|
|
47
|
+
// `error-map` stage never saw it: no `onError`/`reportError` on a 5xx, no HTML error page or
|
|
48
|
+
// sign-in redirect for a browser, no `requestId`, no `Retry-After`.
|
|
49
|
+
//
|
|
50
|
+
// Coerced, then validated — two different jobs, and only the first one belongs to a
|
|
51
|
+
// wire. A search string is characters, so `t.number` and `t.boolean` need the HTTP
|
|
52
|
+
// boundary's decode (`coerceQuery` never invents data: what it cannot convert it
|
|
53
|
+
// hands on untouched). VALIDATING here — `request.query(schema)` — would be the
|
|
54
|
+
// second parser: the same read would answer `X_BODY_INVALID` where every other
|
|
55
|
+
// surface answers `X_INPUT_INVALID` with the line that prints its schema. `runQuery`
|
|
56
|
+
// is the one that decides, exactly as it does for a direct server call.
|
|
57
|
+
//
|
|
58
|
+
// The two page controls come OUT first (`page-controls.ts`): they are the route's, not the
|
|
59
|
+
// read's, and a schema that refused unknown keys would otherwise refuse every paged call.
|
|
60
|
+
const { input: values, page } = pageControlsOf(name, request.queryRaw());
|
|
61
|
+
const input = coerceQuery(target.input, values);
|
|
62
|
+
// With a page control the answer is the `Page` envelope `query.page()` answers a server
|
|
63
|
+
// caller with — `{ rows, endCursor, hasNextPage }`, the same names, so a cursor read off the
|
|
64
|
+
// wire and one read off a direct call are the same string in the same field. Without one the
|
|
65
|
+
// answer is the bare array it has always been: every client written before the controls
|
|
66
|
+
// existed keeps reading rows, and `hasNextPage` never rides on a row where a page marker
|
|
67
|
+
// has no business being.
|
|
68
|
+
return answer(
|
|
69
|
+
page === undefined
|
|
70
|
+
? await runQuery(target, input, { surface: 'http' })
|
|
71
|
+
: await target.page(input, { ...page, surface: 'http' }),
|
|
72
|
+
);
|
|
72
73
|
};
|
|
73
74
|
|
|
74
75
|
const meta: RouteMeta = {
|
package/src/index.ts
CHANGED
|
@@ -66,11 +66,11 @@ export { queryClient, queryClientMethodFor } from './client';
|
|
|
66
66
|
/** The compat window a retirement gets. Versioning is two deployments, not a router feature. */
|
|
67
67
|
export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
|
|
68
68
|
export { recordDeprecatedCall, renderDeprecation } from './deprecation';
|
|
69
|
-
export type { QueryProblem } from './errors';
|
|
70
69
|
export {
|
|
71
70
|
CursorInvalidError,
|
|
72
71
|
CursorValueUnsupportedError,
|
|
73
72
|
MatcherUnsupportedError,
|
|
73
|
+
QueryColumnUnselectedError,
|
|
74
74
|
QueryDeniedError,
|
|
75
75
|
QueryDeprecationInvalidError,
|
|
76
76
|
QueryDuplicateError,
|
|
@@ -79,7 +79,6 @@ export {
|
|
|
79
79
|
QueryInputUnencodableError,
|
|
80
80
|
QueryNotPageableError,
|
|
81
81
|
QueryPolicyMissingError,
|
|
82
|
-
QueryRequestFailedError,
|
|
83
82
|
QuerySubscribesDriftError,
|
|
84
83
|
QuerySubscribesInvalidError,
|
|
85
84
|
QueryUnregisteredError,
|
|
@@ -87,7 +86,7 @@ export {
|
|
|
87
86
|
/** The HTTP projection: `GET /_x/query/<kebab>`, the URL `client()` derives. */
|
|
88
87
|
export { toQueryRoute } from './http';
|
|
89
88
|
export type { LiveCursor, LiveQuery, ResumeMode, ResumePlan, ToLiveOptions } from './live';
|
|
90
|
-
export {
|
|
89
|
+
export { planResume, seekOf, toLiveQuery } from './live';
|
|
91
90
|
export type { ChangeEvent, ChangeOp, Patch } from './matcher';
|
|
92
91
|
export { assertMatchable, match, positionFor } from './matcher';
|
|
93
92
|
export type { QueryToolDescriptor, QueryToolReadOptions } from './mcp-tool';
|
|
@@ -96,7 +95,7 @@ export { isExposed, toQueryTool, toQueryTools } from './mcp-tool';
|
|
|
96
95
|
* Path derivation only. There is no `toToolName`: an MCP tool is served under the export name
|
|
97
96
|
* verbatim, and an exported derivation would be a second way to spell one tool.
|
|
98
97
|
*/
|
|
99
|
-
export { derivePath
|
|
98
|
+
export { derivePath } from './naming';
|
|
100
99
|
/**
|
|
101
100
|
* The read half of `openapi.json`. `@ultimat3/cli` merges these paths into `@ultimat3/action`'s
|
|
102
101
|
* `buildOpenApi` document — the two packages are one tier and cannot compose each other.
|
|
@@ -107,7 +106,7 @@ export { queryOpenApiPaths, toQueryOpenApiOperation } from './openapi';
|
|
|
107
106
|
* shape the typed client's `.page()` takes. `MAX_PAGE_SIZE` is the bound both ends check.
|
|
108
107
|
*/
|
|
109
108
|
export type { PageControls } from './page-controls';
|
|
110
|
-
export { MAX_PAGE_SIZE
|
|
109
|
+
export { MAX_PAGE_SIZE } from './page-controls';
|
|
111
110
|
/**
|
|
112
111
|
* The shapes `query.page(input, { first, after })` takes and answers with. `paginate` itself is
|
|
113
112
|
* deliberately unexported: a page is the read's own answer, and a second, importable way to ask
|
|
@@ -141,7 +140,7 @@ export type {
|
|
|
141
140
|
QueryRateLimit,
|
|
142
141
|
SourceOptions,
|
|
143
142
|
} from './query';
|
|
144
|
-
export { describeQuery, isQuery,
|
|
143
|
+
export { describeQuery, isQuery, query, queryHash } from './query';
|
|
145
144
|
/** The one read path. `defOf` stays unexported — that is the enforcement. */
|
|
146
145
|
export { queryName, runQuery, sourceFor } from './read';
|
|
147
146
|
|
|
@@ -166,7 +165,6 @@ export {
|
|
|
166
165
|
compareValues,
|
|
167
166
|
isNull,
|
|
168
167
|
matchesFilter,
|
|
169
|
-
matchesFilters,
|
|
170
168
|
seekKeyOf,
|
|
171
169
|
totalOrder,
|
|
172
170
|
} from './shape';
|
package/src/naming.ts
CHANGED
|
@@ -1,23 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* derived — it is the export name verbatim, so there is one name to call.
|
|
2
|
+
* The naming rule for reads, as this package has always exported it: a query's export name
|
|
3
|
+
* derives its HTTP path. The rule itself is `@ultimat3/core`'s `client-paths.ts` — one tier-0 file
|
|
4
|
+
* that `action`, `query` and `realtime` all read, so the path cannot be derived two ways. The MCP
|
|
5
|
+
* tool name is NOT derived — it is the export name verbatim, so there is one name to call.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
const QUERY_PREFIX = '/_x/query';
|
|
8
|
+
import { queryPath, splitWords } from '@ultimat3/core/page';
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
export function splitWords(name: string): string[] {
|
|
14
|
-
return name
|
|
15
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
16
|
-
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
17
|
-
.split(/[\s_-]+/)
|
|
18
|
-
.filter((word) => word.length > 0)
|
|
19
|
-
.map((word) => word.toLowerCase());
|
|
20
|
-
}
|
|
10
|
+
export { splitWords };
|
|
21
11
|
|
|
22
12
|
/** `liveFeed` -> `live-feed`. */
|
|
23
13
|
export function toKebabCase(name: string): string {
|
|
@@ -25,10 +15,8 @@ export function toKebabCase(name: string): string {
|
|
|
25
15
|
}
|
|
26
16
|
|
|
27
17
|
/**
|
|
28
|
-
* `liveFeed` -> `/_x/query/live-feed`, read with `GET …?orgId=…`. A read is a GET
|
|
29
|
-
*
|
|
30
|
-
*
|
|
18
|
+
* `liveFeed` -> `/_x/query/live-feed`, read with `GET …?orgId=…`. A read is a GET under its own
|
|
19
|
+
* prefix so a CDN, a browser cache and a log line can all tell it apart from an action's
|
|
20
|
+
* `POST /api/...` without parsing a body.
|
|
31
21
|
*/
|
|
32
|
-
export
|
|
33
|
-
return `${QUERY_PREFIX}/${toKebabCase(name)}`;
|
|
34
|
-
}
|
|
22
|
+
export const derivePath: (name: string) => string = queryPath;
|