@ultimat3/query 24.0.0 → 25.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/live.ts CHANGED
@@ -12,9 +12,11 @@ import type { StandardSchemaV1 } from '@ultimat3/schema';
12
12
  import type { Patch } from './matcher';
13
13
  import { assertMatchable } from './matcher';
14
14
  import type { QueryPolicy, QuerySubject } from './policy-gate';
15
- import { guard } from './policy-gate';
16
- import type { Query } from './query';
15
+ import { guardQuery } from './policy-gate';
16
+ import type { AnyQuery, Query } from './query';
17
17
  import { queryHash } from './query';
18
+ import type { QueryLimitCaller } from './rate-limit-gate';
19
+ import { spendReadLimit } from './rate-limit-gate';
18
20
  import { queryName, sourceFor } from './read';
19
21
  import { assertNoSealedKey } from './sealed-shape';
20
22
  import type { QueryShape, SeekKey } from './shape';
@@ -103,6 +105,20 @@ export interface ToLiveOptions {
103
105
  const SHARED_WINDOW_REASON =
104
106
  'the shared subject-less window has no subscriber to decide about; authorize() runs per subscriber';
105
107
 
108
+ /**
109
+ * A live SUBSCRIBE's spend of the read's declared `rateLimit:` — the bucket the HTTP route, a paged
110
+ * read and the MCP tool spend. Its own export because the subscribe is realtime's: the sync node
111
+ * builds one shared window per `(query, input)` with no subscriber (`enforce: false`, which spends
112
+ * nothing) and calls this once per subscriber, beside `authorize`. Never on a re-authorization:
113
+ * the subscription it already holds was paid for.
114
+ */
115
+ export function spendQueryLimit(
116
+ target: AnyQuery,
117
+ caller: Omit<QueryLimitCaller, 'surface'>,
118
+ ): Promise<void> {
119
+ return spendReadLimit(queryName(target), target.rateLimit, { ...caller, surface: 'live' });
120
+ }
121
+
106
122
  /** Changing the build changes the epoch, which forces reconnects to refetch. */
107
123
  export function liveEpoch(): string {
108
124
  return Bun.env['X_BUILD_ID'] ?? 'dev';
@@ -148,7 +164,7 @@ export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends
148
164
  limit: shape.limit,
149
165
  execute: () => source.execute(),
150
166
  authorize: async (subject) => {
151
- guard(policy, subject, 'live');
167
+ guardQuery(policy, subject, 'live');
152
168
  },
153
169
  initialCursor: (rows) => ({
154
170
  epoch,
package/src/openapi.ts CHANGED
@@ -16,13 +16,17 @@
16
16
  */
17
17
 
18
18
  import { tagKeys } from '@ultimat3/cache';
19
- import { RECORDS_OPENAPI_HEADER, recordEnvelopeSchema } from '@ultimat3/core';
19
+ import {
20
+ isMcpExposed,
21
+ queryPath,
22
+ RECORDS_OPENAPI_HEADER,
23
+ recordEnvelopeSchema,
24
+ } from '@ultimat3/core';
25
+ import { MAX_PAGE_SIZE } from '@ultimat3/entity';
26
+ import { policyCapability } from '@ultimat3/policy';
20
27
  import type { SchemaNode } from '@ultimat3/schema';
21
28
  import { nodeToJsonSchema, tryIntrospect } from '@ultimat3/schema';
22
- import { isExposed } from './mcp-tool';
23
- import { derivePath } from './naming';
24
- import { MAX_PAGE_SIZE, PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
25
- import { policyCapability } from './policy-gate';
29
+ import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
26
30
  import type { AnyQuery } from './query';
27
31
  import { queryName } from './read';
28
32
  import { answersRecords } from './record-answer';
@@ -48,19 +52,22 @@ const ANSWERS: readonly Record<string, unknown>[] = [
48
52
  { type: 'array', items: {} },
49
53
  {
50
54
  type: 'object',
51
- // `nextCursor`/`hasMore` are the preferred names; the other two are the same values, kept as
52
- // aliases — documented as such, not deprecated — so every client written against them reads on.
53
- required: ['rows', 'nextCursor', 'hasMore', 'endCursor', 'hasNextPage'],
55
+ // The one page shape: the `endCursor`/`hasNextPage` alias pair left in 25.0.0 (O-12).
56
+ required: ['rows', 'nextCursor', 'hasMore'],
54
57
  properties: {
55
58
  rows: { type: 'array', items: {} },
56
59
  nextCursor: {
57
60
  type: ['string', 'null'],
58
- description: `the signed cursor that continues this listing — send it as ${PAGE_AFTER_KEY}; null on an empty page`,
61
+ description: `the signed cursor for the next page — send it as ${PAGE_AFTER_KEY}; null exactly when hasMore is false`,
59
62
  },
60
63
  hasMore: { type: 'boolean', description: 'whether another page follows' },
61
- endCursor: { type: ['string', 'null'], description: 'alias of nextCursor — the same value' },
62
- hasNextPage: { type: 'boolean', description: 'alias of hasMore — the same value' },
63
64
  },
65
+ // `@ultimat3/core`'s `Page` is a union, and so is its wire shape: the two facts cannot
66
+ // disagree in a body this document validates (25.0.0 — the last page used to keep a cursor).
67
+ oneOf: [
68
+ { properties: { nextCursor: { type: 'string' }, hasMore: { const: true } } },
69
+ { properties: { nextCursor: { type: 'null' }, hasMore: { const: false } } },
70
+ ],
64
71
  },
65
72
  ];
66
73
 
@@ -70,7 +77,7 @@ export function queryOpenApiPaths(
70
77
  ): Record<string, OpenApiPathItem> {
71
78
  const paths: Record<string, OpenApiPathItem> = {};
72
79
  for (const target of [...queries].sort(compareByName)) {
73
- paths[derivePath(queryName(target))] = { get: toQueryOpenApiOperation(target) };
80
+ paths[queryPath(queryName(target))] = { get: toQueryOpenApiOperation(target) };
74
81
  }
75
82
  return paths;
76
83
  }
@@ -117,9 +124,9 @@ export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknow
117
124
  capability: policyCapability(target.policy),
118
125
  live: target.isLive,
119
126
  cacheTags: tagKeys(target.cache?.tags ?? []),
120
- // The same rule `toMcpTool` and the action side apply: a tool is advertised only where the
121
- // MCP catalog lists one, under the export name verbatim (`mcp-tool.ts`).
122
- tool: isExposed(target) ? name : null,
127
+ // Core's one predicate, the one `@ultimat3/mcp`'s catalog and the action side ask: a tool is
128
+ // advertised only where the MCP catalog lists one, under the export name verbatim.
129
+ tool: isMcpExposed(target.mcp) ? name : null,
123
130
  },
124
131
  };
125
132
  }
@@ -156,7 +163,7 @@ function singleOperation(target: AnyQuery, name: string): Record<string, unknown
156
163
  live: target.isLive,
157
164
  single: true,
158
165
  cacheTags: tagKeys(target.cache?.tags ?? []),
159
- tool: isExposed(target) ? name : null,
166
+ tool: isMcpExposed(target.mcp) ? name : null,
160
167
  },
161
168
  };
