@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/CLAUDE.md +75 -37
- package/README.md +147 -32
- package/package.json +8 -8
- package/src/audit-errors.ts +43 -0
- package/src/audit-gate.ts +177 -0
- package/src/cache.ts +4 -4
- package/src/client.ts +19 -6
- package/src/column-kinds.ts +28 -0
- package/src/cursor-value.ts +3 -3
- package/src/errors.ts +6 -3
- package/src/facade.ts +6 -7
- package/src/http.ts +43 -33
- package/src/index.ts +37 -61
- package/src/input-shape.ts +21 -1
- package/src/live.ts +19 -3
- package/src/matcher.ts +14 -7
- package/src/openapi.ts +24 -17
- package/src/page-controls.ts +5 -9
- package/src/page-keys.ts +1 -1
- package/src/pagination.ts +88 -46
- package/src/policy-gate.ts +17 -49
- package/src/query.ts +22 -56
- package/src/rate-limit-gate.ts +71 -0
- package/src/read.ts +97 -14
- package/src/record-answer.ts +9 -5
- package/src/registry.ts +1 -1
- package/src/search.ts +53 -35
- package/src/shape.ts +52 -85
- package/src/single-answer.ts +22 -0
- package/src/source.ts +26 -11
- package/src/sql.ts +3 -3
- package/src/stable.ts +0 -3
- package/src/subscribes.ts +3 -3
- package/src/type-pins.ts +62 -0
- package/src/deprecation.ts +0 -76
- package/src/mcp-tool.ts +0 -81
- package/src/naming.ts +0 -22
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 {
|
|
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
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
77
|
-
//
|
|
78
|
-
const
|
|
79
|
-
|
|
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`
|
|
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* `
|
|
84
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
138
|
-
*
|
|
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
|
-
):
|
|
181
|
+
): Resumed {
|
|
144
182
|
const key = reviveSortKey(decoded.key);
|
|
145
|
-
if (key.length
|
|
146
|
-
|
|
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
|
}
|
package/src/policy-gate.ts
CHANGED
|
@@ -5,20 +5,20 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
8
|
-
import { assertNever
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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 `
|
|
53
|
+
* never `X_INPUT_INVALID` with the read's input schema in it. Undecided passes to `guardQuery`.
|
|
50
54
|
*/
|
|
51
|
-
export function
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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.
|
|
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
|
+
}
|