@ultimat3/query 20.2.1 → 21.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 +43 -10
- package/README.md +44 -8
- package/package.json +9 -7
- package/src/client.ts +39 -63
- package/src/errors.ts +7 -49
- package/src/facade.ts +1 -0
- package/src/http.ts +4 -2
- package/src/index.ts +0 -2
- 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/query.ts +13 -0
- package/src/record-answer.ts +68 -0
package/CLAUDE.md
CHANGED
|
@@ -17,9 +17,10 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
17
17
|
| `facade.ts` | the fluent surface — binds each projection to the query, re-implements none |
|
|
18
18
|
| `http.ts` | route projection (`GET /_x/query/<kebab>`, `enforcedBy: 'handler'`) |
|
|
19
19
|
| `mcp-tool.ts` | MCP read descriptor, same `sourceFor` |
|
|
20
|
-
| `client.ts` | typed read client (browser-safe: no server imports) |
|
|
20
|
+
| `client.ts` | typed read client (browser-safe: no server imports); dispatches through `@ultimat3/core`'s `clientTransport`, never `fetch` |
|
|
21
|
+
| `record-answer.ts` | a read's HTTP answer: bare rows, or the record envelope when `rows:` is an entity's branded row schema |
|
|
21
22
|
| — | 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 |
|
|
22
|
-
| `naming.ts` | export name → `/_x/query/<kebab
|
|
23
|
+
| `naming.ts` | export name → `/_x/query/<kebab>` — `derivePath` IS `@ultimat3/core`'s `queryPath`, re-exported. **Paths only** — no tool name |
|
|
23
24
|
| `registry.ts` | export-name registration, `describeQueries()`, and the `registerPrimitiveRegistrar('query', …)` announcement |
|
|
24
25
|
| `live.ts` | `LiveQuery` descriptor + cursor arithmetic |
|
|
25
26
|
| `subscribes.ts` | the relations a live read declares, and the two assertions that keep them true |
|
|
@@ -193,14 +194,46 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
193
194
|
promises. It lives in `packages/core/src/client-flight.ts` now; the tests that pin it from this
|
|
194
195
|
side still drive it through this package's own client.
|
|
195
196
|
- **`ClientFlight` is a TYPE inside `client.ts` and never a value.** That erasure is the entire
|
|
196
|
-
tree-shaking story:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
`
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
`
|
|
197
|
+
tree-shaking story: a caller who wants a plain typed read must not pay for the fence, the dedup
|
|
198
|
+
map or the retry loop. Never import `createClientFlight` for a VALUE from `client.ts` —
|
|
199
|
+
`ClientFlight` and `ClientRetry` are `import type` from `@ultimat3/core` and must stay that way.
|
|
200
|
+
- **Every read dispatches through `@ultimat3/core`'s `clientTransport`, and `client.ts` calls no
|
|
201
|
+
`fetch`** (`As of 2026-09-22`, 21.0.0, plan 101 slice 05). The `fetch` option is handed on as the
|
|
202
|
+
transport's injected `fetchImpl`. The transport owns `Accept`, the trace/budget headers, the
|
|
203
|
+
principal fence, the error decode and the record envelope — so a gateway's HTML is core's
|
|
204
|
+
`X_CLIENT_TRANSPORT_FAILED` now, and `QueryRequestFailedError` / `X_RPC_FAILED` are gone from this
|
|
205
|
+
package. The flight is handed to the transport, which is what dedups concurrent identical reads
|
|
206
|
+
(`client.test.ts`, one `fetchImpl` call); `fresh` and a per-call `retry` ride on the request
|
|
207
|
+
to it. The path is core's `queryPath` — `naming.ts` re-exports it as `derivePath`. `slice 15`'s `browser-transport` guard is what will make
|
|
208
|
+
a `fetch(` call here a build error; until then the check is
|
|
209
|
+
`rg -n 'fetch\(' packages/query/src --glob '!*.test.ts'` finding nothing.
|
|
210
|
+
- **Bundle, `bun build --target=browser --minify`, `import { queryClient } from '@ultimat3/query'`**
|
|
211
|
+
(`As of 2026-09-22`): **19,026 B** at 20.2.1 (raw `fetch`), **24,114 B** on `clientTransport`;
|
|
212
|
+
the deep `src/client.ts` import 15,690 → 20,774 B. The +5.1 kB is entirely core's transport graph
|
|
213
|
+
(`client-transport.ts`, `client-dispatch.ts`, `client-problem.ts`, `record-envelope.ts`,
|
|
214
|
+
`client-scope.ts`, `record-sink.ts`, `client-paths.ts`); `client.ts` itself shrank. The 12,755 B this file
|
|
215
|
+
quoted was already false at 20.2.1. Shrinking it is a `packages/core` edit, never a raw `fetch`
|
|
216
|
+
here.
|
|
217
|
+
- **`@ultimat3/query/client` is the read client without the barrel** (`As of 2026-09-22`). The
|
|
218
|
+
barrel anchors `errors.ts` and `registry.ts` (`sideEffects`) and exports the server projections;
|
|
219
|
+
an island that only reads needs neither. `import { queryClient } from '@ultimat3/query/client'`,
|
|
220
|
+
measured `bun build --target=browser --minify`: **16,458 B** against **23,611 B** through the
|
|
221
|
+
barrel. `@ultimat3/realtime`'s `useQuery` is the caller it exists for. `client-subpath.test.ts`
|
|
222
|
+
pins that the specifier resolves and carries no server export.
|
|
223
|
+
- **…and reaches no titles table** (`As of 2026-09-22`, same method): **10,210 B**, from 16,640 B
|
|
224
|
+
that morning. `client.ts` and `naming.ts` import core from `@ultimat3/core/page`, and the two
|
|
225
|
+
page keys live in `page-keys.ts` — a leaf, because `page-controls.ts` refuses a bad value and so
|
|
226
|
+
imports `errors.ts`, which registers this package's whole code table at import. `client.ts` may
|
|
227
|
+
never import `page-controls.ts` or `stable.ts` again; `client-bundle.test.ts` fails on
|
|
228
|
+
`core-error-codes.ts`, `schema-error-codes.ts` or `errors.ts` in the graph, and past 11 kB.
|
|
229
|
+
- **The record envelope is derived from `rows:`, never switched on** (`record-answer.ts`). A read's
|
|
230
|
+
`sql:` names its table as a STRING, so the query declaration carries no schema the envelope could
|
|
231
|
+
be derived from; `rows?: StandardSchemaV1<unknown, TRow>` is that carrier, typed against the row
|
|
232
|
+
`sql:` answers so a projection cannot claim a full entity's schema. `answersRecords(rows)`
|
|
233
|
+
(`hasEntityRows`, `@ultimat3/entity`) is decided ONCE at projection and read by both `http.ts`
|
|
234
|
+
and `openapi.ts`; the envelope is sent on EVERY answer (`records: {}` when none came back) — one body
|
|
235
|
+
shape per operation, action's rule, described once by core's `recordEnvelopeSchema`. No `rows:`,
|
|
236
|
+
or an unbranded one, is byte-identical on the wire and in `openapi.json`.
|
|
204
237
|
- **The `sideEffects` array is what makes the barrel shakable, and it is load-bearing** (`As of
|
|
205
238
|
2026-08-23`). Declaring nothing meant a bundler had to assume every module ran at import, so
|
|
206
239
|
`import { rpc } from '@ultimat3/action'` was 43,104 B and `import { queryClient } from
|
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,12 @@ 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
|
|
|
487
523
|
## Boundaries
|
|
488
524
|
|
|
489
|
-
Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `policy`. Never imports `action`,
|
|
525
|
+
Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `entity`, `http`, `policy`. Never imports `action`,
|
|
490
526
|
`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": "21.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": "21.0.0",
|
|
40
|
+
"@ultimat3/core": "21.0.0",
|
|
41
|
+
"@ultimat3/entity": "21.0.0",
|
|
42
|
+
"@ultimat3/http": "21.0.0",
|
|
43
|
+
"@ultimat3/policy": "21.0.0",
|
|
44
|
+
"@ultimat3/schema": "21.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/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
|
|
@@ -29,11 +29,12 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Codes `@ultimat3/action` owns that this package only throws.
|
|
33
|
-
* enforcing an input schema
|
|
34
|
-
*
|
|
32
|
+
* Codes `@ultimat3/action` owns that this package only throws. It describes an action's job —
|
|
33
|
+
* enforcing an input schema — so action declares the title and query never re-declares it: two
|
|
34
|
+
* copies of a title are two titles, one of which is stale. `X_RPC_FAILED` left this list in
|
|
35
|
+
* 21.0.0: the typed read client's wire failures are `@ultimat3/core`'s `clientTransport`'s now.
|
|
35
36
|
*/
|
|
36
|
-
export const QUERY_BORROWED_ERROR_CODES = ['X_INPUT_INVALID'
|
|
37
|
+
export const QUERY_BORROWED_ERROR_CODES = ['X_INPUT_INVALID'] as const;
|
|
37
38
|
|
|
38
39
|
// One unconditional call: a presence guard would turn "another package claims one of these codes"
|
|
39
40
|
// from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
|
|
@@ -305,46 +306,3 @@ export class QueryInputInvalidError extends UltimateError {
|
|
|
305
306
|
});
|
|
306
307
|
}
|
|
307
308
|
}
|
|
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
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { tagKeys } from '@ultimat3/cache';
|
|
9
9
|
import { isUltimateError } from '@ultimat3/core';
|
|
10
10
|
import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
|
|
11
|
-
import {
|
|
11
|
+
import { problem, toBucket } from '@ultimat3/http';
|
|
12
12
|
import { coerceQuery } from '@ultimat3/schema';
|
|
13
13
|
import type { Deprecation } from './deprecation';
|
|
14
14
|
import { applyHeaders, recordDeprecatedCall, renderDeprecation } from './deprecation';
|
|
@@ -18,6 +18,7 @@ import { pageControlsOf } from './page-controls';
|
|
|
18
18
|
import { admitsAnonymous, policyCapability } from './policy-gate';
|
|
19
19
|
import type { AnyQuery } from './query';
|
|
20
20
|
import { queryName, runQuery } from './read';
|
|
21
|
+
import { recordAnswerFor } from './record-answer';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* `liveFeed` -> `GET /_x/query/live-feed`. Named for the primitive rather than spelled
|
|
@@ -30,6 +31,7 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
30
31
|
// Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
|
|
31
32
|
// not a surprise on the first read.
|
|
32
33
|
const sunsetting = deprecationHeadersFor(name, target.deprecated);
|
|
34
|
+
const answer = recordAnswerFor(target.rows);
|
|
33
35
|
|
|
34
36
|
const handler = async (request: UltimateRequest): Promise<Response> => {
|
|
35
37
|
if (sunsetting !== undefined) recordDeprecatedCall('query', name);
|
|
@@ -52,7 +54,7 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
52
54
|
// answer is the bare array it has always been: every client written before the controls
|
|
53
55
|
// existed keeps reading rows, and `hasNextPage` never rides on a row where a page marker
|
|
54
56
|
// has no business being.
|
|
55
|
-
const response =
|
|
57
|
+
const response = answer(
|
|
56
58
|
page === undefined
|
|
57
59
|
? await runQuery(target, input, { surface: 'http' })
|
|
58
60
|
: await target.page(input, { ...page, surface: 'http' }),
|
package/src/index.ts
CHANGED
|
@@ -66,7 +66,6 @@ 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,
|
|
@@ -79,7 +78,6 @@ export {
|
|
|
79
78
|
QueryInputUnencodableError,
|
|
80
79
|
QueryNotPageableError,
|
|
81
80
|
QueryPolicyMissingError,
|
|
82
|
-
QueryRequestFailedError,
|
|
83
81
|
QuerySubscribesDriftError,
|
|
84
82
|
QuerySubscribesInvalidError,
|
|
85
83
|
QueryUnregisteredError,
|
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;
|
package/src/openapi.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { tagKeys } from '@ultimat3/cache';
|
|
19
|
+
import { RECORDS_OPENAPI_HEADER, recordEnvelopeSchema } from '@ultimat3/core';
|
|
19
20
|
import type { SchemaNode } from '@ultimat3/schema';
|
|
20
21
|
import { nodeToJsonSchema, tryIntrospect } from '@ultimat3/schema';
|
|
21
22
|
import { isExposed } from './mcp-tool';
|
|
@@ -24,6 +25,7 @@ import { MAX_PAGE_SIZE, PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
|
|
|
24
25
|
import { policyCapability } from './policy-gate';
|
|
25
26
|
import type { AnyQuery } from './query';
|
|
26
27
|
import { queryName } from './read';
|
|
28
|
+
import { answersRecords } from './record-answer';
|
|
27
29
|
import { listQueries } from './registry';
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -37,6 +39,24 @@ const QUERY_TAG = 'query';
|
|
|
37
39
|
|
|
38
40
|
export type OpenApiPathItem = Record<string, unknown>;
|
|
39
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Two shapes, decided by the request: the bare rows every client read before the controls
|
|
44
|
+
* existed, and the page envelope when `_first` is sent. `oneOf` and not a second operation — it is
|
|
45
|
+
* one route, one policy evaluation, one URL prefix.
|
|
46
|
+
*/
|
|
47
|
+
const ANSWERS: readonly Record<string, unknown>[] = [
|
|
48
|
+
{ type: 'array', items: {} },
|
|
49
|
+
{
|
|
50
|
+
type: 'object',
|
|
51
|
+
required: ['rows', 'endCursor', 'hasNextPage'],
|
|
52
|
+
properties: {
|
|
53
|
+
rows: { type: 'array', items: {} },
|
|
54
|
+
endCursor: { type: ['string', 'null'] },
|
|
55
|
+
hasNextPage: { type: 'boolean' },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
40
60
|
/** `{ [path]: { get: operation } }` for every registered read, name-sorted like the actions. */
|
|
41
61
|
export function queryOpenApiPaths(
|
|
42
62
|
queries: readonly AnyQuery[] = listQueries(),
|
|
@@ -70,22 +90,14 @@ export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknow
|
|
|
70
90
|
// Two shapes, decided by the request: the bare rows every client read before the
|
|
71
91
|
// controls existed, and the page envelope when `_first` is sent. `oneOf` and not a
|
|
72
92
|
// second operation — it is one route, one policy evaluation, one URL prefix.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
required: ['rows', 'endCursor', 'hasNextPage'],
|
|
79
|
-
properties: {
|
|
80
|
-
rows: { type: 'array', items: {} },
|
|
81
|
-
endCursor: { type: ['string', 'null'] },
|
|
82
|
-
hasNextPage: { type: 'boolean' },
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
],
|
|
86
|
-
},
|
|
93
|
+
// A read declaring an entity's `rows:` ALWAYS answers the envelope, with the same two
|
|
94
|
+
// shapes under `data` — core's one description, shared with the action projection.
|
|
95
|
+
schema: answersRecords(target.rows)
|
|
96
|
+
? recordEnvelopeSchema({ oneOf: ANSWERS })
|
|
97
|
+
: { oneOf: ANSWERS },
|
|
87
98
|
},
|
|
88
99
|
},
|
|
100
|
+
...(answersRecords(target.rows) ? { headers: RECORDS_OPENAPI_HEADER } : {}),
|
|
89
101
|
},
|
|
90
102
|
// `X_INPUT_INVALID` is the search string failing the read's own schema, or a page control
|
|
91
103
|
// outside its bound; `X_CURSOR_INVALID` is an `_after` that is not one of this read's. Both
|
package/src/page-controls.ts
CHANGED
|
@@ -12,11 +12,9 @@
|
|
|
12
12
|
// `input-shape.ts` refuses a declaration that reaches for it, so the two names can never collide.
|
|
13
13
|
|
|
14
14
|
import { QueryInputInvalidError } from './errors';
|
|
15
|
+
import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-keys';
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
export const PAGE_FIRST_KEY = '_first';
|
|
18
|
-
/** `?_after=<cursor>` — the signed cursor the previous page's `endCursor` carried. */
|
|
19
|
-
export const PAGE_AFTER_KEY = '_after';
|
|
17
|
+
export { PAGE_AFTER_KEY, PAGE_FIRST_KEY };
|
|
20
18
|
export const PAGE_CONTROL_KEYS: readonly string[] = [PAGE_FIRST_KEY, PAGE_AFTER_KEY];
|
|
21
19
|
|
|
22
20
|
/**
|
package/src/page-keys.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The two search-string keys that turn `GET /_x/query/<name>` into a page — and nothing else. A
|
|
2
|
+
// leaf apart from `page-controls.ts` because that module refuses a bad value, which means it imports
|
|
3
|
+
// this package's `errors.ts`, which registers the whole code table at import: the browser client
|
|
4
|
+
// that only SPELLS the keys must not carry it. `page-controls.ts` re-exports both names.
|
|
5
|
+
|
|
6
|
+
/** `?_first=20` — the page size. Present is what makes the route answer a `Page`. */
|
|
7
|
+
export const PAGE_FIRST_KEY = '_first';
|
|
8
|
+
/** `?_after=<cursor>` — the signed cursor the previous page's `endCursor` carried. */
|
|
9
|
+
export const PAGE_AFTER_KEY = '_after';
|
package/src/query.ts
CHANGED
|
@@ -89,6 +89,15 @@ export interface QueryDef<TInput extends StandardSchemaV1, TRow extends object>
|
|
|
89
89
|
* is legal — a join reads relations the shape's single `entity` cannot name.
|
|
90
90
|
*/
|
|
91
91
|
readonly subscribes?: readonly string[];
|
|
92
|
+
/**
|
|
93
|
+
* The row schema, when every row IS an entity's row — `rows: Post.$schema`. Typed against the
|
|
94
|
+
* row `sql:` answers, so a projection (`from<PostSummary>`) cannot claim a full entity's schema.
|
|
95
|
+
* It is what the route reads to answer the record envelope (`x-ultimate-records: 1`) so the
|
|
96
|
+
* page's one store adopts the rows; absent, or unbranded, the wire is the bare rows it has
|
|
97
|
+
* always been. `sql:` names a table as a STRING, so nothing else carries the schema to derive
|
|
98
|
+
* from — this key is that carrier, never a `records:` switch.
|
|
99
|
+
*/
|
|
100
|
+
readonly rows?: StandardSchemaV1<unknown, TRow>;
|
|
92
101
|
sql(input: InferOutput<TInput>, ctx: Ctx): SqlSource<TRow>;
|
|
93
102
|
readonly cache?: QueryCache;
|
|
94
103
|
readonly mcp?: QueryMcp;
|
|
@@ -179,6 +188,7 @@ export interface AnyQueryDef {
|
|
|
179
188
|
readonly policy: QueryPolicy;
|
|
180
189
|
readonly live?: boolean;
|
|
181
190
|
readonly subscribes?: readonly string[];
|
|
191
|
+
readonly rows?: StandardSchemaV1;
|
|
182
192
|
sql(input: unknown, ctx: Ctx): SqlSource<object>;
|
|
183
193
|
readonly cache?: QueryCache;
|
|
184
194
|
readonly mcp?: QueryMcp;
|
|
@@ -196,6 +206,8 @@ export interface AnyQuery {
|
|
|
196
206
|
readonly policy: QueryPolicy;
|
|
197
207
|
/** Lifted for the same reason `rateLimit` is: `toLiveQuery` cross-checks it without `defOf`. */
|
|
198
208
|
readonly subscribes?: readonly string[];
|
|
209
|
+
/** Lifted for the route: whether an answer is a record envelope is decided at projection. */
|
|
210
|
+
readonly rows?: StandardSchemaV1;
|
|
199
211
|
readonly cache?: QueryCache;
|
|
200
212
|
readonly mcp?: QueryMcp;
|
|
201
213
|
/** Lifted so `toQueryRoute` reads the declaration without reaching through `defOf`. */
|
|
@@ -240,6 +252,7 @@ export type QueryFacade<TInput extends StandardSchemaV1, TRow extends object> =
|
|
|
240
252
|
| 'input'
|
|
241
253
|
| 'policy'
|
|
242
254
|
| 'subscribes'
|
|
255
|
+
| 'rows'
|
|
243
256
|
| 'cache'
|
|
244
257
|
| 'mcp'
|
|
245
258
|
| 'rateLimit'
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A read's HTTP answer. Decided ONCE per query, at projection, from its `rows:` schema — the rule
|
|
3
|
+
* `@ultimat3/action`'s `record-wire.ts` already follows: a query whose `rows:` is an entity row
|
|
4
|
+
* schema ALWAYS answers `{ data, records }` behind `x-ultimate-records: 1` (records may be `{}`),
|
|
5
|
+
* and every other query answers the bare rows (or `Page`) it always did. One body shape per
|
|
6
|
+
* operation — until 21.0.0 a read sent the envelope only when a row came back, so its OpenAPI was a
|
|
7
|
+
* `oneOf` over "sometimes" and a generated client had to branch on the data.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { RecordRows, Row } from '@ultimat3/core';
|
|
11
|
+
import { encodeRecordEnvelope, RECORDS_HEADER } from '@ultimat3/core';
|
|
12
|
+
import { hasEntityRows, rowsOf } from '@ultimat3/entity';
|
|
13
|
+
import { json } from '@ultimat3/http';
|
|
14
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
15
|
+
import type { Page } from './pagination';
|
|
16
|
+
|
|
17
|
+
/** Turns one read's answer into its response. Built once per route, never per request. */
|
|
18
|
+
export type RecordAnswer = (answer: readonly object[] | Page<object>) => Response;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `undefined` or an unbranded schema answers the plain `json()` it always did. The rows are
|
|
22
|
+
* collected one at a time against the ROW schema, since the answer's own shape — an array or a
|
|
23
|
+
* `Page` — is the route's, not the declaration's.
|
|
24
|
+
*/
|
|
25
|
+
export function recordAnswerFor(rows: StandardSchemaV1 | undefined): RecordAnswer {
|
|
26
|
+
if (!answersRecords(rows)) return (answer) => json(answer);
|
|
27
|
+
return (answer) => {
|
|
28
|
+
const records = collect(rows, isPage(answer) ? answer.rows : answer);
|
|
29
|
+
return json(encodeRecordEnvelope(answer, records), { headers: { [RECORDS_HEADER]: '1' } });
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The one predicate the route and the OpenAPI projection both ask: can this read answer records? */
|
|
34
|
+
export function answersRecords(rows: StandardSchemaV1 | undefined): rows is StandardSchemaV1 {
|
|
35
|
+
return rows !== undefined && hasEntityRows(rows);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `Array.isArray` does not narrow a `readonly` array out of a union, so this says it once. */
|
|
39
|
+
function isPage(answer: readonly object[] | Page<object>): answer is Page<object> {
|
|
40
|
+
return !Array.isArray(answer);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Every entity row across the answer's rows: record type -> record key -> row. First sighting wins,
|
|
45
|
+
* `rowsOf`'s own rule — one record shown twice is one record. Built in `Map`s and handed out as
|
|
46
|
+
* null-prototype objects: a record type and a record key are data, and `__proto__` must stay a key.
|
|
47
|
+
*/
|
|
48
|
+
function collect(
|
|
49
|
+
schema: StandardSchemaV1,
|
|
50
|
+
list: readonly object[],
|
|
51
|
+
): Readonly<Record<string, RecordRows>> {
|
|
52
|
+
const out = new Map<string, Map<string, Row>>();
|
|
53
|
+
for (const row of list) {
|
|
54
|
+
for (const [type, byKey] of Object.entries(rowsOf(schema, row))) {
|
|
55
|
+
const bucket = out.get(type) ?? new Map<string, Row>();
|
|
56
|
+
out.set(type, bucket);
|
|
57
|
+
for (const [key, found] of Object.entries(byKey))
|
|
58
|
+
if (!bucket.has(key)) bucket.set(key, found);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return nullProto([...out].map(([type, bucket]) => [type, nullProto(bucket)] as const));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function nullProto<V>(entries: Iterable<readonly [string, V]>): Readonly<Record<string, V>> {
|
|
65
|
+
const target = Object.create(null) as Record<string, V>;
|
|
66
|
+
for (const [key, value] of entries) target[key] = value;
|
|
67
|
+
return target;
|
|
68
|
+
}
|