@ultimat3/query 1.2.0 → 3.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/src/client.ts CHANGED
@@ -3,11 +3,18 @@
3
3
  * the same pure derivation the server uses, so a renamed query is a compile error
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
+ *
7
+ * Rows arrive as JSON and are handed back as parsed, exactly as `rpc` does: a query declares no
8
+ * output schema — row types come from the `SqlSource` its `sql:` returns — so there is nothing
9
+ * here to rehydrate a `Date` with, and an instant reaches a caller as the ISO string
10
+ * `JSON.stringify` wrote. A surface that formats one converts at its own edge.
6
11
  */
7
12
 
13
+ import { currentSpanContext, traceparent } from '@ultimat3/core';
8
14
  import type { InferInput, StandardSchemaV1 } from '@ultimat3/schema';
9
15
  import { QueryRequestFailedError } from './errors';
10
16
  import { derivePath } from './naming';
17
+ import type { Query } from './query';
11
18
  import { isJsonObject } from './stable';
12
19
 
13
20
  export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
@@ -28,7 +35,57 @@ export type QueryClientMethod<TInput extends StandardSchemaV1, TRow extends obje
28
35
  options?: QueryCallOptions,
29
36
  ) => Promise<readonly TRow[]>;
30
37
 
31
- /** One query's method — what `query.client()` returns. */
38
+ /**
39
+ * Loose constraint on purpose: a map of concrete `Query<TInput, TRow>` values must be
40
+ * assignable to it, while `QueryClient<T>` still recovers each read's own input schema and
41
+ * row type. The mirror of `@ultimat3/action`'s `ActionLike`.
42
+ */
43
+ export interface QueryLike {
44
+ readonly kind: 'query';
45
+ readonly name: string;
46
+ }
47
+
48
+ export type QueryMap = Record<string, QueryLike>;
49
+
50
+ /** `queries.publicPost({ slug })`, with the input schema and the row type both inferred. */
51
+ export type QueryClient<TQueries extends QueryMap> = {
52
+ readonly [K in keyof TQueries]: TQueries[K] extends Query<infer TInput, infer TRow>
53
+ ? QueryClientMethod<TInput, TRow>
54
+ : never;
55
+ };
56
+
57
+ /**
58
+ * The typed client for a whole query map: `queryClient<Api['queries']>({ baseUrl })`, the read
59
+ * half of `rpc<Api['actions']>`. A surface that must not import a feature — `site/`, whose one
60
+ * edge into `app/` would be a boundary violation — reaches every registered read through this
61
+ * and the `Api` TYPE, with no module-graph edge and no codegen step.
62
+ *
63
+ * One blessed name, and one implementation underneath it: every method is
64
+ * `queryClientMethodFor`, so the map-wide spelling and `read.client()` can never derive
65
+ * different URLs for the same read.
66
+ */
67
+ export function queryClient<TQueries extends QueryMap>(
68
+ options: QueryClientOptions,
69
+ ): QueryClient<TQueries> {
70
+ const proxy = new Proxy(
71
+ {},
72
+ {
73
+ get(_target, property: string | symbol) {
74
+ // `then` is answered with `undefined` for the same reason a symbol is: `await client`,
75
+ // `Promise.resolve(client)` and returning the client from an async function all read it,
76
+ // and a method there makes the client a thenable that fetches a read named "then" and
77
+ // resolves the await to its rows. No query may be called `then` — it is the one name the
78
+ // language reserves at this seam.
79
+ if (typeof property !== 'string' || property === 'then') return undefined;
80
+ return queryClientMethodFor(property, options);
81
+ },
82
+ },
83
+ );
84
+ // The proxy realizes the mapped type structurally; TS cannot check a Proxy.
85
+ return proxy as QueryClient<TQueries>;
86
+ }
87
+
88
+ /** One query's method — what `query.client()` returns, and what `queryClient` proxies to. */
32
89
  export function queryClientMethodFor<TInput extends StandardSchemaV1, TRow extends object>(
33
90
  name: string,
34
91
  options: QueryClientOptions,
@@ -52,7 +109,10 @@ async function read(
52
109
  const url = `${base}${derivePath(name)}${search === '' ? '' : `?${search}`}`;
53
110
  const init: RequestInit = {
54
111
  method: 'GET',
55
- headers: { accept: 'application/json', ...options.headers },
112
+ // `traceHeaders()` before the caller's, so an explicit `traceparent` still wins. Without it a
113
+ // service-to-service read started a fresh root trace on the other side, and "which of my
114
+ // downstreams is slow" was unanswerable across every Ultimate-to-Ultimate hop.
115
+ headers: { accept: 'application/json', ...traceHeaders(), ...options.headers },
56
116
  ...(callOptions.signal === undefined ? {} : { signal: callOptions.signal }),
57
117
  };
58
118
 
@@ -63,6 +123,27 @@ async function read(
63
123
  return body;
64
124
  }
65
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) };
145
+ }
146
+
66
147
  /**
67
148
  * Input as a query string. Keys are sorted so the same input always produces the
68
149
  * same URL — a GET is a cache key, and an unstable one caches nothing.
@@ -0,0 +1,65 @@
1
+ // Single responsibility: what a sort value becomes inside a cursor, and what it becomes again on
2
+ // the way out. The codec is `@ultimat3/core`'s and it is JSON, so a `Date` went in and an ISO
3
+ // STRING came back: `isAfterKey` then compared `"1769904000000"` against `"2026-02-01T…"` through
4
+ // `compareValues`' string branch and page two came back empty. A `bigint` was worse — a bare
5
+ // `TypeError` out of `JSON.stringify`, with no code and no fix.
6
+ //
7
+ // `@ultimat3/entity`'s `cursor.ts` solves the same problem by reading the column's declared kind.
8
+ // A `query` has no column kinds — `QueryShape.orderBy` is a name and a direction — so the value
9
+ // carries its own tag instead. Self-describing, which is also what makes the revive total: nothing
10
+ // here has to know which read minted the cursor.
11
+
12
+ import { CursorValueUnsupportedError } from './errors';
13
+
14
+ /** The two tagged forms. `$x` is a key no column value can collide with: JSON has no bigints. */
15
+ const DATE = 'date';
16
+ const BIGINT = 'bigint';
17
+
18
+ interface TaggedValue {
19
+ readonly $x: typeof DATE | typeof BIGINT;
20
+ readonly v: string;
21
+ }
22
+
23
+ function isTagged(value: unknown): value is TaggedValue {
24
+ if (typeof value !== 'object' || value === null) return false;
25
+ const tag = (value as Record<string, unknown>)['$x'];
26
+ return (
27
+ (tag === DATE || tag === BIGINT) && typeof (value as Record<string, unknown>)['v'] === 'string'
28
+ );
29
+ }
30
+
31
+ /**
32
+ * One sort value, in a form `JSON.stringify` carries losslessly.
33
+ *
34
+ * `undefined` becomes `null` because SQL has one absence and `isNull` reads both as it — a key
35
+ * that encoded `undefined` would be dropped by `JSON.stringify` and shift every later key one
36
+ * position left, which is a cursor that seeks by the wrong column.
37
+ *
38
+ * Everything JSON cannot carry AND this cannot tag is refused HERE, where the cursor is minted:
39
+ * the mistake is the read's `orderBy`, and reporting it on the next request would blame a client
40
+ * for a declaration it never saw.
41
+ */
42
+ export function serializeSortValue(value: unknown): unknown {
43
+ if (value === null || value === undefined) return null;
44
+ if (value instanceof Date) {
45
+ if (Number.isNaN(value.getTime())) throw new CursorValueUnsupportedError('an Invalid Date');
46
+ return { $x: DATE, v: value.toISOString() } satisfies TaggedValue;
47
+ }
48
+ if (typeof value === 'bigint') return { $x: BIGINT, v: value.toString() } satisfies TaggedValue;
49
+ if (typeof value === 'string' || typeof value === 'boolean') return value;
50
+ // `NaN` and `±Infinity` are `null` in JSON, which is the largest value in this framework's sort
51
+ // order — so an unsortable number would decode as "past every row" and end the listing.
52
+ if (typeof value === 'number') {
53
+ if (Number.isFinite(value)) return value;
54
+ throw new CursorValueUnsupportedError(`the number ${String(value)}`);
55
+ }
56
+ throw new CursorValueUnsupportedError(`a ${typeof value}`);
57
+ }
58
+
59
+ /** The inverse, over a whole decoded key. A value that is not one of ours is handed back as is. */
60
+ export function reviveSortKey(key: readonly unknown[]): readonly unknown[] {
61
+ return key.map((value) => {
62
+ if (!isTagged(value)) return value;
63
+ return value.$x === DATE ? new Date(value.v) : BigInt(value.v);
64
+ });
65
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * A declared retirement, rendered as the two headers the standards already define — RFC 9745
3
+ * `Deprecation` and RFC 8594 `Sunset` — plus the successor link. Pure string and date maths, and
4
+ * deliberately throw-free: each package raises its own `X_*` for a date it cannot render.
5
+ *
6
+ * `@ultimat3/action` carries the twin of this file. Both are tier 3, so neither may import the
7
+ * other, and the shared home is `@ultimat3/http` (tier 2) once that package grows one — the same
8
+ * compromise `naming.ts` is ported under.
9
+ */
10
+ import { counter } from '@ultimat3/core';
11
+
12
+ export interface Deprecation {
13
+ /** When it was deprecated. ISO-8601, e.g. `'2026-08-01T00:00:00Z'`. */
14
+ readonly since: string;
15
+ /** When it stops answering. ISO-8601 — the date `Sunset` publishes and clients plan against. */
16
+ readonly sunset: string;
17
+ /** The export name of the replacement, projected to a `rel="successor-version"` link. */
18
+ readonly replacedBy?: string;
19
+ }
20
+
21
+ export type DeprecationField = 'since' | 'sunset';
22
+
23
+ export type DeprecationRender =
24
+ | {
25
+ readonly ok: true;
26
+ readonly headers: Readonly<Record<string, string>>;
27
+ /** The same facts as data, for the descriptor and the manifest. */
28
+ readonly meta: Readonly<Record<string, string>>;
29
+ }
30
+ | { readonly ok: false; readonly field: DeprecationField; readonly value: string };
31
+
32
+ /**
33
+ * How many calls a deprecated declaration is still taking — the number "can we remove it yet?"
34
+ * needs. Attributes are the primitive and the declared NAME, both bounded by the size of the
35
+ * codebase; a caller id here would be an unbounded series.
36
+ */
37
+ const deprecatedCalls = counter('deprecated_calls_total', {
38
+ unit: '{call}',
39
+ description: 'Calls served by a declaration that has been deprecated, by primitive and name',
40
+ });
41
+
42
+ export function recordDeprecatedCall(primitive: 'action' | 'query', name: string): void {
43
+ deprecatedCalls.add(1, { primitive, name });
44
+ }
45
+
46
+ /**
47
+ * `Deprecation` is a structured-field Date (`@` + unix seconds, RFC 9745); `Sunset` is an
48
+ * HTTP-date (IMF-fixdate, RFC 8594). Two spellings of one instant because two RFCs chose
49
+ * differently — never render one in the other's format, and never emit `Invalid Date`.
50
+ */
51
+ export function renderDeprecation(
52
+ deprecation: Deprecation,
53
+ successorPath: string | undefined,
54
+ ): DeprecationRender {
55
+ const since = Date.parse(deprecation.since);
56
+ if (Number.isNaN(since)) return { ok: false, field: 'since', value: deprecation.since };
57
+ const sunset = Date.parse(deprecation.sunset);
58
+ if (Number.isNaN(sunset)) return { ok: false, field: 'sunset', value: deprecation.sunset };
59
+
60
+ const headers: Record<string, string> = {
61
+ deprecation: `@${Math.floor(since / 1000)}`,
62
+ sunset: new Date(sunset).toUTCString(),
63
+ };
64
+ // The successor's URL, derived by the caller from the same `naming.ts` the client uses — a
65
+ // link this file built from the export name would be the second URL derivation in the package.
66
+ if (successorPath !== undefined) {
67
+ headers['link'] = `<${successorPath}>; rel="successor-version"`;
68
+ }
69
+
70
+ const meta: Record<string, string> = {
71
+ since: new Date(since).toISOString(),
72
+ sunset: new Date(sunset).toISOString(),
73
+ ...(deprecation.replacedBy === undefined ? {} : { replacedBy: deprecation.replacedBy }),
74
+ };
75
+ return { ok: true, headers, meta };
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
@@ -9,9 +9,13 @@ export { CursorInvalidError } from '@ultimat3/core';
9
9
 
10
10
  /** Titles for the framework-wide code table — every one of them owned by this package. */
11
11
  const OWNED_TITLES: Readonly<Record<string, string>> = {
12
+ X_CURSOR_VALUE_UNSUPPORTED: 'a sort value cannot be carried in a cursor',
12
13
  X_MATCHER_UNSUPPORTED: 'live query shape cannot be patched incrementally',
14
+ X_QUERY_CACHE_TTL_INVALID: 'a query declares a cache ttlMs no tier can hold',
15
+ X_QUERY_DEPRECATION_INVALID: 'a query declares a deprecation whose dates cannot be rendered',
13
16
  X_QUERY_DUPLICATE: 'two queries are registered under one name',
14
17
  X_QUERY_FOREIGN: 'a value that is not a query was projected as one',
18
+ X_QUERY_INPUT_UNENCODABLE: 'a query input cannot be carried in a query string',
15
19
  X_QUERY_NOT_PAGEABLE: 'a read returned rows with no id, so a cursor cannot name a position',
16
20
  X_QUERY_POLICY_MISSING: 'a query was registered without a policy',
17
21
  X_QUERY_UNREGISTERED: 'a query was used before it was registered',
@@ -107,6 +111,49 @@ export class QueryForeignError extends UltimateError {
107
111
  }
108
112
  }
109
113
 
114
+ /**
115
+ * A read whose declared input cannot survive its own route. Thrown at `query()`, so the file that
116
+ * wrote it is the file that fails.
117
+ *
118
+ * The `fix` names the three edits that exist, because which one applies depends on what the key
119
+ * means: a structure belongs in an `action`'s JSON body, a filter can be flattened into scalar
120
+ * keys, and an explicitly-null argument is spelled as an absent optional one.
121
+ */
122
+ export class QueryInputUnencodableError extends UltimateError {
123
+ constructor(offender: string) {
124
+ super({
125
+ code: 'X_QUERY_INPUT_UNENCODABLE',
126
+ cause: `${offender}, and a read is served as GET /_x/query/<name> — a query string carries characters, not structures or nulls`,
127
+ fix: 'flatten the key into scalar arguments (status: t.string, limit: t.number), spell an absent value as `.optional()` rather than `t.nullable(...)`, or declare it as an action() if it really needs a JSON body',
128
+ docs: docs('X_QUERY_INPUT_UNENCODABLE'),
129
+ });
130
+ }
131
+ }
132
+
133
+ /**
134
+ * A `cache.ttlMs` no tier will accept, refused at `query()` — so the file that wrote it fails, and
135
+ * not every read of that query for the life of the process.
136
+ *
137
+ * Every `CacheTier` refuses a non-positive or non-finite lease (`assertTtl`, `X_CACHE_TTL_INVALID`)
138
+ * and the read path's only catch absorbs `X_CACHE_TOO_LARGE`, so `ttlMs: Infinity` used to make a
139
+ * working read fail permanently with a cause naming a cache key. The value is a number the author
140
+ * typed, so it is echoed: it is the one fact that repairs the line.
141
+ *
142
+ * The query has no name yet — `query()` runs before `registerQueries()` stamps one — which is why
143
+ * the cause describes the declaration, exactly as `X_QUERY_INPUT_UNENCODABLE` does.
144
+ */
145
+ export class QueryCacheTtlInvalidError extends UltimateError {
146
+ constructor(ttlMs: number) {
147
+ super({
148
+ code: 'X_QUERY_CACHE_TTL_INVALID',
149
+ cause: `a query declares cache.ttlMs as ${ttlMs}, and every cache tier refuses a lease that is not positive and finite`,
150
+ fix: 'set `cache: { ttlMs: 60_000 }` to a positive whole number of milliseconds, or drop ttlMs to take the read cache default',
151
+ docs: docs('X_QUERY_CACHE_TTL_INVALID'),
152
+ meta: { ttlMs },
153
+ });
154
+ }
155
+ }
156
+
110
157
  export class QueryDuplicateError extends UltimateError {
111
158
  constructor(name: string) {
112
159
  super({
@@ -118,17 +165,43 @@ export class QueryDuplicateError extends UltimateError {
118
165
  }
119
166
  }
120
167
 
168
+ /**
169
+ * The `fix` pastes a PERMISSION and the `cause` names the query, and the two are not
170
+ * interchangeable: `can()` takes `resource:verb` (`Permission` is `` `${string}:${string}` ``), so
171
+ * the `can('${name}')` this used to emit did not compile, and `assertPermission` would refuse it
172
+ * the moment the app declared its own set. The reader needs the query's name to find the file and
173
+ * the permission's SHAPE to fill the argument — the same split `@ultimat3/policy`'s own
174
+ * `policyMissing()` already makes.
175
+ */
121
176
  export class QueryPolicyMissingError extends UltimateError {
122
177
  constructor(name: string) {
123
178
  super({
124
179
  code: 'X_QUERY_POLICY_MISSING',
125
180
  cause: `query "${name}" was registered without a policy`,
126
- fix: `add \`policy: can('${name}')\` to the query definition in the file that exports it`,
181
+ fix: `add \`policy: can('<resource>:<verb>')\` to the query() that exports "${name}" — a permission your definePermissions() call declares, never the query's own name — or \`allow('<resource>:<verb>')\` to state that the read is public`,
127
182
  docs: docs('X_QUERY_POLICY_MISSING'),
128
183
  });
129
184
  }
130
185
  }
131
186
 
187
+ /**
188
+ * A `deprecated:` block whose dates cannot become the headers it promises. Refused where the
189
+ * declaration is converted, so every projection that reads it refuses the same value — the mirror
190
+ * of `@ultimat3/action`'s `X_ACTION_DEPRECATION_INVALID`, and for the same reason: a `Sunset`
191
+ * header rendering `Invalid Date` is a contract statement no client can act on.
192
+ */
193
+ export class QueryDeprecationInvalidError extends UltimateError {
194
+ constructor(name: string, field: string, value: string) {
195
+ super({
196
+ code: 'X_QUERY_DEPRECATION_INVALID',
197
+ cause: `query "${name}" declares deprecated.${field} as "${value}", which is not a date`,
198
+ fix: `edit \`deprecated: { ${field}: … }\` on ${name} to an ISO-8601 instant — e.g. '2026-12-31T23:59:59Z'`,
199
+ docs: docs('X_QUERY_DEPRECATION_INVALID'),
200
+ meta: { query: name, field, value },
201
+ });
202
+ }
203
+ }
204
+
132
205
  /**
133
206
  * Thrown when a paged or live read hands back a row with no `id`. The id is the tiebreak that
134
207
  * makes the sort order total, so without one the position a cursor names is ambiguous — and the
@@ -146,6 +219,29 @@ export class QueryNotPageableError extends UltimateError {
146
219
  }
147
220
  }
148
221
 
222
+ /**
223
+ * A sort key the cursor codec cannot carry, refused where the cursor is MINTED.
224
+ *
225
+ * Deliberately not `X_CURSOR_INVALID`: that code means "the cursor you sent is not one of ours",
226
+ * and its fix — request the first page again — repairs nothing here. This is the read's own
227
+ * `orderBy` naming a column whose values are objects, `NaN` or `±Infinity`, so the repair is one
228
+ * edit to the declaration and no retry will ever help. `Date` and `bigint` are NOT in this set:
229
+ * `cursor-value.ts` tags both and revives them, which is the whole reason it exists.
230
+ *
231
+ * The description says the SHAPE and never the value — a cursor's key is row data, and a `cause`
232
+ * reaches the log index and the problem document alike.
233
+ */
234
+ export class CursorValueUnsupportedError extends UltimateError {
235
+ constructor(description: string) {
236
+ super({
237
+ code: 'X_CURSOR_VALUE_UNSUPPORTED',
238
+ cause: `a sort key holds ${description}, which no cursor can carry`,
239
+ fix: 'order by a scalar column — .orderBy("createdAt") or .orderBy("id") — and project the composite value into the row instead',
240
+ docs: docs('X_CURSOR_VALUE_UNSUPPORTED'),
241
+ });
242
+ }
243
+ }
244
+
149
245
  /** The honest fallback: the matcher refuses to guess rather than patch wrongly. */
150
246
  export class MatcherUnsupportedError extends UltimateError {
151
247
  constructor(name: string, feature: string) {
package/src/facade.ts CHANGED
@@ -27,6 +27,8 @@ export function facadeFor<TInput extends StandardSchemaV1, TRow extends object>(
27
27
  policy: def.policy,
28
28
  ...(def.cache === undefined ? {} : { cache: def.cache }),
29
29
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
30
+ ...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
31
+ ...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
30
32
  // `.as()` is impersonation on the one read path: `runQuery` keeps the
31
33
  // surrounding context whole and swaps only the actor.
32
34
  as: (actor, input, options) => runQuery(self(), input, { ...options, actor }),
package/src/http.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Projection: a query becomes `GET /_x/query/<kebab>` — the URL `client.ts` already
3
+ * derives and fetches. The search string is the input, decoded at the wire and judged
4
+ * by `runQuery`, so the endpoint cannot drift from the MCP tool, the live window or a
5
+ * direct server call, and cannot acquire a second authz path while doing it.
6
+ */
7
+
8
+ import { tagKeys } from '@ultimat3/cache';
9
+ import { isUltimateError } from '@ultimat3/core';
10
+ import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
11
+ import { json, problem, toBucket } from '@ultimat3/http';
12
+ import { coerceQuery } from '@ultimat3/schema';
13
+ import type { Deprecation } from './deprecation';
14
+ import { applyHeaders, recordDeprecatedCall, renderDeprecation } from './deprecation';
15
+ import { QueryDeprecationInvalidError } from './errors';
16
+ import { derivePath } from './naming';
17
+ import { policyCapability } from './policy-gate';
18
+ import type { AnyQuery } from './query';
19
+ import { queryName, runQuery } from './read';
20
+
21
+ /**
22
+ * `liveFeed` -> `GET /_x/query/live-feed`. Named for the primitive rather than spelled
23
+ * `toRoute`, because a host mounts this beside `@ultimat3/action`'s and an alias at the
24
+ * import site is a name the reader has to hold — the same reason the tool projection
25
+ * here is `toQueryTool`.
26
+ */
27
+ export function toQueryRoute(target: AnyQuery): Route {
28
+ const name = queryName(target);
29
+ // Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
30
+ // not a surprise on the first read.
31
+ const sunsetting = deprecationHeadersFor(name, target.deprecated);
32
+
33
+ const handler = async (request: UltimateRequest): Promise<Response> => {
34
+ if (sunsetting !== undefined) recordDeprecatedCall('query', name);
35
+ try {
36
+ // Coerced, then validated — two different jobs, and only the first one belongs to a
37
+ // wire. A search string is characters, so `t.number` and `t.boolean` need the HTTP
38
+ // boundary's decode (`coerceQuery` never invents data: what it cannot convert it
39
+ // hands on untouched). VALIDATING here — `request.query(schema)` — would be the
40
+ // second parser: the same read would answer `X_BODY_INVALID` where every other
41
+ // surface answers `X_INPUT_INVALID` with the line that prints its schema. `runQuery`
42
+ // is the one that decides, exactly as it does for a direct server call.
43
+ const input = coerceQuery(target.input, request.queryRaw());
44
+ const response = json(await runQuery(target, input, { surface: 'http' }));
45
+ // On the failure path too, below: a client polling a deprecated read that is currently
46
+ // 403ing still has to learn the read is going away.
47
+ if (sunsetting !== undefined) applyHeaders(response, sunsetting);
48
+ return response;
49
+ } catch (error) {
50
+ // Framework errors carry their own code, status and fix line; anything else is
51
+ // a bug and belongs to the server's error boundary, not to this route.
52
+ if (!isUltimateError(error)) throw error;
53
+ const response = problem(error);
54
+ if (sunsetting !== undefined) applyHeaders(response, sunsetting);
55
+ return response;
56
+ }
57
+ };
58
+
59
+ const meta: RouteMeta = {
60
+ name,
61
+ // `allow(...)` is the only way a read is public, and saying so explicitly is what
62
+ // keeps "forgot the policy" from ever looking like "meant to be readable".
63
+ auth: target.policy.kind === 'allow' ? 'public' : 'required',
64
+ policy: policyCapability(target.policy),
65
+ // `runQuery` is this route's one evaluation and it decides from the PARSED input the
66
+ // rule reads (`ownsOrg(actor, input.orgId)`); the stage would decide the same policy
67
+ // from raw strings, and would need an `authorize` hook wired to decide at all.
68
+ enforcedBy: 'handler',
69
+ // `input` stays absent, deliberately: the pipeline's body stage validates `meta.input`
70
+ // against the BODY, and a GET has none — declaring it would fail every read on an
71
+ // absent body before the handler ran. The schema is not skipped, it is applied in the
72
+ // handler, by the same `runQuery` every other surface goes through.
73
+
74
+ // A read is answered per actor — the policy decided for this caller, and `sql` may
75
+ // scope the rows to them — while the URL names no actor at all. `public` would hand
76
+ // one actor's rows to the next caller of that URL, so a read is `no-store` and a
77
+ // shared cache is something a CDN in front of the app configures knowingly. The tags
78
+ // ride along so a purge can still name the read the tier keys by.
79
+ cache: { mode: 'no-store', tags: tagKeys(target.cache?.tags ?? []) },
80
+ tags: ['query'],
81
+ // Name AND numbers, exactly as an action's route sets them — the name alone selects a bucket
82
+ // the limiter's table never held, so `bucketFor` falls through to `default` (120 burst, 2/s)
83
+ // and a read declaring 5 runs on 120. `withRouteBuckets` registers the pair at construction.
84
+ // `toBucket` is `@ultimat3/http`'s: the limiter owns the maths, and a copy here would be a
85
+ // second conversion able to publish numbers the limiter refuses.
86
+ ...(target.rateLimit === undefined
87
+ ? {}
88
+ : { rateLimit: name, rateLimitBucket: toBucket(name, target.rateLimit) }),
89
+ ...(target.mcp?.description === undefined ? {} : { description: target.mcp.description }),
90
+ };
91
+
92
+ return { method: 'GET', path: derivePath(name), handler, meta };
93
+ }
94
+
95
+ /**
96
+ * The headers this read's `deprecated:` block renders to, or nothing. The successor's URL comes
97
+ * from `derivePath` — the same derivation `client()` uses, never a second one.
98
+ */
99
+ function deprecationHeadersFor(
100
+ name: string,
101
+ deprecated: Deprecation | undefined,
102
+ ): Readonly<Record<string, string>> | undefined {
103
+ if (deprecated === undefined) return undefined;
104
+ const successor =
105
+ deprecated.replacedBy === undefined ? undefined : derivePath(deprecated.replacedBy);
106
+ const rendered = renderDeprecation(deprecated, successor);
107
+ if (!rendered.ok) throw new QueryDeprecationInvalidError(name, rendered.field, rendered.value);
108
+ return rendered.headers;
109
+ }
package/src/index.ts CHANGED
@@ -9,43 +9,59 @@
9
9
  /** Re-exported so a `query` file needs one import, not two. Same object as schema's. */
10
10
  export type { Infer } from '@ultimat3/schema';
11
11
  export { t } from '@ultimat3/schema';
12
- export type { ReadCache, ReadCacheEntry } from './cache';
12
+ export type { QueryCacheScope } from './cache';
13
+ /** `readAuthority` is the ONLY producer of `cacheKeyFor`'s authority — never spell one by hand. */
13
14
  export {
14
15
  cacheKeyFor,
15
- getReadCache,
16
- invalidateQueryTags,
17
- MemoryReadCache,
16
+ DEFAULT_READ_CACHE_TTL_MS,
17
+ readAuthority,
18
+ readOnce,
18
19
  readThrough,
19
20
  requestMemo,
20
- setReadCache,
21
21
  } from './cache';
22
22
  export type {
23
23
  FetchLike,
24
24
  QueryCallOptions,
25
+ QueryClient,
25
26
  QueryClientMethod,
26
27
  QueryClientOptions,
28
+ QueryLike,
29
+ QueryMap,
27
30
  } from './client';
28
- export { queryClientMethodFor } from './client';
31
+ /** `queryClient` is the map-wide read client; `queryClientMethodFor` is what `.client()` binds. */
32
+ export { queryClient, queryClientMethodFor } from './client';
33
+ /** The compat window a retirement gets. Versioning is two deployments, not a router feature. */
34
+ export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
35
+ export { recordDeprecatedCall, renderDeprecation } from './deprecation';
29
36
  export type { QueryProblem } from './errors';
30
37
  export {
31
38
  CursorInvalidError,
39
+ CursorValueUnsupportedError,
32
40
  MatcherUnsupportedError,
33
41
  QueryDeniedError,
42
+ QueryDeprecationInvalidError,
34
43
  QueryDuplicateError,
35
44
  QueryForeignError,
36
45
  QueryInputInvalidError,
46
+ QueryInputUnencodableError,
37
47
  QueryNotPageableError,
38
48
  QueryPolicyMissingError,
39
49
  QueryRequestFailedError,
40
50
  QueryUnregisteredError,
41
51
  } from './errors';
52
+ /** The HTTP projection: `GET /_x/query/<kebab>`, the URL `client()` derives. */
53
+ export { toQueryRoute } from './http';
42
54
  export type { LiveCursor, LiveQuery, ResumeMode, ResumePlan, ToLiveOptions } from './live';
43
55
  export { advanceCursor, liveEpoch, planResume, seekOf, toLiveQuery } from './live';
44
56
  export type { ChangeEvent, ChangeOp, Patch } from './matcher';
45
57
  export { assertMatchable, match, positionFor } from './matcher';
46
58
  export type { QueryToolDescriptor, QueryToolReadOptions } from './mcp-tool';
47
59
  export { isExposed, toQueryTool, toQueryTools } from './mcp-tool';
48
- export { derivePath, toKebabCase, toToolName } from './naming';
60
+ /**
61
+ * Path derivation only. There is no `toToolName`: an MCP tool is served under the export name
62
+ * verbatim, and an exported derivation would be a second way to spell one tool.
63
+ */
64
+ export { derivePath, toKebabCase } from './naming';
49
65
  /**
50
66
  * The shapes `query.page(input, { first, after })` takes and answers with. `paginate` itself is
51
67
  * deliberately unexported: a page is the read's own answer, and a second, importable way to ask
@@ -54,7 +70,8 @@ export { derivePath, toKebabCase, toToolName } from './naming';
54
70
  */
55
71
  export type { Page, PaginateArgs } from './pagination';
56
72
  export type { QueryPolicy, QuerySubject, QuerySurface } from './policy-gate';
57
- export { actorOf, guard, policyCapability } from './policy-gate';
73
+ /** `policyCapability` is the display label; `policyPermissions` is what a report MATCHES on. */
74
+ export { actorOf, guard, policyCapability, policyPermissions } from './policy-gate';
58
75
  export type {
59
76
  AnyQuery,
60
77
  Query,
@@ -64,11 +81,13 @@ export type {
64
81
  QueryFacade,
65
82
  QueryMcp,
66
83
  QueryOptions,
84
+ QueryRateLimit,
67
85
  SourceOptions,
68
86
  } from './query';
69
87
  export { describeQuery, isQuery, nameQuery, query, queryHash } from './query';
70
88
  /** The one read path. `defOf` stays unexported — that is the enforcement. */
71
89
  export { queryName, runQuery, sourceFor } from './read';
90
+
72
91
  export {
73
92
  describeQueries,
74
93
  getQuery,
@@ -78,7 +97,19 @@ export {
78
97
  resetRegistry,
79
98
  } from './registry';
80
99
  export type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape';
81
- export { compareRows, compareValues, matchesFilter, matchesFilters, seekKeyOf } from './shape';
100
+ /**
101
+ * `isNull` is the one definition of SQL NULL a custom `SqlSource` has to agree with, and
102
+ * `totalOrder` is the one definition of the order it must serve a page in.
103
+ */
104
+ export {
105
+ compareRows,
106
+ compareValues,
107
+ isNull,
108
+ matchesFilter,
109
+ matchesFilters,
110
+ seekKeyOf,
111
+ totalOrder,
112
+ } from './shape';
82
113
  export type { RowProvider, SqlSource, SqlText } from './source';
83
114
  /** `isAfterKey` is the one definition of "after this position" — both seek paths use it. */
84
115
  export { Builder, from, isAfterKey } from './source';