162
169
  }
@@ -177,7 +184,7 @@ const PAGE_PARAMETERS: readonly Record<string, unknown>[] = [
177
184
  name: PAGE_AFTER_KEY,
178
185
  in: 'query',
179
186
  required: false,
180
- description: `the nextCursor (alias endCursor) a previous page answered; needs ${PAGE_FIRST_KEY}`,
187
+ description: `the nextCursor a previous page answered; needs ${PAGE_FIRST_KEY}`,
181
188
  schema: { type: 'string' },
182
189
  },
183
190
  ];
@@ -11,21 +11,17 @@
11
11
  // the framework's own namespace mark — the route lives under `/_x/` for the same reason — and
12
12
  // `input-shape.ts` refuses a declaration that reaches for it, so the two names can never collide.
13
13
 
14
+ import { MAX_PAGE_SIZE } from '@ultimat3/entity';
14
15
  import { QueryInputInvalidError } from './errors';
15
16
  import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-keys';
16
17
 
17
18
  export { PAGE_AFTER_KEY, PAGE_FIRST_KEY };
18
19
  export const PAGE_CONTROL_KEYS: readonly string[] = [PAGE_FIRST_KEY, PAGE_AFTER_KEY];
19
20
 
20
- /**
21
- * The largest page a read will serve. A TWIN of `@ultimat3/entity`'s `MAX_PAGE_SIZE` — this
22
- * package holds no dependency on that one, the same compromise `naming.ts` and `deprecation.ts`
23
- * are ported under — and it exists for the same reason: `first` reaches `paginate` straight from
24
- * an action's input or a route parameter, so `args.first + 1` bound whatever a client sent and one
25
- * request could ask for five million rows. Lives here rather than in `pagination.ts` because the
26
- * route validates against it BEFORE `paginate` runs — see `pageControlsOf`.
27
- */
28
- export const MAX_PAGE_SIZE = 10_000;
21
+ // The largest page a read will serve is `@ultimat3/entity`'s `MAX_PAGE_SIZE`, imported — a batch
22
+ // and a page are one memory bound, and this package already depends on entity. `first` reaches
23
+ // `paginate` straight from a route parameter, so the route judges it against that bound BEFORE
24
+ // `paginate` runs (`pageControlsOf`), and one request can never ask for five million rows.
29
25
 
