@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.
@@ -0,0 +1,74 @@
1
+ // Single responsibility: what a read's `input:` may be, given that a read is projected to
2
+ // `GET /_x/query/<kebab>` and a query string is characters.
3
+ //
4
+ // `client.ts` encoded a nested member as `JSON.stringify(item)` and skipped a `null`; the server's
5
+ // own route decodes with `coerceQuery`, which has no inverse for either — `case 'object'` hands
6
+ // the raw value back untouched and there is no `JSON.parse` on that path. So the typed client
7
+ // type-checked calls the server then answered `X_INPUT_INVALID` for, which is precisely the
8
+ // failure `client.ts`'s header claims to prevent ("a compile error in a Solid component rather
9
+ // than a 404 at runtime").
10
+ //
11
+ // The fix is the DECLARATION, not the encoder. Teaching `coerceQuery` to `JSON.parse` a string
12
+ // would make the one HTTP-boundary decoder invent structure for every surface that shares it —
13
+ // forms and route params included — against that file's own rule that it "never invents data";
14
+ // and a `null` sentinel would be a reserved string colliding with the legitimate value `"null"`.
15
+ // Refusing here means the client can never encode something the server rejects, because such an
16
+ // input cannot be written.
17
+
18
+ import { type SchemaNode, tryIntrospect } from '@ultimat3/schema';
19
+ import { QueryInputUnencodableError } from './errors';
20
+
21
+ /** Node kinds whose value is a structure, not characters. `money` is `{ minor, currency }`. */
22
+ const STRUCTURAL = new Set(['object', 'record', 'money']);
23
+
24
+ /**
25
+ * The first key this input could not put on a wire, or `undefined`. One key, not a list: a fix
26
+ * line names one edit, and the next declaration attempt reports the next key.
27
+ */
28
+ function unencodable(node: SchemaNode): string | undefined {
29
+ for (const [key, child] of Object.entries(node.properties ?? {})) {
30
+ // Required AND nullable: `searchOf` sends nothing for a `null`, and absence is what the far
31
+ // side then sees — so the value the caller explicitly chose fails validation on arrival.
32
+ // Optional or defaulted, absence is already the schema's own answer and nothing is lost.
33
+ if (child.nullable === true && child.optional !== true && child.hasDefault !== true) {
34
+ return `${key} is nullable and required`;
35
+ }
36
+ const kind = structuralKind(child);
37
+ if (kind !== undefined) return `${key} is a ${kind}`;
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ /** The structural kind inside a member, through an array or a union. */
43
+ function structuralKind(node: SchemaNode): string | undefined {
44
+ if (STRUCTURAL.has(node.kind)) return node.kind;
45
+ if (node.kind === 'array' && node.items !== undefined) return structuralKind(node.items);
46
+ if (node.kind === 'union') {
47
+ for (const member of node.anyOf ?? []) {
48
+ const kind = structuralKind(member);
49
+ if (kind !== undefined) return kind;
50
+ }
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ /**
56
+ * Refused at `query()`, which is the first import of the authoring file — the same place
57
+ * `@ultimat3/schema` refuses a `discriminatedUnion` it could never route, and for the same reason:
58
+ * the declaration is wrong for every input, so the earliest honest moment is where it is written.
59
+ *
60
+ * A schema this package cannot introspect is left alone rather than guessed at: `tryIntrospect`
61
+ * answering `undefined` means a foreign Standard Schema, and refusing one would make the seam
62
+ * `configureSchemaProvider` exists for unusable.
63
+ */
64
+ export function assertEncodableInput(input: unknown): void {
65
+ const node = tryIntrospect(input);
66
+ if (node === undefined) return;
67
+ if (node.kind !== 'object') {
68
+ // `searchOf` answers `''` for anything that is not a plain object, so the server receives no
69
+ // arguments at all — a read declared this way is called with an input nothing ever carries.
70
+ throw new QueryInputUnencodableError(`the input is a ${node.kind}, not an object`);
71
+ }
72
+ const offender = unencodable(node);
73
+ if (offender !== undefined) throw new QueryInputUnencodableError(offender);
74
+ }
package/src/live.ts CHANGED
@@ -2,8 +2,11 @@
2
2
  * What `live: true` actually produces: the descriptor @ultimat3/realtime
3
3
  * subscribes to. It carries the SQL shape (for the matcher), the dependency set
4
4
  * (which entities/tags a change feed must touch to matter), the policy (re-run
5
- * per subscriber, never once for a channel) and a cursor for cheap reconnects.
5
+ * per subscriber, never once for a channel) and a cursor for cheap reconnects
6
+ * and it runs that read (`execute`), so the shared window and the matcher over it
7
+ * come from one build of one `(query, input)` rather than two that agree by luck.
6
8
  */
9
+ import { tagKeys } from '@ultimat3/cache';
7
10
  import type { Ctx } from '@ultimat3/core';
8
11
  import type { StandardSchemaV1 } from '@ultimat3/schema';
9
12
  import type { Patch } from './matcher';
@@ -15,7 +18,6 @@ import { queryHash } from './query';
15
18
  import { queryName, sourceFor } from './read';
16
19
  import type { QueryShape, SeekKey } from './shape';
17
20
  import { seekKeyOf } from './shape';
18
- import { tagKeys } from './tags';
19
21
 
20
22
  /**
21
23
  * Reconnect state. Deliberately tiny — an id, a sort key and a version — because
@@ -58,6 +60,17 @@ export interface LiveQuery {
58
60
  readonly policy: QueryPolicy;
59
61
  readonly sqlText: string;
60
62
  readonly limit: number | null;
63
+ /**
64
+ * The read this descriptor describes, run. It is the *same* source the shape, the reads and the
65
+ * SQL text were taken from, which is the point: a subscriber's window and the matcher that
66
+ * patches it have to come from one build of one `(query, input)`. A caller that wanted rows and
67
+ * called `sourceFor` itself would be a second build — twice the parse, twice the `sql()`, and
68
+ * two descriptions of one read that are only equal by luck.
69
+ *
70
+ * It executes on every call rather than memoising: a client joining an existing subscription
71
+ * must see the rows as they are now, not the window someone else opened.
72
+ */
73
+ execute(): Promise<readonly object[]>;
61
74
  /** Per-subscriber authorization. Called on subscribe *and* on every fanout. */
62
75
  authorize(subject: QuerySubject): Promise<void>;
63
76
  initialCursor(rows: readonly object[]): LiveCursor;
@@ -81,6 +94,13 @@ export interface ToLiveOptions {
81
94
  readonly enforce?: boolean;
82
95
  }
83
96
 
97
+ /**
98
+ * Why a shared window builds with no policy evaluation. Spelled once, here, so the reason a sync
99
+ * node gives `sourceFor` cannot drift from the one `ToLiveOptions.enforce` documents.
100
+ */
101
+ const SHARED_WINDOW_REASON =
102
+ 'the shared subject-less window has no subscriber to decide about; authorize() runs per subscriber';
103
+
84
104
  /** Changing the build changes the epoch, which forces reconnects to refetch. */
85
105
  export function liveEpoch(): string {
86
106
  return Bun.env['X_BUILD_ID'] ?? 'dev';
@@ -94,7 +114,10 @@ export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends
94
114
  const name = queryName(target);
95
115
  const source = await sourceFor(target, input, {
96
116
  ...(options.ctx === undefined ? {} : { ctx: options.ctx }),
97
- ...(options.enforce === undefined ? {} : { enforce: options.enforce }),
117
+ // The boolean stays this layer's spelling because it has exactly ONE reason, stated on
118
+ // `ToLiveOptions.enforce` above; `sourceFor` takes that reason as a string so every skipped
119
+ // policy in the framework is greppable with its justification attached.
120
+ ...(options.enforce === false ? { unenforced: SHARED_WINDOW_REASON } : {}),
98
121
  surface: 'live',
99
122
  });
100
123
  const shape = source.shape();
@@ -115,6 +138,7 @@ export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends
115
138
  policy,
116
139
  sqlText: source.toSQL().sql,
117
140
  limit: shape.limit,
141
+ execute: () => source.execute(),
118
142
  authorize: async (subject) => {
119
143
  guard(policy, subject, 'live');
120
144
  },
package/src/matcher.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  * filters + orderBy + limit; anything else throws X_MATCHER_UNSUPPORTED, because
5
5
  * an honest refusal beats a silently wrong result set.
6
6
  */
7
- import { MatcherUnsupportedError } from './errors';
7
+ import { MatcherUnsupportedError, QueryNotPageableError } from './errors';
8
8
  import type { QueryShape } from './shape';
9
- import { compareRows, matchesFilters } from './shape';
9
+ import { compareRows, matchesFilters, totalOrder } from './shape';
10
10
  import { columnOf } from './stable';
11
11
 
12
12
  export type ChangeOp = 'insert' | 'update' | 'delete';
@@ -55,13 +55,13 @@ export function match<TRow extends object>(
55
55
  assertMatchable(name, shape);
56
56
  if (event.entity !== shape.entity) return [];
57
57
 
58
- const id = idOf(event.row);
59
- const index = rows.findIndex((row) => idOf(row) === id);
58
+ const id = idOf(event.row, shape.entity);
59
+ const index = rows.findIndex((row) => idOf(row, shape.entity) === id);
60
60
  const inSet = index >= 0;
61
61
  const belongs = event.op !== 'delete' && matchesFilters(event.row, shape.filters);
62
62
 
63
63
  if (event.op === 'delete' || (inSet && !belongs)) {
64
- return inSet ? removeAt(shape, index, id, true) : [];
64
+ return inSet ? removeAt(shape, index, id, true, rows.length) : [];
65
65
  }
66
66
  if (!belongs) return [];
67
67
  if (!inSet) return insert(shape, rows, event.row);
@@ -74,9 +74,22 @@ export function match<TRow extends object>(
74
74
  compareRows(event.row, current, shape.orderBy) !== 0;
75
75
  if (!moved) return [{ kind: 'update', position: index, row: event.row }];
76
76
 
77
- // A move keeps the window full, so it never needs a refill.
78
77
  const without = [...rows.slice(0, index), ...rows.slice(index + 1)];
79
- return [...removeAt<TRow>(shape, index, id, false), ...insert(shape, without, event.row)];
78
+ // A move to the TAIL of a full window is a position only the server can fill. `insert()` places
79
+ // the row among the `limit - 1` rows the client still holds, so its position can never reach
80
+ // `shape.limit` and the `position >= shape.limit` bail below is unreachable on this path — the
81
+ // row was re-inserted INSIDE the window. Proven with `limit: 3`, window `[a:1, b:2, c:3]` and a
82
+ // server also holding `d:4, e:5`: moving `a` to `99` rendered `[b, c, a:99]` where the true
83
+ // window is `[b, c, d]`. Whether the moved row is still in the window is the server's answer
84
+ // too, so the refill covers both.
85
+ const wasFull = shape.limit !== null && rows.length >= shape.limit;
86
+ if (wasFull && positionFor(shape, without, event.row) >= without.length) {
87
+ return removeAt<TRow>(shape, index, id, true, rows.length);
88
+ }
89
+ return [
90
+ ...removeAt<TRow>(shape, index, id, false, rows.length),
91
+ ...insert(shape, without, event.row),
92
+ ];
80
93
  }
81
94
 
82
95
  function insert<TRow extends object>(
@@ -91,36 +104,66 @@ function insert<TRow extends object>(
91
104
  if (shape.limit !== null && rows.length >= shape.limit) {
92
105
  const evicted = rows[shape.limit - 1];
93
106
  if (evicted !== undefined) {
94
- patches.push({ kind: 'remove', position: shape.limit, id: idOf(evicted) });
107
+ patches.push({ kind: 'remove', position: shape.limit, id: idOf(evicted, shape.entity) });
95
108
  }
96
109
  }
97
110
  return patches;
98
111
  }
99
112
 
113
+ /**
114
+ * `held` is how many rows the window actually holds, and it is the whole condition on the refill.
115
+ *
116
+ * A refill says the tail is unknown to the client, and it is answered by a full re-read: the bridge
117
+ * folds it into `BridgeResult.refill`, and the fanout then sends NO patch frame that round —
118
+ * suppressing the `remove` beside it and leaving a deleted row on screen until the next change to
119
+ * the same query. A window under `limit` has no unknown tail: the source served fewer rows than it
120
+ * was allowed to, so what the client holds IS the result set. Unconditional, this also named a
121
+ * position no result set has — `limit: 50` over three rows emitted `{ refill, from: 49 }`.
122
+ */
100
123
  function removeAt<TRow extends object>(
101
124
  shape: QueryShape,
102
125
  index: number,
103
126
  id: string,
104
127
  refill: boolean,
128
+ held: number,
105
129
  ): readonly Patch<TRow>[] {
106
130
  const patches: Patch<TRow>[] = [{ kind: 'remove', position: index, id }];
107
- // A limited window may now be one row short, and the tail lives on the server.
108
- if (refill && shape.limit !== null) patches.push({ kind: 'refill', from: shape.limit - 1 });
131
+ // A window that WAS full is now one row short, and that row lives on the server.
132
+ if (refill && shape.limit !== null && held >= shape.limit) {
133
+ patches.push({ kind: 'refill', from: shape.limit - 1 });
134
+ }
109
135
  return patches;
110
136
  }
111
137
 
112
- /** Insertion index under the query's ordering; append when the query is unordered. */
138
+ /**
139
+ * Insertion index under the ordering the source serves — `totalOrder`, not the declared keys.
140
+ *
141
+ * A page arrives as `order by <declared keys>, "id" asc` and `isAfterKey` reads the next one the
142
+ * same way, so a row tied on every declared key belongs where its id puts it. Placing it at the
143
+ * end of the tie group instead is a position the database would never return: the client renders
144
+ * one order, a re-read answers another, and the cursor cut from the window's tail skips the ties
145
+ * the matcher pushed past it.
146
+ *
147
+ * An unordered query has no position to get wrong — SQL promises none — so it appends.
148
+ */
113
149
  export function positionFor<TRow extends object>(
114
150
  shape: QueryShape,
115
151
  rows: readonly TRow[],
116
152
  row: TRow,
117
153
  ): number {
118
154
  if (shape.orderBy.length === 0) return rows.length;
119
- const found = rows.findIndex((current) => compareRows(row, current, shape.orderBy) < 0);
155
+ const order = totalOrder(shape.orderBy);
156
+ const found = rows.findIndex((current) => compareRows(row, current, order) < 0);
120
157
  return found === -1 ? rows.length : found;
121
158
  }
122
159
 
123
- function idOf(row: object): string {
160
+ /**
161
+ * The row's identity, and the tiebreak `positionFor` sorts by. Refused when absent for the reason
162
+ * `seekKeyOf` refuses it: `String(undefined)` is `"undefined"`, an id every id-less row shares, so
163
+ * one row's patch lands on another's position and a `remove` names a row no client holds.
164
+ */
165
+ function idOf(row: object, entity: string): string {
124
166
  const value = columnOf(row, 'id');
167
+ if (value === undefined || value === null) throw new QueryNotPageableError(entity);
125
168
  return typeof value === 'string' ? value : String(value);
126
169
  }
package/src/mcp-tool.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  */
6
6
 
7
7
  import type { Actor, Ctx } from '@ultimat3/core';
8
+ import { isMcpExposed } from '@ultimat3/core';
8
9
  import type { JsonSchema } from '@ultimat3/schema';
9
10
  import { toMcpInputSchema } from '@ultimat3/schema';
10
- import { toToolName } from './naming';
11
11
  import type { QueryPolicy } from './policy-gate';
12
12
  import type { AnyQuery } from './query';
13
13
  import { queryName, sourceFor } from './read';
@@ -20,6 +20,11 @@ export interface QueryToolReadOptions {
20
20
  }
21
21
 
22
22
  export interface QueryToolDescriptor {
23
+ /**
24
+ * The export name VERBATIM, and identical to `query` below. `@ultimat3/mcp` serves a read
25
+ * under `queryName(target)` and answers `tools/call` for nothing else, so a snake_cased
26
+ * descriptor named a tool the server had never heard of — which it did until 2026-08.
27
+ */
23
28
  readonly name: string;
24
29
  /** The query's `mcp.description`, or its name when the author gave none. */
25
30
  readonly description: string;
@@ -38,7 +43,7 @@ export interface QueryToolDescriptor {
38
43
  export function toQueryTool(target: AnyQuery): QueryToolDescriptor {
39
44
  const name = queryName(target);
40
45
  return {
41
- name: toToolName(name),
46
+ name,
42
47
  description: target.mcp?.description ?? name,
43
48
  query: name,
44
49
  policy: target.policy,
@@ -57,11 +62,12 @@ export function toQueryTool(target: AnyQuery): QueryToolDescriptor {
57
62
  }
58
63
 
59
64
  /**
60
- * Opt-in, unlike an action's tool: a read hands rows to an agent, so silence
61
- * exposes nothing. `mcp: { expose: true }` is the whole opt-in.
65
+ * Opt-in: a read hands rows to an agent, so silence exposes nothing. `mcp: { expose: true }` is
66
+ * the whole opt-in `isMcpExposed` from `@ultimat3/core` is the framework's one answer, and an
67
+ * action's tool is now decided by the same call rather than by a second, looser rule.
62
68
  */
63
69
  export function isExposed(target: AnyQuery): boolean {
64
- return target.mcp?.expose === true;
70
+ return isMcpExposed(target.mcp);
65
71
  }
66
72
 
67
73
  /** Deterministic order — the tool list is part of the agent-visible contract. */
package/src/naming.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
- * The one naming rule for reads: a query's export name derives its HTTP path and
3
- * its MCP tool name. Pure string math, so the browser client derives the same URL
4
- * without importing a byte of server code. Ported rather than imported from
5
- * @ultimat3/action: that package is the same tier, and tiers never go sideways.
2
+ * The one naming rule for reads: a query's export name derives its HTTP path.
3
+ * Pure string math, so the browser client derives the same URL without importing
4
+ * a byte of server code. Ported rather than imported from @ultimat3/action: that
5
+ * package is the same tier, and tiers never go sideways. The MCP tool name is NOT
6
+ * derived — it is the export name verbatim, so there is one name to call.
6
7
  */
7
8
 
8
9
  /** Every read is served under one prefix, so a router can claim it in one rule. */
@@ -31,8 +32,3 @@ export function toKebabCase(name: string): string {
31
32
  export function derivePath(name: string): string {
32
33
  return `${QUERY_PREFIX}/${toKebabCase(name)}`;
33
34
  }
34
-
35
- /** MCP tool names are `snake_case`: `liveFeed` -> `live_feed`. */
36
- export function toToolName(name: string): string {
37
- return splitWords(name).join('_');
38
- }
package/src/pagination.ts CHANGED
@@ -9,8 +9,9 @@
9
9
  * The codec is `@ultimat3/core`'s. This file only decides what a cursor is bound
10
10
  * to — `queryHash(name, input)` — so one read's cursor cannot page another.
11
11
  */
12
- import { decodeCursor, encodeCursor } from '@ultimat3/core';
12
+ import { assert, decodeCursor, encodeCursor } from '@ultimat3/core';
13
13
  import type { StandardSchemaV1 } from '@ultimat3/schema';
14
+ import { reviveSortKey, serializeSortValue } from './cursor-value';
14
15
  import type { Query, SourceOptions } from './query';
15
16
  import { queryHash, queryName, sourceFor } from './query';
16
17
  import type { QueryShape, SeekKey } from './shape';
@@ -29,6 +30,15 @@ export interface PaginateArgs extends SourceOptions {
29
30
  readonly after?: string;
30
31
  }
31
32
 
33
+ /**
34
+ * The largest page a read will serve. A TWIN of `@ultimat3/entity`'s `MAX_PAGE_SIZE` — this
35
+ * package holds no dependency on that one, the same compromise `naming.ts` and `deprecation.ts`
36
+ * are ported under — and it exists for the same reason: `first` reaches here straight from an
37
+ * action's input or a route parameter, so `args.first + 1` bound whatever a client sent and one
38
+ * request could ask for five million rows.
39
+ */
40
+ const MAX_PAGE_SIZE = 10_000;
41
+
32
42
  /**
33
43
  * One page. Push-down when the source implements `seek()`; otherwise the rows are
34
44
  * sliced after execution and the source is doing more work than it should.
@@ -38,12 +48,21 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
38
48
  input: unknown,
39
49
  args: PaginateArgs,
40
50
  ): Promise<Page<TRow>> {
51
+ assert(
52
+ Number.isInteger(args.first) && args.first >= 1 && args.first <= MAX_PAGE_SIZE,
53
+ `first must be a whole number of rows between 1 and ${MAX_PAGE_SIZE}`,
54
+ `read.page(input, { first: Math.min(requested, ${MAX_PAGE_SIZE}) }) — or bound it in the input schema: t.number.int().min(1).max(50)`,
55
+ );
41
56
  const name = queryName(target);
42
57
  const hash = queryHash(name, input);
43
58
  // The scope is this read plus these arguments: a cursor from anywhere else is
44
59
  // already `X_CURSOR_INVALID` by the time it gets here.
45
60
  const decoded = args.after === undefined ? null : decodeCursor(args.after, hash);
46
- const after: SeekKey | null = decoded === null ? null : { key: decoded.key, id: decoded.id };
61
+ // Revived to the types the columns hold, never left as the strings JSON handed back: a `Date`
62
+ // key decoded as an ISO string reaches `compareValues` as text and is compared against the
63
+ // row's own millisecond number, so page two matched nothing at all. See `cursor-value.ts`.
64
+ const after: SeekKey | null =
65
+ decoded === null ? null : { key: reviveSortKey(decoded.key), id: decoded.id };
47
66
  const base = await sourceFor(target, input, args);
48
67
  const shape = base.shape();
49
68
 
@@ -59,7 +78,10 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
59
78
 
60
79
  return {
61
80
  rows,
62
- endCursor: seek === null ? null : encodeCursor({ scope: hash, key: seek.key, id: seek.id }),
81
+ endCursor:
82
+ seek === null
83
+ ? null
84
+ : encodeCursor({ scope: hash, key: seek.key.map(serializeSortValue), id: seek.id }),
63
85
  hasNextPage: scoped.length > args.first,
64
86
  };
65
87
  }
@@ -7,7 +7,7 @@
7
7
  import type { Actor, Ctx } from '@ultimat3/core';
8
8
  import { assertNever, isAnonymous } from '@ultimat3/core';
9
9
  import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
10
- import { enforce } from '@ultimat3/policy';
10
+ import { enforce, policyPermissions as flattenedPermissions } from '@ultimat3/policy';
11
11
  import { QueryDeniedError } from './errors';
12
12
 
13
13
  /** Policies are opaque here: we evaluate them, we never introspect their rules. */
@@ -60,7 +60,18 @@ export function actorOf(ctx: Ctx): Actor | null {
60
60
  return isAnonymous(ctx.actor) ? null : ctx.actor;
61
61
  }
62
62
 
63
- /** The capability a read requires, for manifests and the `/_x` dashboard. */
63
+ /** The capability a read requires, for manifests and the `/_x` dashboard. A DISPLAY label. */
64
64
  export function policyCapability(policy: QueryPolicy): string {
65
65
  return policy.label;
66
66
  }
67
+
68
+ /**
69
+ * Every permission the policy tree references, flattened and deduped — and the only field a
70
+ * compliance report may match a grant against. `label` renders a composite as
71
+ * `or(feed:read, org:administer)`, which is a sentence and never equals a permission string, so
72
+ * matching on it reported every read guarded by a composite as enforcing nothing. The mirror of
73
+ * `@ultimat3/action`'s, because `x policy list` reads both lists the same way.
74
+ */
75
+ export function policyPermissions(policy: QueryPolicy): readonly string[] {
76
+ return flattenedPermissions(policy);
77
+ }
package/src/query.ts CHANGED
@@ -7,24 +7,40 @@
7
7
  */
8
8
 
9
9
  import type { CacheTag } from '@ultimat3/cache';
10
+ import { tagKeys } from '@ultimat3/cache';
10
11
  import type { Actor, Ctx } from '@ultimat3/core';
11
12
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
13
+ import type { QueryCacheScope } from './cache';
12
14
  import type { QueryClientMethod, QueryClientOptions } from './client';
15
+ import type { Deprecation } from './deprecation';
16
+ import { QueryCacheTtlInvalidError } from './errors';
13
17
  import { facadeFor } from './facade';
18
+ import { assertEncodableInput } from './input-shape';
14
19
  import type { LiveQuery, ToLiveOptions } from './live';
15
20
  import type { QueryToolDescriptor } from './mcp-tool';
16
21
  import type { Page, PaginateArgs } from './pagination';
17
22
  import type { QueryPolicy, QuerySurface } from './policy-gate';
18
- import { policyCapability } from './policy-gate';
23
+ import { policyCapability, policyPermissions } from './policy-gate';
19
24
  import { hasDef, queryName, runQuery, stashDef } from './read';
20
25
  import type { SqlSource } from './source';
21
26
  import { fingerprint } from './stable';
22
- import { tagKeys } from './tags';
23
27
 
24
28
  export interface QueryCache {
25
29
  /** Tags this read depends on. An action's `invalidates` drops exactly these keys. */
26
30
  readonly tags: readonly CacheTag[];
31
+ /**
32
+ * Lifetime of a tier entry. Positive and finite — `Infinity`, `0` and `NaN` are refused at
33
+ * `query()` (`X_QUERY_CACHE_TTL_INVALID`), where the file that wrote them fails, rather than on
34
+ * every read of that query forever. Omitted takes `DEFAULT_READ_CACHE_TTL_MS`.
35
+ */
27
36
  readonly ttlMs?: number;
37
+ /**
38
+ * Who a cached answer may be handed back to. **Defaults to `actor`**, and that default is the
39
+ * mechanism: a read that declares nothing gets the narrowest key, which is always correct.
40
+ * `tenant` and `global` are written statements that the rows do not depend on the caller beyond
41
+ * their org, or at all — see `readAuthority`.
42
+ */
43
+ readonly scope?: QueryCacheScope;
28
44
  }
29
45
 
30
46
  export interface QueryMcp {
@@ -40,6 +56,17 @@ export interface QueryMcp {
40
56
  readonly visibleTo?: readonly string[];
41
57
  }
42
58
 
59
+ /**
60
+ * The symmetric twin of `ActionRateLimit`, and the field whose absence meant a read could not be
61
+ * throttled at all: every `GET /_x/query/*` fell through to the `default` bucket (120 burst, 2/s
62
+ * per actor), so one authenticated caller could hold 120 cross-tenant aggregates in flight and
63
+ * then 2/s forever, from a single account, with no declaration able to say otherwise.
64
+ */
65
+ export interface QueryRateLimit {
66
+ readonly limit: number;
67
+ readonly windowMs: number;
68
+ }
69
+
43
70
  export interface QueryDef<TInput extends StandardSchemaV1, TRow extends object> {
44
71
  readonly input: TInput;
45
72
  readonly policy: QueryPolicy;
@@ -48,12 +75,27 @@ export interface QueryDef<TInput extends StandardSchemaV1, TRow extends object>
48
75
  sql(input: InferOutput<TInput>, ctx: Ctx): SqlSource<TRow>;
49
76
  readonly cache?: QueryCache;
50
77
  readonly mcp?: QueryMcp;
78
+ /** Burst and refill for THIS read's route, in the limiter's own vocabulary. */
79
+ readonly rateLimit?: QueryRateLimit;
80
+ /**
81
+ * On its way out. `Deprecation` and `Sunset` response headers (RFC 9745 / RFC 8594), a
82
+ * `rel="successor-version"` link when `replacedBy` names one, and a
83
+ * `deprecated_calls_total{name}` counter — the only way to answer "is anyone still reading
84
+ * it?" before deleting it. Versioning itself is two deployments behind one ingress, not a
85
+ * router feature; see the README.
86
+ */
87
+ readonly deprecated?: Deprecation;
51
88
  }
52
89
 
53
90
  export interface QueryOptions {
54
91
  readonly ctx?: Ctx;
55
92
  readonly surface?: QuerySurface;
56
- /** Skips the cache tiers for this call. Live fanout always reads fresh. */
93
+ /**
94
+ * Skips every cache for this call — the request memo as well as the tiers, since a memo is a
95
+ * cache with a request's lifetime. The one way to read past a write made earlier in the same
96
+ * request: what it reads replaces the memo entry, so the next plain read of this key in this
97
+ * request sees the write too. Live fanout always reads fresh.
98
+ */
57
99
  readonly fresh?: boolean;
58
100
  /**
59
101
  * Run as someone else. Omitted keeps the context's own actor; `null` is the
@@ -65,20 +107,42 @@ export interface QueryOptions {
65
107
 
66
108
  export interface SourceOptions extends QueryOptions {
67
109
  /**
68
- * `false` for developer tooling (`explain`, admin-gated) and for the shared, subject-less
69
- * window a sync node builds once per `(query, input)` see `ToLiveOptions.enforce`. Both are
70
- * reads with no subscriber to decide about; every other caller leaves it alone.
110
+ * Build this source WITHOUT evaluating its policy, and say WHY — a written reason, never a bare
111
+ * boolean. It was `enforce: false`, and a boolean is wrong here for the reason
112
+ * `@ultimat3/entity`'s `cross-tenant.ts` gives for the same shape: it reads exactly like
113
+ * forgetting the check. The reason is the mechanism, not documentation of it — a blank one is
114
+ * refused (`X_INVARIANT`), so an escape with no argument cannot be written at all, and every
115
+ * skipped policy in a codebase is one `grep` away with the justification attached.
116
+ *
117
+ * Two situations qualify, and both are reads with no subscriber to decide about: developer
118
+ * tooling that returns no rows (`explain`, `describeSql`) and the shared, subject-less window a
119
+ * sync node builds once per `(query, input)` — see `ToLiveOptions.enforce`, which is that one
120
+ * use spelled as a boolean because it has exactly one reason and this file already states it.
121
+ *
122
+ * It is deliberately NOT gated on a capability the way `crossTenant` is: `explain` runs from the
123
+ * CLI with no actor at all to check one against, so a scope requirement would close the one
124
+ * surface this exists for. The rows are still tenant-scoped by `@ultimat3/entity` under the
125
+ * caller's own context, which `read.ts` now installs.
71
126
  */
72
- readonly enforce?: boolean;
127
+ readonly unenforced?: string;
73
128
  }
74
129
 
75
130
  export interface QueryDescriptor {
76
131
  readonly kind: 'query';
77
132
  readonly name: string;
78
133
  readonly live: boolean;
134
+ /** The policy's DISPLAY label. A composite renders as `or(a:b, c:d)` — never a permission. */
79
135
  readonly capability: string;
136
+ /**
137
+ * Every permission the policy tree references, flattened. The field a compliance report
138
+ * matches a grant against; `capability` is a label and matching on it reported every
139
+ * composite-guarded read as enforcing nothing.
140
+ */
141
+ readonly permissions: readonly string[];
80
142
  readonly tags: readonly string[];
81
143
  readonly ttlMs: number | null;
144
+ readonly rateLimit: QueryRateLimit | null;
145
+ readonly deprecated: Deprecation | null;
82
146
  }
83
147
 
84
148
  /**
@@ -93,6 +157,8 @@ export interface AnyQueryDef {
93
157
  sql(input: unknown, ctx: Ctx): SqlSource<object>;
94
158
  readonly cache?: QueryCache;
95
159
  readonly mcp?: QueryMcp;
160
+ readonly rateLimit?: QueryRateLimit;
161
+ readonly deprecated?: Deprecation;
96
162
  }
97
163
 
98
164
  export interface AnyQuery {
@@ -105,6 +171,9 @@ export interface AnyQuery {
105
171
  readonly policy: QueryPolicy;
106
172
  readonly cache?: QueryCache;
107
173
  readonly mcp?: QueryMcp;
174
+ /** Lifted so `toQueryRoute` reads the declaration without reaching through `defOf`. */
175
+ readonly rateLimit?: QueryRateLimit;
176
+ readonly deprecated?: Deprecation;
108
177
  describe(): QueryDescriptor;
109
178
  /** A twin under another name. Registration names through `named`. */
110
179
  named(name: string): AnyQuery;
@@ -141,15 +210,42 @@ export interface Query<
141
210
  /** The fluent half of a query: lifted declaration plus one method per projection. */
142
211
  export type QueryFacade<TInput extends StandardSchemaV1, TRow extends object> = Pick<
143
212
  Query<TInput, TRow>,
144
- 'input' | 'policy' | 'cache' | 'mcp' | 'as' | 'page' | 'live' | 'tool' | 'client'
213
+ | 'input'
214
+ | 'policy'
215
+ | 'cache'
216
+ | 'mcp'
217
+ | 'rateLimit'
218
+ | 'deprecated'
219
+ | 'as'
220
+ | 'page'
221
+ | 'live'
222
+ | 'tool'
223
+ | 'client'
145
224
  >;
146
225
 
147
226
  export function query<TInput extends StandardSchemaV1, TRow extends object>(
148
227
  def: QueryDef<TInput, TRow>,
149
228
  ): Query<TInput, TRow> {
229
+ // Here and not in `toQueryRoute`: a read is projected to `GET /_x/query/<kebab>` whether or not
230
+ // anyone mounts it, and the typed client derives that same URL — so an input a query string
231
+ // cannot carry is wrong for every call, and the file that declared it is where it is repaired.
232
+ assertEncodableInput(def.input);
233
+ assertCacheTtl(def.cache);
150
234
  return build(def, '');
151
235
  }
152
236
 
237
+ /**
238
+ * The same rule, for the same reason: a lease every `CacheTier` refuses is wrong for every read of
239
+ * this query, so it fails on the line that wrote it rather than on the first request. Positive and
240
+ * finite is `assertTtl`'s bar in `@ultimat3/cache`, restated as a refusal and never as a second
241
+ * resolution — there is no "never expires" to fall back to.
242
+ */
243
+ function assertCacheTtl(cache: QueryCache | undefined): void {
244
+ const ttlMs = cache?.ttlMs;
245
+ if (ttlMs === undefined) return;
246
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new QueryCacheTtlInvalidError(ttlMs);
247
+ }
248
+
153
249
  /**
154
250
  * Structural, not nominal: an object only counts as a query if `query()` built it,
155
251
  * because only then does a declaration exist for `sourceFor` to read. A look-alike
@@ -203,8 +299,12 @@ export function describeQuery(target: AnyQuery): QueryDescriptor {
203
299
  name: queryName(target),
204
300
  live: target.isLive,
205
301
  capability: policyCapability(target.policy),
302
+ // The flattened list, beside the label and never instead of it: one is read, one is matched.
303
+ permissions: policyPermissions(target.policy),
206
304
  tags: tagKeys(target.cache?.tags ?? []),
207
305
  ttlMs: target.cache?.ttlMs ?? null,
306
+ rateLimit: target.rateLimit ?? null,
307
+ deprecated: target.deprecated ?? null,
208
308
  };
209
309
  }
210
310