@ultimat3/query 22.8.2 → 22.9.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/README.md CHANGED
@@ -59,6 +59,34 @@ on as the transport's `fetchImpl`. The transport owns `Accept`, the trace/budget
59
59
  principal fence, and the error decode — a non-`problem+json` failure (a gateway's HTML) is core's
60
60
  `X_CLIENT_TRANSPORT_FAILED`, a `problem+json` one is the server's own code, verbatim.
61
61
 
62
+ ### One object — `single: true`
63
+
64
+ A detail read declares `single: true`. The route answers the first row as the body and **404
65
+ `X_NOT_FOUND`** when `sql` returns none (a list read answers `200 []`), refuses `_first`/`_after`
66
+ (400), and `openapi.json` documents one object with a `404` and no page controls. `.client()` and
67
+ `queryClient` type it `Promise<TRow>` with no `.page`. In process nothing moves: `postById({ id })`,
68
+ `.as()`, `.page()`, `.live()` and the MCP tool still answer `readonly TRow[]`, so opting in breaks no
69
+ `[0]` already written. `As of 2026-09-29`.
70
+
71
+ ```ts
72
+ import { can } from '@ultimat3/policy';
73
+ import { from, query, queryClient, t } from '@ultimat3/query';
74
+
75
+ type Post = { readonly id: string; readonly title: string };
76
+ declare const posts: readonly Post[];
77
+
78
+ export const postById = query({
79
+ input: t.object({ id: t.uuid }),
80
+ policy: can('post:read'),
81
+ single: true,
82
+ sql: ({ id }) => from<Post>('posts', posts).where({ id }).limit(1),
83
+ });
84
+
85
+ const queries = queryClient<{ postById: typeof postById }>({ baseUrl: '' });
86
+ const post: Post = await queries.postById({ id: '018f4a1c-1b2c-7d3e-8f90-abcdef012345' }); // or X_NOT_FOUND
87
+ const rows: readonly Post[] = await postById({ id: post.id }); // in process: still the rows
88
+ ```
89
+
62
90
  ### Records — `rows:` puts a read's answer in the page's store
63
91
 
64
92
  ```ts
@@ -514,6 +542,8 @@ router feature here.
514
542
  | `X_QUERY_NOT_PAGEABLE` | a paged or live read returned a row with no `id` | select the primary key: `db.<rows>.select({ id: true, … })` |
515
543
  | `X_INPUT_INVALID` | input failed the Standard Schema | `x queries describe <name> --json` |
516
544
  | `X_QUERY_UNREGISTERED` | used before `registerQueries()` ran | register at boot |
545
+ | `X_NOT_FOUND` | a `single: true` read matched no row — the HTTP route's 404 only (entity's code) | `x queries describe <name> --json` prints the SQL |
546
+ | `X_QUERY_SINGLE_INVALID` | `single:` declared as something other than a boolean | `single: true`, or drop the key |
517
547
  | `X_QUERY_FOREIGN` | a look-alike was projected as a query | declare it with `query({ … })` |
518
548
  | `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` |
519
549
 
@@ -537,6 +567,8 @@ a job boundary the class is gone and the `code` is what survives — match on th
537
567
  | `QueryInputUnencodableError` | `X_QUERY_INPUT_UNENCODABLE` | `src/errors.ts` |
538
568
  | `QueryNotPageableError` | `X_QUERY_NOT_PAGEABLE` | `src/errors.ts` |
539
569
  | `QueryPolicyMissingError` | `X_QUERY_POLICY_MISSING` | `src/errors.ts` |
570
+ | `QueryRowNotFoundError` | `X_NOT_FOUND` | `src/errors.ts` |
571
+ | `QuerySingleInvalidError` | `X_QUERY_SINGLE_INVALID` | `src/errors.ts` |
540
572
  | `QuerySubscribesDriftError` | `X_QUERY_SUBSCRIBES_DRIFT` | `src/errors.ts` |
541
573
  | `QuerySubscribesInvalidError` | `X_QUERY_SUBSCRIBES_INVALID` | `src/errors.ts` |
542
574
  | `QueryUnregisteredError` | `X_QUERY_UNREGISTERED` | `src/errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/query",
3
- "version": "22.8.2",
3
+ "version": "22.9.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",
@@ -36,11 +36,11 @@
36
36
  "test": "bun test"
37
37
  },
