@ultimat3/query 23.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/pagination.ts CHANGED
@@ -9,42 +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';
16
+ import { kindsOf } from './column-kinds';
14
17
  import { reviveSortKey, serializeSortValue } from './cursor-value';
15
- import { MAX_PAGE_SIZE } from './page-controls';
16
18
  import type { Query, SourceOptions } from './query';
17
- import { queryHash, queryName, sourceFor } from './query';
19
+ import { queryHash, queryName } from './query';
20
+ import { readCall } from './read';
18
21
  import type { QueryShape, SeekKey } from './shape';
19
22
  import { compareRows, seekKeyOf, totalOrder } from './shape';
20
23
  import type { SqlSource } from './source';
21
24
  import { isAfterKey } from './source';
22
25
 
23
26
  /**
24
- * One page. `nextCursor` / `hasMore` are the preferred names; `endCursor` / `hasNextPage` are the
25
- * same two values under the names every client before 22.12 reads — aliases, both always present,
26
- * 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.
27
31
  */
28
- export interface Page<TRow> {
29
- readonly rows: readonly TRow[];
30
- /** The signed cursor that continues this listing; `null` on an empty page. */
31
- readonly nextCursor: string | null;
32
- /** Whether another page follows. */
33
- readonly hasMore: boolean;
34
- /** Alias of `nextCursor`. */
35
- readonly endCursor: string | null;
36
- /** Alias of `hasMore`. */
37
- readonly hasNextPage: boolean;
38
- }
39
-
40
- /** Builds a `Page` from its two facts — the only constructor, so the aliases cannot drift. */
41
- export function pageOf<TRow>(
42
- rows: readonly TRow[],
43
- nextCursor: string | null,
44
- hasMore: boolean,
45
- ): Page<TRow> {
46
- return { rows, nextCursor, hasMore, endCursor: nextCursor, hasNextPage: hasMore };
47
- }
32
+ export type { Page } from '@ultimat3/core';
48
33
 