30
26
  /** What the route hands `query.page()` — `PaginateArgs` minus the server-side options. */
31
27
  export interface PageControls {
package/src/page-keys.ts CHANGED
@@ -5,5 +5,5 @@
5
5
 
6
6
  /** `?_first=20` — the page size. Present is what makes the route answer a `Page`. */
7
7
  export const PAGE_FIRST_KEY = '_first';
8
- /** `?_after=<cursor>` — the signed cursor the previous page's `endCursor` carried. */
8
+ /** `?_after=<cursor>` — the signed cursor the previous page's `nextCursor` carried. */
9
9
  export const PAGE_AFTER_KEY = '_after';
package/src/pagination.ts CHANGED
@@ -9,44 +9,27 @@
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 { assert, decodeCursor, encodeCursor } from '@ultimat3/core';
12
+ import type { CursorPayload, Page } from '@ultimat3/core';
13
+ import { assert, CursorInvalidError, decodeCursor, encodeCursor, pageOf } from '@ultimat3/core';
14
+ import { MAX_PAGE_SIZE } from '@ultimat3/entity';
13
15
  import type { StandardSchemaV1 } from '@ultimat3/schema';
14
16
  import { kindsOf } from './column-kinds';
15
17
  import { reviveSortKey, serializeSortValue } from './cursor-value';
16
- import { CursorInvalidError } from './errors';
17
- import { MAX_PAGE_SIZE } from './page-controls';
18
18
  import type { Query, SourceOptions } from './query';
19
- import { queryHash, queryName, sourceFor } from './query';
19
+ import { queryHash, queryName } from './query';
20
+ import { readCall } from './read';
20
21
  import type { QueryShape, SeekKey } from './shape';
21
22
  import { compareRows, seekKeyOf, totalOrder } from './shape';
22
23
  import type { SqlSource } from './source';
23
24
  import { isAfterKey } from './source';
24
25
 
25
26
  /**
26
- * One page. `nextCursor` / `hasMore` are the preferred names; `endCursor` / `hasNextPage` are the
27
- * same two values under the names every client before 22.12 reads — aliases, both always present,
28
- * never able to disagree because `pageOf` is the one place that writes them.
27
+ * One page is `@ultimat3/core`'s `Page` — the framework's ONE page shape (O-12, 25.0.0), the same
28
+ * type `@ultimat3/entity`'s `findMany` answers, re-exported so a `query` file needs one import.
29
+ * `nextCursor` is `null` exactly when `hasMore` is false: before 25.0.0 this page kept a cursor on
30
+ * its LAST page, so a `while (page.nextCursor)` loop fetched an empty page past the end.
29
31
  */
30
- export interface Page<TRow> {
31
- readonly rows: readonly TRow[];
32
- /** The signed cursor that continues this listing; `null` on an empty page. */
33
- readonly nextCursor: string | null;
34
- /** Whether another page follows. */
35
- readonly hasMore: boolean;
36
- /** Alias of `nextCursor`. */
37
- readonly endCursor: string | null;
38
- /** Alias of `hasMore`. */
39
- readonly hasNextPage: boolean;
40
- }
41
-
42
- /** Builds a `Page` from its two facts — the only constructor, so the aliases cannot drift. */
43
- export function pageOf<TRow>(
44
- rows: readonly TRow[],
45
- nextCursor: string | null,
46
- hasMore: boolean,
47
- ): Page<TRow> {
48
- return { rows, nextCursor, hasMore, endCursor: nextCursor, hasNextPage: hasMore };
49
- }
32
+ export type { Page } from '@ultimat3/core';
50
33
 
51
34
  export interface PaginateArgs extends SourceOptions {
52
35
  readonly first: number;
@@ -72,7 +55,18 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
72
55
  // The scope is this read plus these arguments: a cursor from anywhere else is
73
56
  // already `X_CURSOR_INVALID` by the time it gets here.
74
57
  const decoded = args.after === undefined ? null : decodeCursor(args.after, hash);
75
- const base = await sourceFor(target, input, args);
58
+ // One call: an audited read's record covers the build AND the page it serves — the early return
59
+ // below included, since a caller refused nothing and was answered (`audit-gate.ts`).
60
+ return readCall(target, input, args, (base) => pageFrom<TRow>(base, decoded, args.first, hash));
61
+ }
62
+
63
+ /** The page itself, over a source `readCall` already validated, authorized and built. */
64
+ async function pageFrom<TRow extends object>(
65
+ base: SqlSource<object>,
66
+ decoded: CursorPayload | null,
67
+ first: number,
68
+ hash: string,
69
+ ): Promise<Page<TRow>> {
76
70
  const shape = base.shape();
77
71
  // Revived to the types the columns hold, never left as the strings JSON handed back: a `Date`
78
72
  // key decoded as an ISO string was compared as text against the row's own instant, so page two
@@ -81,25 +75,30 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
81
75
  const after = resumed?.seek ?? null;
82
76
  const left = rowsLeft(shape.limit, resumed);
83
77
  // The whole of a declared limit is already served: there is no row to ask the source for.
84
- if (left === 0) return pageOf<TRow>([], null, false);
78
+ if (left === 0) return pageOf<TRow>([], null);
85
79
 
86
- // `MAX_PAGE_SIZE` lives in `page-controls.ts`: the route checks the same bound at the wire, as
80
+ // `MAX_PAGE_SIZE` is `@ultimat3/entity`'s: `page-controls.ts` checks the same bound at the wire, as
87
81
  // a 400, before this `assert` — which is a 500 — can see the number.
88
- // Fetch one extra row: its presence *is* `hasNextPage`, with no count query. Never past what a
82
+ // Fetch one extra row: its presence *is* `hasMore`, with no count query. Never past what a
89
83
  // declared `.limit()` has left — `first` used to REPLACE that limit, so `?_first=10000` walked a
90
84
  // "top 3" to the end of the table.
91
- const window = Math.min(left, args.first + 1);
85
+ const window = Math.min(left, first + 1);
92
86
  const source: SqlSource<object> = base.seek === undefined ? base : base.seek(after, window);
93
87
  const executed = await source.execute();
94
88
  const scoped =
95
89
  base.seek === undefined ? inTotalOrder(executed, after, shape).slice(0, window) : executed;
96
90
  // The source came from this query's own `sql()`, so its rows are TRow.
97
- const rows = scoped.slice(0, args.first) as unknown as readonly TRow[];
91
+ const rows = scoped.slice(0, first) as unknown as readonly TRow[];
98
92
  const last = rows[rows.length - 1];
93
+ // Read on EVERY page with a row, the last included: a read with no id is refused as unpageable
94
+ // on page one, never only once its listing grows past one page.
99
95
  const seek = last === undefined ? null : seekKeyOf(last, shape);
100
96
 
97
+ // A cursor is minted only when another page follows (the extra row arrived): the position of the
98
+ // last row of the LAST page is not a next page, and handing it back made a
99
+ // `while (page.nextCursor)` loop fetch an empty page past the end.
101
100
  const nextCursor =
102
- seek === null
101
+ seek === null || scoped.length <= first
103
102
  ? null
104
103
  : encodeCursor({
105
104
  scope: hash,
@@ -113,7 +112,7 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
113
112
  ],
114
113
  id: String(seek.id),
115
114
  });
116
- return pageOf(rows, nextCursor, scoped.length > args.first);
115
+ return pageOf(rows, nextCursor);
117
116
  }