38
38
  "dependencies": {
39
- "@ultimat3/cache": "22.8.2",
40
- "@ultimat3/core": "22.8.2",
41
- "@ultimat3/entity": "22.8.2",
42
- "@ultimat3/http": "22.8.2",
43
- "@ultimat3/policy": "22.8.2",
44
- "@ultimat3/schema": "22.8.2"
39
+ "@ultimat3/cache": "22.9.0",
40
+ "@ultimat3/core": "22.9.0",
41
+ "@ultimat3/entity": "22.9.0",
42
+ "@ultimat3/http": "22.9.0",
43
+ "@ultimat3/policy": "22.9.0",
44
+ "@ultimat3/schema": "22.9.0"
45
45
  }
46
46
  }
package/src/client.ts CHANGED
@@ -75,6 +75,26 @@ export interface QueryClientMethod<TInput extends StandardSchemaV1, TRow extends
75
75
  ): Promise<Page<TRow>>;
76
76
  }
77
77
 
78
+ /**
79
+ * A `single: true` read's method: the route answers the row itself, or 404 `X_NOT_FOUND` — which
80
+ * the transport rejects with, as it does every problem document. No `page`: the route refuses the
81
+ * page controls on a single read, so the method a list read carries would be a guaranteed 400.
82
+ */
83
+ export type QuerySingleClientMethod<TInput extends StandardSchemaV1, TRow extends object> = (
84
+ input: InferInput<TInput>,
85
+ options?: QueryCallOptions,
86
+ ) => Promise<TRow>;
87
+
88
+ /**
89
+ * The method a read's declaration projects to. DISTRIBUTIVE on purpose: for `boolean` it is the
90
+ * union, so a `Query<I, R, true>` is still assignable where `Query<I, R, boolean>` is asked for.
91
+ */
92
+ export type QueryClientMethodOf<
93
+ TInput extends StandardSchemaV1,
94
+ TRow extends object,
95
+ TSingle extends boolean,
96
+ > = TSingle extends true ? QuerySingleClientMethod<TInput, TRow> : QueryClientMethod<TInput, TRow>;
97
+
78
98
  /**
79
99
  * Loose constraint on purpose: a map of concrete `Query<TInput, TRow>` values must be
80
100
  * assignable to it, while `QueryClient<T>` still recovers each read's own input schema and
@@ -89,8 +109,12 @@ export type QueryMap = Record<string, QueryLike>;
89
109
 
90
110
  /** `queries.publicPost({ slug })`, with the input schema and the row type both inferred. */
91
111
  export type QueryClient<TQueries extends QueryMap> = {
92
- readonly [K in keyof TQueries]: TQueries[K] extends Query<infer TInput, infer TRow>
93
- ? QueryClientMethod<TInput, TRow>
112
+ readonly [K in keyof TQueries]: TQueries[K] extends Query<
113
+ infer TInput,
114
+ infer TRow,
115
+ infer TSingle extends boolean
116
+ >
117
+ ? QueryClientMethodOf<TInput, TRow, TSingle>
94
118
  : never;
95
119
  };
96
120
 
package/src/errors.ts CHANGED
@@ -27,15 +27,17 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
27
27
  X_QUERY_SUBSCRIBES_DRIFT: 'a live query subscribes to a relation its own sql never reads',
28
28
  X_QUERY_SUBSCRIBES_INVALID: 'a query declares subscribed relations no live read can use',