49
34
  export interface PaginateArgs extends SourceOptions {
50
35
  readonly first: number;
@@ -70,37 +55,88 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
70
55
  // The scope is this read plus these arguments: a cursor from anywhere else is
71
56
  // already `X_CURSOR_INVALID` by the time it gets here.
72
57
  const decoded = args.after === undefined ? null : decodeCursor(args.after, hash);
73
- 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>> {
74
70
  const shape = base.shape();
75
71
  // Revived to the types the columns hold, never left as the strings JSON handed back: a `Date`
76
- // key decoded as an ISO string reaches `compareValues` as text and is compared against the
77
- // row's own millisecond number, so page two matched nothing at all. See `cursor-value.ts`.
78
- const after: SeekKey | null =
79
- decoded === null ? null : seekFromCursor(decoded, shape.orderBy.length);
72
+ // key decoded as an ISO string was compared as text against the row's own instant, so page two
73
+ // matched nothing at all. See `cursor-value.ts`.
74
+ const resumed = decoded === null ? null : seekFromCursor(decoded, shape.orderBy.length);
75
+ const after = resumed?.seek ?? null;
76
+ const left = rowsLeft(shape.limit, resumed);
77
+ // The whole of a declared limit is already served: there is no row to ask the source for.
78
+ if (left === 0) return pageOf<TRow>([], null);
80
79
 
81
- // `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
82
81
  // a 400, before this `assert` — which is a 500 — can see the number.
83
- // Fetch one extra row: its presence *is* `hasNextPage`, with no count query.
84
- const window = args.first + 1;
82
+ // Fetch one extra row: its presence *is* `hasMore`, with no count query. Never past what a
83
+ // declared `.limit()` has left — `first` used to REPLACE that limit, so `?_first=10000` walked a
84
+ // "top 3" to the end of the table.
85
+ const window = Math.min(left, first + 1);
85
86
  const source: SqlSource<object> = base.seek === undefined ? base : base.seek(after, window);
86
87
  const executed = await source.execute();
87
- const scoped = base.seek === undefined ? inTotalOrder(executed, after, shape) : executed;
88
+ const scoped =
89
+ base.seek === undefined ? inTotalOrder(executed, after, shape).slice(0, window) : executed;
88
90
  // The source came from this query's own `sql()`, so its rows are TRow.
89
- const rows = scoped.slice(0, args.first) as unknown as readonly TRow[];
91
+ const rows = scoped.slice(0, first) as unknown as readonly TRow[];
90
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.
91
95
  const seek = last === undefined ? null : seekKeyOf(last, shape);
92
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.
93
100
  const nextCursor =
94
- seek === null
101
+ seek === null || scoped.length <= first
95
102
  ? null
96
103
  : encodeCursor({
97
104
  scope: hash,
98
105
  // The id rides TYPED at the tail of the key — core's `id` slot is a string — so a
99
106
  // numeric or bigint tiebreak compares as a number on the next page, not lexically.
100
- key: [...seek.key, seek.id].map(serializeSortValue),
107
+ // A limited read appends how much of its limit is now spent: a keyset cursor names a
108
+ // position and no count, and the count is the only thing that can end a "top N".
109
+ key: [
110
+ ...[...seek.key, seek.id].map(serializeSortValue),
111
+ ...(shape.limit === null ? [] : [(resumed?.served ?? 0) + rows.length]),
112
+ ],
101
113
  id: String(seek.id),
102
114
  });
103
- return pageOf(rows, nextCursor, scoped.length > args.first);
115
+ return pageOf(rows, nextCursor);
116
+ }
117
+
118
+ /** A decoded cursor: where the page resumes, and how many rows of a declared limit are spent. */
119
+ interface Resumed {
120
+ readonly seek: SeekKey;
121
+ readonly served: number | undefined;
122
+ }
123
+
124
+ /**
125
+ * Rows a declared limit still allows, or no bound at all for a read that declares none.
126
+ *
127
+ * A cursor on a limited read MUST say how many rows came before it. One that does not was minted
128
+ * before the limit bounded the listing (or before the read declared one), and honouring it would
129
+ * start a fresh "top N" below the real one — so it is refused as any other cursor this read did
130
+ * not mint, with core's own instruction: request the first page again.
131
+ */
132
+ function rowsLeft(limit: number | null, resumed: Resumed | null): number {
133
+ if (limit === null) return Number.POSITIVE_INFINITY;
134
+ if (resumed === null) return Math.max(0, limit);
135
+ const { served } = resumed;
136
+ if (served === undefined || !Number.isInteger(served) || served < 1) {
137
+ throw new CursorInvalidError('it does not say how much of this read’s limit is already served');
138
+ }
139
+ return Math.max(0, limit - served);
104
140
  }
105
141
 
106
142
  /**
@@ -128,20 +164,26 @@ function inTotalOrder(
128
164
  shape: QueryShape,
129
165
  ): readonly object[] {
130
166
  const keys = totalOrder(shape.orderBy);
131
- const ordered = [...rows].sort((left, right) => compareRows(left, right, keys));
167
+ const kindOf = kindsOf(shape.entity);
168
+ const ordered = [...rows].sort((left, right) => compareRows(left, right, keys, kindOf));
132
169
  if (after === null) return ordered;
133
- return ordered.filter((row) => isAfterKey(row, after, shape.orderBy));
170
+ return ordered.filter((row) => isAfterKey(row, after, shape.orderBy, kindOf));
134
171
  }
135
172
 
136
173
  /**
137
- * The seek a decoded cursor names. A cursor carrying one more key than the ordering has its typed
138
- * id at the tail; an older cursor, minted before the id rode there, falls back to the string slot.
174
+ * The seek a decoded cursor names. A cursor carrying more keys than the ordering has its typed id
175
+ * next, then — on a limited read — the count of rows served so far; an older cursor, minted before
176
+ * the id rode there, falls back to the string slot.
139
177
  */
140
178
  function seekFromCursor(
141
179
  decoded: { readonly key: readonly unknown[]; readonly id: string },
142
180
  width: number,
143
- ): SeekKey {
181
+ ): Resumed {
144
182
  const key = reviveSortKey(decoded.key);
145
- if (key.length !== width + 1) return { key, id: decoded.id };
146
- return { key: key.slice(0, width), id: key[width] };
183
+ if (key.length <= width) return { seek: { key, id: decoded.id }, served: undefined };
184
+ const count = key[width + 1];
185
+ return {
186
+ seek: { key: key.slice(0, width), id: key[width] },
187
+ served: typeof count === 'number' ? count : undefined,
188
+ };
147
189
  }
@@ -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<
@@ -318,7 +285,6 @@ export type QueryFacade<
318
285
  | 'as'
319
286
  | 'page'
320
287
  | 'live'
321
- | 'tool'
322
288
  | 'client'
323
289
  >;
324
290
 
@@ -372,7 +338,7 @@ export function isQuery(value: unknown): value is AnyQuery {
372
338
  /**
373
339
  * Stamp the export name onto the query the app declared, rather than handing back a
374
340
  * differently-named copy of it. `import { liveFeed } from './live'` is then the query
375
- * 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
376
342
  * rule `nameAction` follows; naming twice is the one case that still needs a twin.
377
343
  */
378
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
+ }