118
117
 
119
118
  /** A decoded cursor: where the page resumes, and how many rows of a declared limit are spent. */
@@ -5,20 +5,20 @@
5
5
  */
6
6
 
7
7
  import type { Actor, Ctx } from '@ultimat3/core';
8
- import { assertNever, isAnonymous } from '@ultimat3/core';
8
+ import { assertNever } from '@ultimat3/core';
9
9
  import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
10
- import {
11
- enforce,
12
- enforceBeforeInput,
13
- policyPermissions as flattenedPermissions,
14
- admitsAnonymous as policyAdmitsAnonymous,
15
- } from '@ultimat3/policy';
10
+ import { enforce, enforceBeforeInput } from '@ultimat3/policy';
16
11
  import { QueryDeniedError } from './errors';
17
12
 
18
13
  /** Policies are opaque here: we evaluate them, we never introspect their rules. */
19
14
  export type QueryPolicy = Policy<unknown>;
20
15
 
21
- export type QuerySurface = 'server' | 'http' | 'live';
16
+ /**
17
+ * Where a read came from. `'mcp'` is an agent's tool call — named so a declared `rateLimit:`
18
+ * spends for it (`'server'`, in-process app code, does not) — and is judged by the policy exactly
19
+ * as `'server'` always judged it, so no MCP read's authz answer moved.
20
+ */
21
+ export type QuerySurface = 'server' | 'http' | 'live' | 'mcp';
22
22
 