29
29
  X_QUERY_UNREGISTERED: 'a query was used before it was registered',
30
+ X_QUERY_SINGLE_INVALID: 'a query declares single: as something other than a boolean',
30
31
  };
31
32
 
32
33
  /**
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
34
+ * Codes another package owns that this one only throws. `X_INPUT_INVALID` describes an action's
35
+ * job — enforcing an input schema — and `X_NOT_FOUND` is `@ultimat3/entity`'s "no row for that
36
+ * id", which a `single: true` read answers over HTTP. The owner declares the title and query never
37
+ * re-declares it: two copies of a title are two titles, one of which is stale. `X_RPC_FAILED` left this list in
36
38
  * 21.0.0: the typed read client's wire failures are `@ultimat3/core`'s `clientTransport`'s now.
37
39
  */
38
- export const QUERY_BORROWED_ERROR_CODES = ['X_INPUT_INVALID'] as const;
40
+ export const QUERY_BORROWED_ERROR_CODES = ['X_INPUT_INVALID', 'X_NOT_FOUND'] as const;
39
41
 
40
42
  // One unconditional call: a presence guard would turn "another package claims one of these codes"
41
43
  // from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
@@ -324,3 +326,28 @@ export class QueryInputInvalidError extends UltimateError {
324
326
  });
325
327
  }
326
328
  }
329
+
330
+ /**
331
+ * A `single: true` read matched no row. The HTTP route's answer only — every in-process caller of
332
+ * the same read gets `[]`, as it always has. `@ultimat3/entity`'s code, so the status (404) and the
333
+ * title are the ones a repo's `findById` miss already answers, not a second "not found".
334
+ */
335
+ export class QueryRowNotFoundError extends UltimateError {
336
+ constructor(name: string) {
337
+ super({
338
+ code: 'X_NOT_FOUND',
339
+ cause: `query "${name}" is declared single: true and its sql matched no row for this input`,
340
+ fix: `x queries describe ${name} --json # prints the SQL — a stale id, another tenant's row or a soft-deleted one matches nothing`,
341
+ });
342
+ }
343
+ }
344
+
345
+ export class QuerySingleInvalidError extends UltimateError {
346
+ constructor(value: unknown) {
347
+ super({
348
+ code: 'X_QUERY_SINGLE_INVALID',
349
+ cause: `a query declares single: as ${typeof value}, and only a boolean says whether it reads one object`,
350
+ fix: 'write single: true for a read of one object, or drop the key for a list read',
351
+ });
352
+ }
353
+ }
package/src/facade.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { StandardSchemaV1 } from '@ultimat3/schema';
10
+ import type { QueryClientMethodOf } from './client';
10
11
  import { queryClientMethodFor } from './client';
11
12
  import { toLiveQuery } from './live';
12
13
  import { toQueryTool } from './mcp-tool';
@@ -18,13 +19,19 @@ import { queryName, runQuery } from './read';
18
19
  * `self` is a thunk on purpose: the façade is attached while the query is still
19
20
  * being assembled, so every method resolves the query when it is called, not now.
20
21
  */
21
- export function facadeFor<TInput extends StandardSchemaV1, TRow extends object>(
22
- def: QueryDef<TInput, TRow>,
23
- self: () => Query<TInput, TRow>,
24
- ): QueryFacade<TInput, TRow> {
22
+ export function facadeFor<
23
+ TInput extends StandardSchemaV1,
24
+ TRow extends object,
25
+ TSingle extends boolean,
26
+ >(
27
+ def: QueryDef<TInput, TRow, TSingle>,
28
+ self: () => Query<TInput, TRow, TSingle>,
29
+ ): QueryFacade<TInput, TRow, TSingle> {
25
30
  return {
26
31
  input: def.input,
27
32
  policy: def.policy,
33
+ // Only when declared, like every lifted field: a list read's surface is unchanged.
34
+ ...(def.single === undefined ? {} : { single: def.single }),
28
35
  ...(def.subscribes === undefined ? {} : { subscribes: def.subscribes }),
29
36
  ...(def.rows === undefined ? {} : { rows: def.rows }),
30
37
  ...(def.cache === undefined ? {} : { cache: def.cache }),
@@ -39,6 +46,13 @@ export function facadeFor<TInput extends StandardSchemaV1, TRow extends object>(
39
46
  page: (input, args) => paginate(self(), input, args),
40
47
  live: (input, options) => toLiveQuery(self(), input, options),
41
48
  tool: () => toQueryTool(self()),
42
- client: (options) => queryClientMethodFor(queryName(self()), options),
49
+ // One implementation for both wire shapes: the route decides the body, the transport parses
50
+ // it, and only the TYPE differs — a single read's method answers the row, and has no `page`.
51
+ client: (options) =>
52
+ queryClientMethodFor<TInput, TRow>(queryName(self()), options) as QueryClientMethodOf<
53
+ TInput,
54
+ TRow,
55
+ TSingle
56
+ >,
43
57
  };
44
58
  }
package/src/http.ts CHANGED
@@ -11,13 +11,17 @@ import { toBucket } from '@ultimat3/http';
11
11
  import { coerceQuery } from '@ultimat3/schema';
12
12
  import type { Deprecation } from './deprecation';
13
13
  import { recordDeprecatedCall, renderDeprecation } from './deprecation';
14
- import { QueryDeprecationInvalidError } from './errors';
14
+ import {
15
+ QueryDeprecationInvalidError,
16
+ QueryInputInvalidError,
17
+ QueryRowNotFoundError,
18
+ } from './errors';
15
19
  import { derivePath } from './naming';
16
- import { pageControlsOf } from './page-controls';
20
+ import { PAGE_FIRST_KEY, pageControlsOf } from './page-controls';
17
21
  import { admitsAnonymous, policyCapability } from './policy-gate';
18
22
  import type { AnyQuery } from './query';
19
23
  import { queryName, runQuery } from './read';
20
- import { recordAnswerFor } from './record-answer';
24
+ import { recordAnswerFor, recordRowAnswerFor } from './record-answer';
21
25
 
22
26
  /**
23
27
  * `liveFeed` -> `GET /_x/query/live-feed`. Named for the primitive rather than spelled
@@ -31,6 +35,7 @@ export function toQueryRoute(target: AnyQuery): Route {
31
35
  // not a surprise on the first read.
32
36
  const sunsetting = deprecationHeadersFor(name, target.deprecated);
33
37
  const answer = recordAnswerFor(target.rows);
38
+ const answerRow = recordRowAnswerFor(target.rows);
34
39
 
35
40
  const handler = async (request: UltimateRequest): Promise<Response> => {
36
41
  if (sunsetting !== undefined) {
@@ -59,6 +64,21 @@ export function toQueryRoute(target: AnyQuery): Route {
59
64
  // read's, and a schema that refused unknown keys would otherwise refuse every paged call.
60
65
  const { input: values, page } = pageControlsOf(name, request.queryRaw());
61
66
  const input = coerceQuery(target.input, values);
67
+ if (target.single === true) {
68
+ // Refused, not ignored: a caller paging a read of one object has the wrong read in mind, and
69
+ // a silently dropped `_first` would answer a shape it did not ask for.
70
+ if (page !== undefined) {
71
+ throw new QueryInputInvalidError(
72
+ name,
73
+ `${PAGE_FIRST_KEY} pages a list, and this read is declared single: true — it answers one row, or 404`,
74
+ );
75
+ }
76
+ // The first row — what every in-process `[0]` of the same read already takes. None is the
77
+ // 404 a detail URL means, where a list read answers `200 []`.
78
+ const [row] = await runQuery(target, input, { surface: 'http' });
79
+ if (row === undefined) throw new QueryRowNotFoundError(name);
80
+ return answerRow(row);
81
+ }
62
82
  // With a page control the answer is the `Page` envelope `query.page()` answers a server
63
83
  // caller with — `{ rows, endCursor, hasNextPage }`, the same names, so a cursor read off the
64
84
  // wire and one read off a direct call are the same string in the same field. Without one the
package/src/index.ts CHANGED
@@ -57,9 +57,11 @@ export type {
57
57
  QueryCallOptions,
58
58
  QueryClient,
59
59
  QueryClientMethod,
60
+ QueryClientMethodOf,
60
61
  QueryClientOptions,
61
62
  QueryLike,
62
63
  QueryMap,
64
+ QuerySingleClientMethod,
63
65
  } from './client';
64
66
  /** `queryClient` is the map-wide read client; `queryClientMethodFor` is what `.client()` binds. */
65
67
  export { queryClient, queryClientMethodFor } from './client';
@@ -79,6 +81,8 @@ export {
79
81
  QueryInputUnencodableError,
80
82
  QueryNotPageableError,
81
83
  QueryPolicyMissingError,
84
+ QueryRowNotFoundError,
85
+ QuerySingleInvalidError,
82
86
  QuerySubscribesDriftError,
83
87
  QuerySubscribesInvalidError,
84
88
  QueryUnregisteredError,
package/src/live.ts CHANGED
@@ -108,7 +108,7 @@ export function liveEpoch(): string {
108
108
  }
109
109
 
110
110
  export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends object>(
111
- target: Query<TInput, TRow>,
111
+ target: Query<TInput, TRow, boolean>,
112
112
  input: unknown,
113
113
  options: ToLiveOptions = {},
114
114
  ): Promise<LiveQuery> {
package/src/openapi.ts CHANGED
@@ -76,6 +76,7 @@ export function queryOpenApiPaths(
76
76
  */
77
77
  export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknown> {
78
78
  const name = queryName(target);
79
+ if (target.single === true) return singleOperation(target, name);
79
80
  return {
80
81
  operationId: name,
81
82
  tags: [QUERY_TAG],
@@ -116,6 +117,46 @@ export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknow
116
117
  };
117
118
  }
118
119
 
120
+ /**
121
+ * A `single: true` read: one object, a 404 when there is none, and no page controls — the route
122
+ * refuses them, so advertising them would document a guaranteed 400. Everything else — the input
123
+ * parameters, the record envelope, the `x-ultimate` block — is the list operation's, unchanged.
124
+ */
125
+ function singleOperation(target: AnyQuery, name: string): Record<string, unknown> {
126
+ const records = answersRecords(target.rows);
127
+ return {
128
+ operationId: name,
129
+ tags: [QUERY_TAG],
130
+ summary: target.mcp?.description ?? name,
131
+ ...(target.deprecated === undefined ? {} : { deprecated: true }),
132
+ parameters: inputParameters(target.input),
133
+ responses: {
134
+ '200': {
135
+ description: 'ok',
136
+ content: {
137
+ 'application/json': {
138
+ schema: records ? recordEnvelopeSchema(SINGLE_ANSWER) : SINGLE_ANSWER,
139
+ },
140
+ },
141
+ ...(records ? { headers: RECORDS_OPENAPI_HEADER } : {}),
142
+ },
143
+ '400': problemResponse('X_INPUT_INVALID'),
144
+ '403': problemResponse('policy denied'),
145
+ '404': problemResponse('X_NOT_FOUND — the read matched no row for this input'),
146
+ },
147
+ 'x-ultimate': {
148
+ capability: policyCapability(target.policy),
149
+ live: target.isLive,
150
+ single: true,
151
+ cacheTags: tagKeys(target.cache?.tags ?? []),
152
+ tool: isExposed(target) ? name : null,
153
+ },
154
+ };
155
+ }
156
+
157
+ /** One row. Its members are not published — a query declares no output schema, as `ANSWERS` says. */
158
+ const SINGLE_ANSWER: Readonly<Record<string, unknown>> = { type: 'object' };
159
+
119
160
  /** The two controls, spelled from `page-controls.ts` so the document cannot drift from the route. */
120
161
  const PAGE_PARAMETERS: readonly Record<string, unknown>[] = [
121
162
  {
package/src/pagination.ts CHANGED
@@ -36,7 +36,7 @@ export interface PaginateArgs extends SourceOptions {
36
36
  * sliced after execution and the source is doing more work than it should.
37
37
  */
38
38
  export async function paginate<TInput extends StandardSchemaV1, TRow extends object>(
39
- target: Query<TInput, TRow>,
39
+ target: Query<TInput, TRow, boolean>,
40
40
  input: unknown,
41
41
  args: PaginateArgs,
42
42
  ): Promise<Page<TRow>> {
package/src/query.ts CHANGED
@@ -12,9 +12,9 @@ import type { Actor, Ctx } from '@ultimat3/core';
12
12
  import { fingerprint } from '@ultimat3/core';
13
13
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
14
14
  import type { QueryCacheScope } from './cache';
15
- import type { QueryClientMethod, QueryClientOptions } from './client';
15
+ import type { QueryClientMethodOf, QueryClientOptions } from './client';
16
16
  import type { Deprecation } from './deprecation';
17
- import { QueryCacheTtlInvalidError } from './errors';
17
+ import { QueryCacheTtlInvalidError, QuerySingleInvalidError } from './errors';
18
18
  import { facadeFor } from './facade';
19
19
  import { assertEncodableInput } from './input-shape';
20
20
  import type { LiveQuery, ToLiveOptions } from './live';
@@ -86,11 +86,25 @@ export interface QueryRateLimit {
86
86
  readonly windowMs: number;
87
87
  }
88
88
 
89
- export interface QueryDef<TInput extends StandardSchemaV1, TRow extends object> {
89
+ export interface QueryDef<
90
+ TInput extends StandardSchemaV1,
91
+ TRow extends object,
92
+ TSingle extends boolean = boolean,
93
+ > {
90
94
  readonly input: TInput;
91
95
  readonly policy: QueryPolicy;
92
96
  /** `true` makes the read subscribable — see `toLiveQuery`. */
93
97
  readonly live?: boolean;
98
+ /**
99
+ * `true` declares a read of ONE object — a detail page's row by id or slug. Only the WIRE
100
+ * changes: `GET /_x/query/<name>` answers that row itself (the first one `sql` returns), and
101
+ * **404 `X_NOT_FOUND`** when `sql` returns none, where a list read answers `200 []`; the OpenAPI
102
+ * operation documents one object and no `_first`/`_after`, which the route refuses (400). The
103
+ * typed client answers `Promise<TRow>`. Every in-process caller — `read(input)`, `.as()`,
104
+ * `.page()`, `.live()` and the MCP tool — keeps the rows it always had, so opting in breaks no
105
+ * `[0]` a server file already wrote. Omitted is `false`: a list.
106
+ */
107
+ readonly single?: TSingle;
94
108
  /**
95
109
  * The relations this live read is patched from — the tables `x db gen` grants
96
110
  * `REPLICA IDENTITY FULL`, without which logical replication carries no old row on an UPDATE
@@ -205,6 +219,7 @@ export interface AnyQueryDef {
205
219
  readonly input: StandardSchemaV1;
206
220
  readonly policy: QueryPolicy;
207
221
  readonly live?: boolean;
222
+ readonly single?: boolean;
208
223
  readonly subscribes?: readonly string[];
209
224
  readonly rows?: StandardSchemaV1;
210
225
  sql(input: unknown, ctx: Ctx): SqlSource<object>;
@@ -219,6 +234,8 @@ export interface AnyQuery {
219
234
  readonly name: string;
220
235
  /** Declared `live: true`. The subscription itself is `live()`. */
221
236
  readonly isLive: boolean;
237
+ /** Declared `single: true` — the route answers one object or 404. Absent is a list read. */
238
+ readonly single?: boolean;
222
239
  /** The declaration, minus `sql`: readable, and never a way to run it. */
223
240
  readonly input: StandardSchemaV1;
224
241
  readonly policy: QueryPolicy;
@@ -245,11 +262,14 @@ export interface AnyQuery {
245
262
  export interface Query<
246
263
  TInput extends StandardSchemaV1 = StandardSchemaV1,
247
264
  TRow extends object = Record<string, unknown>,
265
+ TSingle extends boolean = false,
248
266
  > extends AnyQuery {
249
267
  /** Callable server-side with the same types the client and the MCP tool see. */
250
268
  (input: InferInput<TInput>, options?: QueryOptions): Promise<readonly TRow[]>;
251
269
  readonly input: TInput;
252
- named(name: string): Query<TInput, TRow>;
270
+ /** The declaration, as a type: what `.client()` and `queryClient` read the wire shape off. */
271
+ readonly single?: TSingle;
272
+ named(name: string): Query<TInput, TRow, TSingle>;
253
273
  as(
254
274
  actor: Actor | null,
255
275
  input: InferInput<TInput>,
@@ -261,14 +281,19 @@ export interface Query<
261
281
  * Typed against this query's input and row type, which is the whole point of it —
262
282
  * so it lives here and not on the schema-erased `AnyQuery` view.
263
283
  */
264
- client(options: QueryClientOptions): QueryClientMethod<TInput, TRow>;
284
+ client(options: QueryClientOptions): QueryClientMethodOf<TInput, TRow, TSingle>;
265
285
  }
266
286
 
267
287
  /** The fluent half of a query: lifted declaration plus one method per projection. */
268
- export type QueryFacade<TInput extends StandardSchemaV1, TRow extends object> = Pick<
269
- Query<TInput, TRow>,
288
+ export type QueryFacade<
289
+ TInput extends StandardSchemaV1,
290
+ TRow extends object,
291
+ TSingle extends boolean = false,
292
+ > = Pick<
293
+ Query<TInput, TRow, TSingle>,
270
294
  | 'input'
271
295
  | 'policy'
296
+ | 'single'
272
297
  | 'subscribes'
273
298
  | 'rows'
274
299
  | 'cache'
@@ -282,15 +307,22 @@ export type QueryFacade<TInput extends StandardSchemaV1, TRow extends object> =
282
307
  | 'client'
283
308
  >;
284
309
 
285
- export function query<TInput extends StandardSchemaV1, TRow extends object>(
286
- def: QueryDef<TInput, TRow>,
287
- ): Query<TInput, TRow> {
310
+ export function query<
311
+ TInput extends StandardSchemaV1,
312
+ TRow extends object,
313
+ TSingle extends boolean = false,
314
+ >(def: QueryDef<TInput, TRow, TSingle>): Query<TInput, TRow, TSingle> {
288
315
  // Here and not in `toQueryRoute`: a read is projected to `GET /_x/query/<kebab>` whether or not
289
316
  // anyone mounts it, and the typed client derives that same URL — so an input a query string
290
317
  // cannot carry is wrong for every call, and the file that declared it is where it is repaired.
291
318
  assertEncodableInput(def.input);
292
319
  assertCacheTtl(def.cache);
293
320
  assertSubscribes(def.subscribes, def.live);
321
+ // A JS caller or generated code can write `single: 'yes'`; truthiness would read it as `true`
322
+ // on the route and `false` in the type, so anything but a boolean is refused where it is written.
323
+ if (def.single !== undefined && typeof def.single !== 'boolean') {
324
+ throw new QuerySingleInvalidError(def.single);
325
+ }
294
326
  // Snapshot and freeze BEFORE registration. `build()` stores `def` and `facadeFor()` exposes the
295
327
  // same array, so a caller holding the literal it passed could mutate the list AFTER validation
296
328
  // — and that list is what the manifest publishes and what `x db gen` grants REPLICA IDENTITY
@@ -335,20 +367,20 @@ export function nameQuery<Q extends AnyQuery>(target: Q, name: string): Q {
335
367
  return target;
336
368
  }
337
369
 
338
- function build<TInput extends StandardSchemaV1, TRow extends object>(
339
- def: QueryDef<TInput, TRow>,
370
+ function build<TInput extends StandardSchemaV1, TRow extends object, TSingle extends boolean>(
371
+ def: QueryDef<TInput, TRow, TSingle>,
340
372
  name: string,
341
- ): Query<TInput, TRow> {
373
+ ): Query<TInput, TRow, TSingle> {
342
374
  const callable = (
343
375
  input: InferInput<TInput>,
344
376
  options: QueryOptions = {},
345
377
  ): Promise<readonly TRow[]> => runQuery(self, input, options);
346
378
 
347
- const self: Query<TInput, TRow> = Object.assign(callable, {
379
+ const self: Query<TInput, TRow, TSingle> = Object.assign(callable, {
348
380
  kind: 'query' as const,
349
381
  isLive: def.live === true,
350
382
  describe: (): QueryDescriptor => describeQuery(self),
351
- named: (next: string): Query<TInput, TRow> => build(def, next),
383
+ named: (next: string): Query<TInput, TRow, TSingle> => build(def, next),
352
384
  ...facadeFor(def, () => self),
353
385
  });
354
386
  // `name` on a function is non-writable, so Object.assign cannot set it.
package/src/read.ts CHANGED
@@ -65,7 +65,7 @@ export function queryName(target: AnyQuery): string {
65
65
 
66
66
  /** Validate, authorize, then read — the same three steps on every surface. */
67
67
  export function runQuery<TInput extends StandardSchemaV1, TRow extends object>(
68
- target: Query<TInput, TRow>,
68
+ target: Query<TInput, TRow, boolean>,
69
69
  raw: unknown,
70
70
  options?: QueryOptions,
71
71
  ): Promise<readonly TRow[]>;
@@ -30,6 +30,17 @@ export function recordAnswerFor(rows: StandardSchemaV1 | undefined): RecordAnswe
30
30
  };
31
31
  }
32
32
 
33
+ /**
34
+ * The same decision for a `single: true` read, whose answer is ONE row: the row itself, or the
35
+ * envelope with that row as `data` and its records beside it — the shape an action's record wire
36
+ * answers for a single output, so the transport unwraps both the same way.
37
+ */
38
+ export function recordRowAnswerFor(rows: StandardSchemaV1 | undefined): (row: object) => Response {
39
+ if (!answersRecords(rows)) return (row) => json(row);
40
+ return (row) =>
41
+ json(encodeRecordEnvelope(row, collect(rows, [row])), { headers: { [RECORDS_HEADER]: '1' } });
42
+ }
43
+
33
44
  /** The one predicate the route and the OpenAPI projection both ask: can this read answer records? */
34
45
  export function answersRecords(rows: StandardSchemaV1 | undefined): rows is StandardSchemaV1 {
35
46
  return rows !== undefined && hasEntityRows(rows);
package/src/sql.ts CHANGED
@@ -22,7 +22,7 @@ export interface ExplainResult extends SqlText {
22
22
  * surfaces that expose it (`/_x`, the CLI) are admin-gated in their own right.
23
23
  */
24
24
  export async function explain<TInput extends StandardSchemaV1, TRow extends object>(
25
- target: Query<TInput, TRow>,
25
+ target: Query<TInput, TRow, boolean>,
26
26
  input: unknown,
27
27
  ctx?: Ctx,
28
28
  ): Promise<ExplainResult> {