23
23
  export interface QuerySubject {
24
24
  readonly actor: Actor | null;
@@ -33,7 +33,11 @@ export interface QuerySubject {
33
33
  readonly query: string;
34
34
  }
35
35
 
36
- export function guard(policy: QueryPolicy, subject: QuerySubject, surface: QuerySurface): void {
36
+ export function guardQuery(
37
+ policy: QueryPolicy,
38
+ subject: QuerySubject,
39
+ surface: QuerySurface,
40
+ ): void {
37
41
  const denial = enforce(policySurface(surface), policy, {
38
42
  input: subject.input,
39
43
  actor: subject.actor,
@@ -44,11 +48,11 @@ export function guard(policy: QueryPolicy, subject: QuerySubject, surface: Query
44
48
  }
45
49
 
46
50
  /**
47
- * The actor-only half of `guard`, run BEFORE the input is parsed — the twin of
51
+ * The actor-only half of `guardQuery`, run BEFORE the input is parsed — the twin of
48
52
  * `@ultimat3/action`'s. A reader the policy refuses whatever they send is answered 403 (or 401),
49
- * never `X_INPUT_INVALID` with the read's input schema in it. Undecided passes to `guard`.
53
+ * never `X_INPUT_INVALID` with the read's input schema in it. Undecided passes to `guardQuery`.
50
54
  */
51
- export function guardBeforeInput(
55
+ export function guardQueryBeforeInput(
52
56
  policy: QueryPolicy,
53
57
  subject: Omit<QuerySubject, 'input' | 'row'>,
54
58
  surface: QuerySurface,
@@ -68,45 +72,9 @@ function policySurface(surface: QuerySurface): PolicySurface {
68
72
  case 'live':
69
73
  return 'live';
70
74
  case 'server':
75
+ case 'mcp':
71
76
  return 'job';
72
77
  default:
73
78
  return assertNever(surface);
74
79
  }
75
80
  }
76
-
77
- /**
78
- * Core models "nobody" as an anonymous actor; policy models it as `null`, which is
79
- * what turns a missing session into `X_UNAUTHENTICATED` instead of a bare denial.
80
- */
81
- export function actorOf(ctx: Ctx): Actor | null {
82
- return isAnonymous(ctx.actor) ? null : ctx.actor;
83
- }
84
-
85
- /** The capability a read requires, for manifests and the `/_x` dashboard. A DISPLAY label. */
86
- export function policyCapability(policy: QueryPolicy): string {
87
- return policy.label;
88
- }
89
-
90
- /**
91
- * Every permission the policy tree references, flattened and deduped — and the only field a
92
- * compliance report may match a grant against. `label` renders a composite as
93
- * `or(feed:read, org:administer)`, which is a sentence and never equals a permission string, so
94
- * matching on it reported every read guarded by a composite as enforcing nothing. The mirror of
95
- * `@ultimat3/action`'s, because `x policy list` reads both lists the same way.
96
- */
97
- export function policyPermissions(policy: QueryPolicy): readonly string[] {
98
- return flattenedPermissions(policy);
99
- }
100
-
101
- /**
102
- * Whether a policy admits an ANONYMOUS caller — `@ultimat3/policy`'s answer, re-exported here so
103
- * `http.ts` reads it through this file like every other authz question. `toQueryRoute` derives
104
- * `meta.auth` from it, never from `policy.kind === 'allow'`: that read looked at the ROOT
105
- * combinator only, so `or(allow(), can('x:y'))` was 401'd by the pipeline before `runQuery` ran
106
- * while the MCP tool and a direct server read allowed it. `true` is `meta.auth` only — the read is
107
- * still `no-store` and `runQuery` still evaluates the policy per caller. Declared once in
108
- * `policy.ts`, exactly as `policyPermissions` is: the answer is a property of the combinators.
109
- */
110
- export function admitsAnonymous(policy: QueryPolicy): boolean {
111
- return policyAdmitsAnonymous(policy);
112
- }
package/src/query.ts CHANGED
@@ -8,20 +8,19 @@
8
8
 
9
9
  import type { CacheTag } from '@ultimat3/cache';
10
10
  import { tagKeys } from '@ultimat3/cache';
11
- import type { Actor, Ctx } from '@ultimat3/core';
11
+ import type { Actor, Ctx, Deprecation, McpExposureDeclaration } from '@ultimat3/core';
12
12
  import { fingerprint } from '@ultimat3/core';
13
+ import type { RateLimitDecision } from '@ultimat3/http';
14
+ import { policyCapability, policyPermissions } from '@ultimat3/policy';
13
15
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
14
16
  import type { QueryCacheScope } from './cache';
15
17
  import type { QueryClientMethodOf, QueryClientOptions } from './client';
16
- import type { Deprecation } from './deprecation';
17
18
  import { QueryCacheTtlInvalidError, QuerySingleInvalidError } from './errors';
18
19
  import { facadeFor } from './facade';
19
20
  import { assertEncodableInput } from './input-shape';
20
21
  import type { LiveQuery, ToLiveOptions } from './live';
21
- import type { QueryToolDescriptor } from './mcp-tool';
22
22
  import type { Page, PaginateArgs } from './pagination';
23
23
  import type { QueryPolicy, QuerySurface } from './policy-gate';
24
- import { policyCapability, policyPermissions } from './policy-gate';
25
24
  import { hasDef, queryName, runQuery, stashDef } from './read';
26
25
  import type { SqlSource } from './source';
27
26
  import { assertSubscribes } from './subscribes';
@@ -44,52 +43,6 @@ export interface QueryCache {
44
43
  readonly scope?: QueryCacheScope;
45
44
  }
46
45
 
47
- export interface QueryMcp {
48
- /** Opt-in: a read reaches an agent only when it says so. Silence exposes nothing. */
49
- readonly expose: boolean;
50
- /** Contract text, not UI text — see `ActionMcp.description` for why it stays outside `t()`. */
51
- readonly description?: string;
52
- /**
53
- * Roles that may SEE the projected tool — a catalog audience, not an authz rule; the `policy`
54
- * still decides every call. A caller whose role is not named gets ToolNotFound, never
55
- * Forbidden. See `ActionMcp.visibleTo`, which this mirrors exactly.
56
- */
57
- readonly visibleTo?: readonly string[];
58
- /**
59
- * The list whitelist an MCP meta surface composes over (`@ultimat3/mcp` `McpListParams`):
60
- * `filters` field → operators, sortable `sort` fields, pickable `fields`, `maxLimit`. Flat keys:
61
- * the query's own `input` declares `status_eq`, `sort`, `fields`, `cursor`, `limit` and
62
- * implements them; `manage_resource` refuses anything outside the whitelist before it runs.
63
- */
64
- readonly listParams?: QueryListParams;
65
- /** The tool's display name in an MCP client. See `ActionMcp.title`. */
66
- readonly title?: string;
67
- /**
68
- * MCP tool annotations, overriding the derived `readOnlyHint: true` key by key — `openWorldHint:
69
- * true` for a read that reaches outside the app. See `ActionMcp.annotations`.
70
- */
71
- readonly annotations?: QueryMcpAnnotations;
72
- }
73
-
74
- /** Structural twin of `@ultimat3/action`'s `McpAnnotationHints` (siblings cannot import). */
75
- export interface QueryMcpAnnotations {
76
- readonly readOnlyHint?: boolean;
77
- readonly destructiveHint?: boolean;
78
- readonly idempotentHint?: boolean;
79
- readonly openWorldHint?: boolean;
80
- }
81
-
82
- /** One comparison a list filter accepts. A filter key is `<field><op>`: `status_eq`. */
83
- export type QueryListFilterOp = '_eq' | '_in' | '_gt' | '_lt' | '_cont';
84
-
85
- /** Structural twin of `@ultimat3/mcp`'s `McpListParams` (tier 3 cannot import tier 4). */
86
- export interface QueryListParams {
87
- readonly filters?: Readonly<Record<string, readonly QueryListFilterOp[]>>;
88
- readonly sort?: readonly string[];
89
- readonly fields?: readonly string[];
90
- readonly maxLimit?: number;
91
- }
92
-
93
46
  /**
94
47
  * The symmetric twin of `ActionRateLimit`, and the field whose absence meant a read could not be
95
48
  * throttled at all: every `GET /_x/query/*` fell through to the `default` bucket (120 burst, 2/s
@@ -147,7 +100,7 @@ export interface QueryDef<
147
100
  readonly rows?: StandardSchemaV1<unknown, TRow>;
148
101
  sql(input: InferOutput<TInput>, ctx: Ctx): SqlSource<TRow>;
149
102
  readonly cache?: QueryCache;
150
- readonly mcp?: QueryMcp;
103
+ readonly mcp?: McpExposureDeclaration;
151
104
  /** Burst and refill for THIS read's route, in the limiter's own vocabulary. */
152
105
  readonly rateLimit?: QueryRateLimit;
153
106
  /**
@@ -158,6 +111,13 @@ export interface QueryDef<
158
111
  * router feature; see the README.
159
112
  */
160
113
  readonly deprecated?: Deprecation;
114
+ /**
115
+ * Record every call through the installed `AuditSink` (`@ultimat3/core`'s, the one an action's
116
+ * `audit: true` writes to) — allowed, denied and failed, and a memo or cache hit too: an audit of
117
+ * a read is who saw what, not what was computed. Off unless declared; no sink installed is
118
+ * `X_QUERY_AUDIT_SINK_MISSING` before the input is parsed.
119
+ */
120
+ readonly audit?: boolean;
161
121
  }
162
122
 
163
123
  export interface QueryOptions {
@@ -176,6 +136,13 @@ export interface QueryOptions {
176
136
  * stays on the one read path instead of forking a second one.
177
137
  */
178
138
  readonly actor?: Actor | null;
139
+ /**
140
+ * The reader's connection address — the only rate-limit subject an anonymous reader has. The
141
+ * HTTP projection passes the request's; elsewhere every anonymous reader shares one bucket.
142
+ */
143
+ readonly clientAddress?: string | null;
144
+ /** The declared `rateLimit:` decision, before a refusal: how HTTP answers `RateLimit-*`. */
145
+ readonly onRateLimit?: (decision: RateLimitDecision) => void;
179
146
  }
180
147
 
181
148
  export interface SourceOptions extends QueryOptions {
@@ -239,9 +206,10 @@ export interface AnyQueryDef {
239
206
  readonly rows?: StandardSchemaV1;
240
207
  sql(input: unknown, ctx: Ctx): SqlSource<object>;
241
208
  readonly cache?: QueryCache;
242
- readonly mcp?: QueryMcp;
209
+ readonly mcp?: McpExposureDeclaration;
243
210
  readonly rateLimit?: QueryRateLimit;
244
211
  readonly deprecated?: Deprecation;
212
+ readonly audit?: boolean;
245
213
  }
246
214
 
247
215
  export interface AnyQuery {
@@ -259,7 +227,7 @@ export interface AnyQuery {
259
227
  /** Lifted for the route: whether an answer is a record envelope is decided at projection. */
260
228
  readonly rows?: StandardSchemaV1;
261
229
  readonly cache?: QueryCache;
262
- readonly mcp?: QueryMcp;
230
+ readonly mcp?: McpExposureDeclaration;
263
231
  /** Lifted so `toQueryRoute` reads the declaration without reaching through `defOf`. */
264
232
  readonly rateLimit?: QueryRateLimit;
265
233
  readonly deprecated?: Deprecation;
@@ -271,7 +239,6 @@ export interface AnyQuery {
271
239
  /** One bounded page plus the signed cursor that continues it. There is no `offset`. */
272
240
  page(input: unknown, args: PaginateArgs): Promise<Page<object>>;
273
241
  live(input: unknown, options?: ToLiveOptions): Promise<LiveQuery>;
274
- tool(): QueryToolDescriptor;
275
242
  }
276
243
 
277
244
  export interface Query<
@@ -297,8 +264,6 @@ export interface Query<
297
264
  * so it lives here and not on the schema-erased `AnyQuery` view.
298
265
  */
299
266
  client(options: QueryClientOptions): QueryClientMethodOf<TInput, TRow, TSingle>;
300
- /** Narrowed by the declaration: a list read's tool answers rows, a single read's one row. */
301
- tool(): QueryToolDescriptor<TSingle>;
302
267
  }
303
268
 
304
269
  /** The fluent half of a query: lifted declaration plus one method per projection. */
@@ -320,7 +285,6 @@ export type QueryFacade<
320
285
  | 'as'
321
286
  | 'page'
322
287
  | 'live'
323
- | 'tool'
324
288
  | 'client'
325
289
  >;
326
290
 
@@ -374,7 +338,7 @@ export function isQuery(value: unknown): value is AnyQuery {
374
338
  /**
375
339
  * Stamp the export name onto the query the app declared, rather than handing back a
376
340
  * differently-named copy of it. `import { liveFeed } from './live'` is then the query
377
- * that projects — `liveFeed.tool()` after boot, with nothing to remember. The same
341
+ * that projects — `liveFeed.client()` after boot, with nothing to remember. The same
378
342
  * rule `nameAction` follows; naming twice is the one case that still needs a twin.
379
343
  */
380
344
  export function nameQuery<Q extends AnyQuery>(target: Q, name: string): Q {
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Where a read's declared `rateLimit:` is spent: once, in the read path's front half, so the HTTP
3
+ * route, a paged read, the MCP tool and a live subscribe draw on ONE bucket. The store is
4
+ * `@ultimat3/http`'s installed one — the slot `@ultimat3/action`'s `invoke` spends from too.
5
+ */
6
+
7
+ import type { Actor } from '@ultimat3/core';
8
+ import { isAnonymous, systemClock } from '@ultimat3/core';
9
+ import type { RateLimitDecision } from '@ultimat3/http';
10
+ // The bucket maths, the subject precedence and the refusal are `@ultimat3/http`'s (tier 2): a copy
11
+ // here would be a second answer to "what does this limit mean" and "whose bucket is it".
12
+ import { installedRateLimitStore, rateLimited, rateLimitSpends, toBucket } from '@ultimat3/http';
13
+ import type { QuerySurface } from './policy-gate';
14
+ import type { QueryRateLimit } from './query';
15
+
16
+ /**
17
+ * Namespaced, never the bare query name: the HTTP stage still spends its own buckets under
18
+ * `<route name>|<subject>`, and one key taken against two bucket shapes corrupts both counters.
19
+ */
20
+ const QUERY_SCOPE = 'query';
21
+
22
+ export interface QueryLimitCaller {
23
+ /** Who is reading — `ctx.actor` on a read, the socket's actor on a live subscribe. */
24
+ readonly actor: Actor;
25
+ readonly surface: QuerySurface;
26
+ /** The connection address — the only subject an anonymous reader has. */
27
+ readonly clientAddress?: string | null | undefined;
28
+ /** The decision, before a refusal is thrown: how HTTP puts `RateLimit-*` on the answer. */
29
+ readonly onRateLimit?: ((decision: RateLimitDecision) => void) | undefined;
30
+ /** Epoch ms from the caller's clock. Absent, the system clock. */
31
+ readonly nowMs?: number | undefined;
32
+ }
33
+
34
+ /**
35
+ * Spend one token of a read's declared limit for this caller, or throw `X_RATE_LIMITED`. Called by
36
+ * `read.ts`'s `buildSource` for every enforced build, and through `spendQueryLimit` (`live.ts`) by
37
+ * the one surface this package does not run: realtime's per-subscriber live subscribe.
38
+ *
39
+ * `surface: 'server'` spends nothing — app code reading in-process (a page's render, a service),
40
+ * the rule `@ultimat3/action` applies to its own `'server'` surface. A refused attempt spends
41
+ * nothing either: the token bucket only decrements on an admission.
42
+ */
43
+ export async function spendReadLimit(
44
+ name: string,
45
+ limit: QueryRateLimit | undefined,
46
+ caller: QueryLimitCaller,
47
+ ): Promise<void> {
48
+ if (limit === undefined || caller.surface === 'server') return;
49
+ const actor = isAnonymous(caller.actor) ? null : caller.actor;
50
+ const spends = rateLimitSpends(
51
+ {
52
+ actorId: actor?.id ?? null,
53
+ orgId: actor?.orgId ?? null,
54
+ ip: caller.clientAddress ?? null,
55
+ routeName: `${QUERY_SCOPE}:${name}`,
56
+ },
57
+ // The tenant allowance is the HTTP pipeline's; counting it here too would charge twice.
58
+ { route: name, tenant: null },
59
+ );
60
+ const nowMs = caller.nowMs ?? systemClock.now().getTime();
61
+ for (const spend of spends) {
62
+ const decision = await installedRateLimitStore().take(
63
+ spend.key,
64
+ toBucket(name, limit),
65
+ 1,
66
+ nowMs,
67
+ );
68
+ caller.onRateLimit?.(decision);
69
+ if (!decision.allowed) throw rateLimited(spend.key, decision.retryAfterSeconds);
70
+ }
71
+ }