@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/CLAUDE.md +54 -34
- package/README.md +102 -26
- 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 +4 -5
- package/src/errors.ts +6 -3
- package/src/facade.ts +6 -7
- package/src/http.ts +39 -25
- package/src/index.ts +27 -55
- package/src/input-shape.ts +1 -1
- package/src/live.ts +19 -3
- package/src/openapi.ts +24 -17
- package/src/page-controls.ts +5 -9
- package/src/page-keys.ts +1 -1
- package/src/pagination.ts +34 -35
- package/src/policy-gate.ts +17 -49
- package/src/query.ts +22 -58
- 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 +8 -8
- package/src/source.ts +2 -2
- 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 -102
- package/src/naming.ts +0 -22
package/src/read.ts
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Ctx } from '@ultimat3/core';
|
|
9
9
|
import {
|
|
10
|
+
actorOf,
|
|
10
11
|
anonymousActor,
|
|
11
12
|
assert,
|
|
12
|
-
|
|
13
|
+
ctxOf,
|
|
13
14
|
finiteOption,
|
|
14
15
|
logger,
|
|
15
16
|
runWithContext,
|
|
@@ -20,6 +21,8 @@ import {
|
|
|
20
21
|
} from '@ultimat3/core';
|
|
21
22
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
22
23
|
import { formatPath, validateAsync } from '@ultimat3/schema';
|
|
24
|
+
import type { ReadAudit, ReadTrace } from './audit-gate';
|
|
25
|
+
import { audited, auditedBuild, readAuditFor } from './audit-gate';
|
|
23
26
|
import {
|
|
24
27
|
cacheKeyFor,
|
|
25
28
|
DEFAULT_READ_CACHE_TTL_MS,
|
|
@@ -29,8 +32,9 @@ import {
|
|
|
29
32
|
readThrough,
|
|
30
33
|
} from './cache';
|
|
31
34
|
import { QueryForeignError, QueryInputInvalidError, QueryUnregisteredError } from './errors';
|
|
32
|
-
import {
|
|
35
|
+
import { guardQuery, guardQueryBeforeInput } from './policy-gate';
|
|
33
36
|
import type { AnyQuery, AnyQueryDef, Query, QueryOptions, SourceOptions } from './query';
|
|
37
|
+
import { spendReadLimit } from './rate-limit-gate';
|
|
34
38
|
import type { SqlSource } from './source';
|
|
35
39
|
|
|
36
40
|
/**
|
|
@@ -84,7 +88,12 @@ export function runQuery(
|
|
|
84
88
|
raw: unknown,
|
|
85
89
|
options: QueryOptions = {},
|
|
86
90
|
): Promise<readonly object[]> {
|
|
87
|
-
return asActor(options, (ctx) =>
|
|
91
|
+
return asActor(options, (ctx) => {
|
|
92
|
+
const audit = auditFor(target, ctx, options);
|
|
93
|
+
return audit === null
|
|
94
|
+
? readRows(target, raw, ctx, options, null)
|
|
95
|
+
: audited(audit, () => readRows(target, raw, ctx, options, audit.trace));
|
|
96
|
+
});
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
/**
|
|
@@ -97,7 +106,45 @@ export function sourceFor(
|
|
|
97
106
|
raw: unknown,
|
|
98
107
|
options: SourceOptions = {},
|
|
99
108
|
): Promise<SqlSource<object>> {
|
|
100
|
-
return asActor(options, (ctx) =>
|
|
109
|
+
return asActor(options, (ctx) => {
|
|
110
|
+
const audit = auditFor(target, ctx, options);
|
|
111
|
+
// A source handed out is executed by its caller — `@ultimat3/mcp`'s served tool among them —
|
|
112
|
+
// so an audited read records per `execute()`, and a build that refuses records here.
|
|
113
|
+
return audit === null
|
|
114
|
+
? buildSource(target, raw, ctx, options, null)
|
|
115
|
+
: auditedBuild(audit, () => buildSource(target, raw, ctx, options, audit.trace));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One whole call over a built source, for a projection inside this package whose call is more
|
|
121
|
+
* than one `execute()` — `paginate`, which may answer a spent limit without executing at all. The
|
|
122
|
+
* record covers the build and `use` together, so the call is recorded exactly once either way.
|
|
123
|
+
*/
|
|
124
|
+
export function readCall<T>(
|
|
125
|
+
target: AnyQuery,
|
|
126
|
+
raw: unknown,
|
|
127
|
+
options: SourceOptions,
|
|
128
|
+
use: (source: SqlSource<object>) => Promise<T>,
|
|
129
|
+
): Promise<T> {
|
|
130
|
+
return asActor(options, async (ctx) => {
|
|
131
|
+
const audit = auditFor(target, ctx, options);
|
|
132
|
+
if (audit === null) return use(await buildSource(target, raw, ctx, options, null));
|
|
133
|
+
return audited(audit, async () =>
|
|
134
|
+
use(await buildSource(target, raw, ctx, options, audit.trace)),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Resolved before the parse, so an audited read with no sink refuses while it has read nothing. */
|
|
140
|
+
function auditFor(target: AnyQuery, ctx: Ctx, options: SourceOptions): ReadAudit | null {
|
|
141
|
+
return readAuditFor(
|
|
142
|
+
defOf(target).audit,
|
|
143
|
+
options.unenforced,
|
|
144
|
+
queryName(target),
|
|
145
|
+
ctx,
|
|
146
|
+
options.surface ?? 'server',
|
|
147
|
+
);
|
|
101
148
|
}
|
|
102
149
|
|
|
103
150
|
/**
|
|
@@ -118,7 +165,7 @@ function asActor<T>(options: QueryOptions, run: (ctx: Ctx) => Promise<T>): Promi
|
|
|
118
165
|
const inChild = (): Promise<T> => run(useContext());
|
|
119
166
|
const base = options.ctx ?? tryUseContext();
|
|
120
167
|
return base === undefined
|
|
121
|
-
? runWithContext(
|
|
168
|
+
? runWithContext(ctxOf(patch), inChild)
|
|
122
169
|
: runWithContext(base, () => withChildContext(patch, inChild));
|
|
123
170
|
}
|
|
124
171
|
|
|
@@ -136,6 +183,7 @@ function readRows(
|
|
|
136
183
|
raw: unknown,
|
|
137
184
|
ctx: Ctx,
|
|
138
185
|
options: QueryOptions,
|
|
186
|
+
trace: ReadTrace | null,
|
|
139
187
|
): Promise<readonly object[]> {
|
|
140
188
|
const name = queryName(target);
|
|
141
189
|
return withSpan(`query.${name}`, async (span) => {
|
|
@@ -150,7 +198,7 @@ function readRows(
|
|
|
150
198
|
// read is filled — and which of the two a slow read took is the first thing to ask.
|
|
151
199
|
'ultimate.cached': defOf(target).cache !== undefined,
|
|
152
200
|
});
|
|
153
|
-
const rows = await readRowsIn(target, raw, ctx, options);
|
|
201
|
+
const rows = await readRowsIn(target, raw, ctx, options, trace);
|
|
154
202
|
span.setAttribute('ultimate.rows', rows.length);
|
|
155
203
|
return rows;
|
|
156
204
|
});
|
|
@@ -161,11 +209,18 @@ async function readRowsIn(
|
|
|
161
209
|
raw: unknown,
|
|
162
210
|
ctx: Ctx,
|
|
163
211
|
options: QueryOptions,
|
|
212
|
+
trace: ReadTrace | null,
|
|
164
213
|
): Promise<readonly object[]> {
|
|
165
214
|
const def = defOf(target);
|
|
166
215
|
const name = queryName(target);
|
|
167
|
-
const source = await buildSource(target, raw, ctx, options);
|
|
168
|
-
|
|
216
|
+
const source = await buildSource(target, raw, ctx, options, trace);
|
|
217
|
+
// Whether THIS call executed, for the audit record: a memo or tier hit never calls `read`, and
|
|
218
|
+
// its record says so (`replayed: true`) rather than being skipped.
|
|
219
|
+
if (trace !== null) trace.replayed = true;
|
|
220
|
+
const read = (): Promise<readonly object[]> => {
|
|
221
|
+
if (trace !== null) trace.replayed = false;
|
|
222
|
+
return source.execute();
|
|
223
|
+
};
|
|
169
224
|
// The source came from this query's own `sql()`, so its rows are TRow throughout —
|
|
170
225
|
// which is what the typed overload above states, and this body never has to assert.
|
|
171
226
|
const tags = def.cache?.tags ?? [];
|
|
@@ -201,6 +256,7 @@ async function buildSource(
|
|
|
201
256
|
raw: unknown,
|
|
202
257
|
ctx: Ctx,
|
|
203
258
|
options: SourceOptions,
|
|
259
|
+
trace: ReadTrace | null,
|
|
204
260
|
): Promise<SqlSource<object>> {
|
|
205
261
|
const def = defOf(target);
|
|
206
262
|
const name = queryName(target);
|
|
@@ -208,15 +264,36 @@ async function buildSource(
|
|
|
208
264
|
// The actor half of the policy first, exactly as `invoke` does it: a reader refused whatever
|
|
209
265
|
// they send learns nothing about this read's input schema from an `X_INPUT_INVALID`.
|
|
210
266
|
if (unenforced === undefined) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
267
|
+
try {
|
|
268
|
+
guardQueryBeforeInput(
|
|
269
|
+
def.policy,
|
|
270
|
+
{ actor: actorOf(ctx), ctx, query: name },
|
|
271
|
+
options.surface ?? 'server',
|
|
272
|
+
);
|
|
273
|
+
} catch (denial) {
|
|
274
|
+
// The record still names what was attempted, as an action's does: the denial is already
|
|
275
|
+
// decided, so parsing now tells the CALLER nothing, and an unparseable payload is no input.
|
|
276
|
+
if (trace !== null) trace.input = await parsedOrNothing(def.input, raw);
|
|
277
|
+
throw denial;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// The ONE place a read's declared `rateLimit:` is spent for every surface it runs on — the
|
|
281
|
+
// route, a paged read, the MCP tool. After the actor half (a refused reader learns that, not a
|
|
282
|
+
// 429), before the parse and `sql()`, which are the work being limited. An unenforced build has
|
|
283
|
+
// no caller to charge: the shared live window spends per subscriber (`spendQueryLimit`).
|
|
284
|
+
if (unenforced === undefined) {
|
|
285
|
+
await spendReadLimit(name, def.rateLimit, {
|
|
286
|
+
actor: ctx.actor,
|
|
287
|
+
surface: options.surface ?? 'server',
|
|
288
|
+
clientAddress: options.clientAddress,
|
|
289
|
+
onRateLimit: options.onRateLimit,
|
|
290
|
+
nowMs: ctx.now().getTime(),
|
|
291
|
+
});
|
|
216
292
|
}
|
|
217
293
|
const input = await validate(def.input, raw, name);
|
|
294
|
+
if (trace !== null) trace.input = input;
|
|
218
295
|
if (unenforced === undefined) {
|
|
219
|
-
|
|
296
|
+
guardQuery(
|
|
220
297
|
def.policy,
|
|
221
298
|
{ actor: actorOf(ctx), input, ctx, query: name },
|
|
222
299
|
options.surface ?? 'server',
|
|
@@ -244,6 +321,12 @@ async function buildSource(
|
|
|
244
321
|
return options.surface === 'live' && source.total !== undefined ? source.total() : source;
|
|
245
322
|
}
|
|
246
323
|
|
|
324
|
+
/** The parse without the refusal — only for the record of a read already refused on other grounds. */
|
|
325
|
+
async function parsedOrNothing(schema: StandardSchemaV1, raw: unknown): Promise<unknown> {
|
|
326
|
+
const result = await validateAsync(schema, raw);
|
|
327
|
+
return result.issues === undefined ? result.value : undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
247
330
|
async function validate(schema: StandardSchemaV1, raw: unknown, name: string): Promise<unknown> {
|
|
248
331
|
const result = await validateAsync(schema, raw);
|
|
249
332
|
if (result.issues !== undefined) {
|
package/src/record-answer.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import type { RecordRows, Row } from '@ultimat3/core';
|
|
11
11
|
import { encodeRecordEnvelope, RECORDS_HEADER } from '@ultimat3/core';
|
|
12
12
|
import { hasEntityRows, rowsOf } from '@ultimat3/entity';
|
|
13
|
-
import {
|
|
13
|
+
import { jsonResponse } from '@ultimat3/http';
|
|
14
14
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
15
15
|
import type { Page } from './pagination';
|
|
16
16
|
|
|
@@ -23,10 +23,12 @@ export type RecordAnswer = (answer: readonly object[] | Page<object>) => Respons
|
|
|
23
23
|
* `Page` — is the route's, not the declaration's.
|
|
24
24
|
*/
|
|
25
25
|
export function recordAnswerFor(rows: StandardSchemaV1 | undefined): RecordAnswer {
|
|
26
|
-
if (!answersRecords(rows)) return (answer) =>
|
|
26
|
+
if (!answersRecords(rows)) return (answer) => jsonResponse(answer);
|
|
27
27
|
return (answer) => {
|
|
28
28
|
const records = collect(rows, isPage(answer) ? answer.rows : answer);
|
|
29
|
-
return
|
|
29
|
+
return jsonResponse(encodeRecordEnvelope(answer, records), {
|
|
30
|
+
headers: { [RECORDS_HEADER]: '1' },
|
|
31
|
+
});
|
|
30
32
|
};
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -36,9 +38,11 @@ export function recordAnswerFor(rows: StandardSchemaV1 | undefined): RecordAnswe
|
|
|
36
38
|
* answers for a single output, so the transport unwraps both the same way.
|
|
37
39
|
*/
|
|
38
40
|
export function recordRowAnswerFor(rows: StandardSchemaV1 | undefined): (row: object) => Response {
|
|
39
|
-
if (!answersRecords(rows)) return (row) =>
|
|
41
|
+
if (!answersRecords(rows)) return (row) => jsonResponse(row);
|
|
40
42
|
return (row) =>
|
|
41
|
-
|
|
43
|
+
jsonResponse(encodeRecordEnvelope(row, collect(rows, [row])), {
|
|
44
|
+
headers: { [RECORDS_HEADER]: '1' },
|
|
45
|
+
});
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
/** The one predicate the route and the OpenAPI projection both ask: can this read answer records? */
|
package/src/registry.ts
CHANGED
package/src/search.ts
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
* syntax, and the tenant predicate is never optional.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { type Ctx, finiteOption } from '@ultimat3/core';
|
|
9
|
+
import { type Ctx, finiteOption, type McpExposureDeclaration } from '@ultimat3/core';
|
|
10
10
|
import type { InferOutput, Shape, Simplify } from '@ultimat3/schema';
|
|
11
11
|
import { t } from '@ultimat3/schema';
|
|
12
12
|
import { QueryInputInvalidError } from './errors';
|
|
13
13
|
import type { QueryPolicy } from './policy-gate';
|
|
14
|
-
import type { QueryCache,
|
|
14
|
+
import type { QueryCache, QueryRateLimit } from './query';
|
|
15
15
|
import { query, queryName } from './query';
|
|
16
16
|
import type { SeekKey } from './shape';
|
|
17
17
|
import type { Builder, SqlSource, SqlText } from './source';
|
|
@@ -60,7 +60,7 @@ export interface SearchDef<S extends Shape, Row extends object> {
|
|
|
60
60
|
readonly termMax?: number;
|
|
61
61
|
readonly page?: SearchPage;
|
|
62
62
|
readonly cache?: QueryCache;
|
|
63
|
-
readonly mcp?:
|
|
63
|
+
readonly mcp?: McpExposureDeclaration;
|
|
64
64
|
readonly rateLimit?: QueryRateLimit;
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -84,7 +84,7 @@ const limitSchema = (max: number, fallback: number) =>
|
|
|
84
84
|
* `SqlSource` is handed a `SeekKey` (the previous page's sort VALUES) and the chain wants its own
|
|
85
85
|
* signed, plan-scoped string, and there is no way to mint one from the other here. Falling through
|
|
86
86
|
* to `paginate`'s in-memory slice would cut inside the one page the provider fetched and report
|
|
87
|
-
* `
|
|
87
|
+
* `hasMore: false` at its edge — rows served on no page at all, which is the defect 12.0.0 spent
|
|
88
88
|
* a release removing from the timestamp seek. So it is a refusal naming the alternative: page with
|
|
89
89
|
* the entity chain's own `.search(term).after(cursor)`, or raise this read's `limit`.
|
|
90
90
|
*
|
|
@@ -93,7 +93,7 @@ const limitSchema = (max: number, fallback: number) =>
|
|
|
93
93
|
* `assert` they were `X_INVARIANT`, a 500 that blamed the server and paged whoever watches it.
|
|
94
94
|
*
|
|
95
95
|
* **The refusal is on the rows that would be CUT, never on the window** (`As of 2026-08-26`). It
|
|
96
|
-
* used to serve `first` rows, mint an `
|
|
96
|
+
* used to serve `first` rows, mint an `nextCursor` and report `hasMore: true` — a connection
|
|
97
97
|
* protocol saying "call me again with this" for a call the cursor assert is guaranteed to throw on.
|
|
98
98
|
* A first repair demanded `first >= limit` and refused the window itself, which refuses the
|
|
99
99
|
* framework's OWN default pair: `limit` defaults to 20 and `first` arrives from a client
|
|
@@ -103,7 +103,7 @@ const limitSchema = (max: number, fallback: number) =>
|
|
|
103
103
|
*
|
|
104
104
|
* So the condition is the one thing that is actually wrong: the read answered MORE rows than the
|
|
105
105
|
* window carries, and this seam has no second page to put the rest on. Below that, the page is
|
|
106
|
-
* whole and `
|
|
106
|
+
* whole and `hasMore` is false by construction — the true stop signal, and no cursor is ever
|
|
107
107
|
* handed back that a second call is guaranteed to refuse.
|
|
108
108
|
*
|
|
109
109
|
* **`seek` narrows the WINDOW and never the ORDER, and that is the whole reason this wrapper takes a
|
|
@@ -135,7 +135,7 @@ const onePage = <Row extends object>(
|
|
|
135
135
|
`search of ${entity} serves one page: a relevance-filtered read has no cursor this layer can carry — raise this read's limit, or page the entity chain itself with .search(term).after(cursor)`,
|
|
136
136
|
);
|
|
137
137
|
}
|
|
138
|
-
// `paginate` asks for `first + 1` — the extra row IS `
|
|
138
|
+
// `paginate` asks for `first + 1` — the extra row IS `hasMore` — so the window it names is
|
|
139
139
|
// one wider than the page it will serve.
|
|
140
140
|
return windowOf(base, read, entity, served, window - 1);
|
|
141
141
|
},
|
|
@@ -157,7 +157,7 @@ const windowOf = <Row extends object>(
|
|
|
157
157
|
read: string,
|
|
158
158
|
entity: string,
|
|
159
159
|
served: number,
|
|
160
|
-
/** Rows `paginate` will serve. It fetched one more, to decide `
|
|
160
|
+
/** Rows `paginate` will serve. It fetched one more, to decide `hasMore`. */
|
|
161
161
|
first: number,
|
|
162
162
|
): SqlSource<Row> => {
|
|
163
163
|
const windowed = base.limit(first + 1);
|
package/src/source.ts
CHANGED
|
@@ -52,7 +52,7 @@ export type RowProvider<TRow> =
|
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* In-memory reference source. `from<Post>('posts', rows).where({ orgId }).orderBy('createdAt')`
|
|
55
|
-
* generates real SQL text for `
|
|
55
|
+
* generates real SQL text for `explainQuery()` while executing against the provided rows.
|
|
56
56
|
*/
|
|
57
57
|
export function from<TRow extends object>(entity: string, rows: RowProvider<TRow>): Builder<TRow> {
|
|
58
58
|
return new Builder<TRow>(entity, rows, [], [], null, null, []);
|
|
@@ -74,7 +74,7 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
74
74
|
/**
|
|
75
75
|
* One equality filter per key, and the clauses come out in **lexical key order** — not the order
|
|
76
76
|
* the object literal was typed in. The generated text is something callers compare across runs:
|
|
77
|
-
* `
|
|
77
|
+
* `explainQuery()` prints it, `LiveQuery.sqlText` caches it, and tests pin it. Reordering two keys in
|
|
78
78
|
* a call site must not rewrite the statement.
|
|
79
79
|
*/
|
|
80
80
|
where(equals: Readonly<Record<string, unknown>>): Builder<TRow> {
|
package/src/sql.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SQL transparency. An agent that can read the SQL a query generates can fix a
|
|
3
3
|
* slow or wrong read by itself; a query builder that hides its output forces it
|
|
4
|
-
* to guess. `
|
|
4
|
+
* to guess. `explainQuery` is the read path for `/_x` and for `x db explain`.
|
|
5
5
|
*/
|
|
6
6
|
import type { Ctx } from '@ultimat3/core';
|
|
7
7
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
@@ -18,10 +18,10 @@ export interface ExplainResult extends SqlText {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Policy is deliberately NOT enforced here: `
|
|
21
|
+
* Policy is deliberately NOT enforced here: `explainQuery` never returns rows, and the
|
|
22
22
|
* surfaces that expose it (`/_x`, the CLI) are admin-gated in their own right.
|
|
23
23
|
*/
|
|
24
|
-
export async function
|
|
24
|
+
export async function explainQuery<TInput extends StandardSchemaV1, TRow extends object>(
|
|
25
25
|
target: Query<TInput, TRow, boolean>,
|
|
26
26
|
input: unknown,
|
|
27
27
|
ctx?: Ctx,
|
package/src/stable.ts
CHANGED
|
@@ -11,9 +11,6 @@
|
|
|
11
11
|
|
|
12
12
|
import { isJsonObject } from '@ultimat3/core';
|
|
13
13
|
|
|
14
|
-
/** Re-exported, not re-declared: `./stable` stays this package's one import path for it. */
|
|
15
|
-
export { isJsonObject };
|
|
16
|
-
|
|
17
14
|
/** Column read that works for interfaces without an index signature. */
|
|
18
15
|
export function columnOf(row: object, column: string): unknown {
|
|
19
16
|
const record: unknown = row;
|
package/src/subscribes.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The relations a live read is patched from: the one thing about a query that cannot be derived,
|
|
3
|
-
* and the two assertions that keep the declaration honest
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* and the two assertions that keep the declaration honest — a stale name is a declaration its
|
|
4
|
+
* author reads as true. Since #518 `x db gen` grants these relations no `REPLICA IDENTITY FULL`:
|
|
5
|
+
* every entity is keyed, and a keyed table is correct on DEFAULT (`x db gen` validates the names).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { QuerySubscribesDriftError, QuerySubscribesInvalidError } from './errors';
|
package/src/type-pins.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Compile-time pins for this package's public surface. Source, not a `.test.ts`, on purpose:
|
|
2
|
+
// `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a
|
|
3
|
+
// type-level claim written in one can never fail. This module emits nothing and exports nothing
|
|
4
|
+
// anybody imports — a regression here is a build error, the only enforcement that counts.
|
|
5
|
+
|
|
6
|
+
import type { McpExposureDeclaration } from '@ultimat3/core';
|
|
7
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
+
import type { AnyQuery, Query, QueryDef } from './query';
|
|
9
|
+
|
|
10
|
+
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
11
|
+
type Assert<T extends true> = T;
|
|
12
|
+
|
|
13
|
+
type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* IDENTICAL, not merely mutually assignable: an object type without an optional key is assignable
|
|
17
|
+
* to one with it and back, so `Equals` cannot see an optional field added on one side only — and
|
|
18
|
+
* every field of the `mcp` block is optional but `expose`.
|
|
19
|
+
*/
|
|
20
|
+
type Identical<A, B> =
|
|
21
|
+
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* O-tool, 25.0.0: a query projects no MCP tool of its own — `@ultimat3/mcp`'s `toolFrom` is
|
|
25
|
+
* the one projection, and a tier-3 `.tool()` could only ever be a second one. A `tool` member
|
|
26
|
+
* coming back on either view flips these to `false`.
|
|
27
|
+
*/
|
|
28
|
+
export type _AQueryHasNoToolTwin = Assert<Equals<Extract<keyof AnyQuery, 'tool'>, never>>;
|
|
29
|
+
export type _ATypedQueryHasNoToolTwin = Assert<Equals<Extract<keyof Query, 'tool'>, never>>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 25.0.0 (plan 101, M5): the deprecation TYPES are `@ultimat3/core`'s alone, as the helpers are.
|
|
33
|
+
* A runtime test cannot see a type re-export, so each is an expected error: a re-export coming
|
|
34
|
+
* back makes the directive unused, which `tsc` refuses.
|
|
35
|
+
*/
|
|
36
|
+
// @ts-expect-error — `Deprecation` is imported from `@ultimat3/core`, never from this barrel.
|
|
37
|
+
export type _NoDeprecationReexport = import('./index').Deprecation;
|
|
38
|
+
// @ts-expect-error — `DeprecationField` is imported from `@ultimat3/core`.
|
|
39
|
+
export type _NoDeprecationFieldReexport = import('./index').DeprecationField;
|
|
40
|
+
// @ts-expect-error — `DeprecationRender` is imported from `@ultimat3/core`.
|
|
41
|
+
export type _NoDeprecationRenderReexport = import('./index').DeprecationRender;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 25.0.0: a query's `mcp` block is core's ONE `McpExposureDeclaration`, on the declaration and on
|
|
45
|
+
* both views. `QueryMcp`, `QueryMcpAnnotations` and `QueryListParams` restated it; a twin coming
|
|
46
|
+
* back on the barrel makes its directive unused, and a field drifting flips the pins to `false`.
|
|
47
|
+
*/
|
|
48
|
+
export type _QueryDeclaresCoresBlock = Assert<
|
|
49
|
+
Identical<NonNullable<QueryDef<StandardSchemaV1, object>['mcp']>, McpExposureDeclaration>
|
|
50
|
+
>;
|
|
51
|
+
export type _AnyQueryCarriesCoresBlock = Assert<
|
|
52
|
+
Identical<NonNullable<AnyQuery['mcp']>, McpExposureDeclaration>
|
|
53
|
+
>;
|
|
54
|
+
export type _TypedQueryCarriesCoresBlock = Assert<
|
|
55
|
+
Identical<NonNullable<Query['mcp']>, McpExposureDeclaration>
|
|
56
|
+
>;
|
|
57
|
+
// @ts-expect-error — `QueryMcp` is gone: the block is `@ultimat3/core`'s `McpExposureDeclaration`.
|
|
58
|
+
export type _NoQueryMcpTwin = import('./index').QueryMcp;
|
|
59
|
+
// @ts-expect-error — `QueryMcpAnnotations` is core's `McpAnnotationHints`.
|
|
60
|
+
export type _NoQueryMcpAnnotationsTwin = import('./index').QueryMcpAnnotations;
|
|
61
|
+
// @ts-expect-error — `QueryListParams` is core's `McpListParams`.
|
|
62
|
+
export type _NoQueryListParamsTwin = import('./index').QueryListParams;
|
package/src/deprecation.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A declared retirement, rendered as the two headers the standards already define — RFC 9745
|
|
3
|
-
* `Deprecation` and RFC 8594 `Sunset` — plus the successor link. Pure string and date maths, and
|
|
4
|
-
* deliberately throw-free: each package raises its own `X_*` for a date it cannot render.
|
|
5
|
-
*
|
|
6
|
-
* `@ultimat3/action` carries the twin of this file. Both are tier 3, so neither may import the
|
|
7
|
-
* other, and the shared home is `@ultimat3/http` (tier 2) once that package grows one — the same
|
|
8
|
-
* compromise `naming.ts` is ported under.
|
|
9
|
-
*/
|
|
10
|
-
import { counter } from '@ultimat3/core';
|
|
11
|
-
|
|
12
|
-
export interface Deprecation {
|
|
13
|
-
/** When it was deprecated. ISO-8601, e.g. `'2026-08-01T00:00:00Z'`. */
|
|
14
|
-
readonly since: string;
|
|
15
|
-
/** When it stops answering. ISO-8601 — the date `Sunset` publishes and clients plan against. */
|
|
16
|
-
readonly sunset: string;
|
|
17
|
-
/** The export name of the replacement, projected to a `rel="successor-version"` link. */
|
|
18
|
-
readonly replacedBy?: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export type DeprecationField = 'since' | 'sunset';
|
|
22
|
-
|
|
23
|
-
export type DeprecationRender =
|
|
24
|
-
| {
|
|
25
|
-
readonly ok: true;
|
|
26
|
-
readonly headers: Readonly<Record<string, string>>;
|
|
27
|
-
/** The same facts as data, for the descriptor and the manifest. */
|
|
28
|
-
readonly meta: Readonly<Record<string, string>>;
|
|
29
|
-
}
|
|
30
|
-
| { readonly ok: false; readonly field: DeprecationField; readonly value: string };
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* How many calls a deprecated declaration is still taking — the number "can we remove it yet?"
|
|
34
|
-
* needs. Attributes are the primitive and the declared NAME, both bounded by the size of the
|
|
35
|
-
* codebase; a caller id here would be an unbounded series.
|
|
36
|
-
*/
|
|
37
|
-
const deprecatedCalls = counter('deprecated_calls_total', {
|
|
38
|
-
unit: '{call}',
|
|
39
|
-
description: 'Calls served by a declaration that has been deprecated, by primitive and name',
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
export function recordDeprecatedCall(primitive: 'action' | 'query', name: string): void {
|
|
43
|
-
deprecatedCalls.add(1, { primitive, name });
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* `Deprecation` is a structured-field Date (`@` + unix seconds, RFC 9745); `Sunset` is an
|
|
48
|
-
* HTTP-date (IMF-fixdate, RFC 8594). Two spellings of one instant because two RFCs chose
|
|
49
|
-
* differently — never render one in the other's format, and never emit `Invalid Date`.
|
|
50
|
-
*/
|
|
51
|
-
export function renderDeprecation(
|
|
52
|
-
deprecation: Deprecation,
|
|
53
|
-
successorPath: string | undefined,
|
|
54
|
-
): DeprecationRender {
|
|
55
|
-
const since = Date.parse(deprecation.since);
|
|
56
|
-
if (Number.isNaN(since)) return { ok: false, field: 'since', value: deprecation.since };
|
|
57
|
-
const sunset = Date.parse(deprecation.sunset);
|
|
58
|
-
if (Number.isNaN(sunset)) return { ok: false, field: 'sunset', value: deprecation.sunset };
|
|
59
|
-
|
|
60
|
-
const headers: Record<string, string> = {
|
|
61
|
-
deprecation: `@${Math.floor(since / 1000)}`,
|
|
62
|
-
sunset: new Date(sunset).toUTCString(),
|
|
63
|
-
};
|
|
64
|
-
// The successor's URL, derived by the caller from the same `naming.ts` the client uses — a
|
|
65
|
-
// link this file built from the export name would be the second URL derivation in the package.
|
|
66
|
-
if (successorPath !== undefined) {
|
|
67
|
-
headers['link'] = `<${successorPath}>; rel="successor-version"`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const meta: Record<string, string> = {
|
|
71
|
-
since: new Date(since).toISOString(),
|
|
72
|
-
sunset: new Date(sunset).toISOString(),
|
|
73
|
-
...(deprecation.replacedBy === undefined ? {} : { replacedBy: deprecation.replacedBy }),
|
|
74
|
-
};
|
|
75
|
-
return { ok: true, headers, meta };
|
|
76
|
-
}
|
package/src/mcp-tool.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A query as an MCP read tool. Its `read` goes through `sourceFor` — the same
|
|
3
|
-
* authorized front half the HTTP read and the live subscription use — so the tool
|
|
4
|
-
* cannot drift from the endpoint and cannot acquire a second authz path.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { Actor, Ctx } from '@ultimat3/core';
|
|
8
|
-
import { isMcpExposed } from '@ultimat3/core';
|
|
9
|
-
import type { WireJsonSchema } from '@ultimat3/schema';
|
|
10
|
-
import { toWireSchema } from '@ultimat3/schema';
|
|
11
|
-
import type { QueryPolicy } from './policy-gate';
|
|
12
|
-
import type { AnyQuery } from './query';
|
|
13
|
-
import { queryName, sourceFor } from './read';
|
|
14
|
-
import { listQueries } from './registry';
|
|
15
|
-
import { readAnswer } from './single-answer';
|
|
16
|
-
|
|
17
|
-
export interface QueryToolReadOptions {
|
|
18
|
-
readonly ctx?: Ctx;
|
|
19
|
-
/** The agent behind the call. `null` is the signed-out caller. */
|
|
20
|
-
readonly actor?: Actor | null;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* What `read()` resolves to, decided by the declaration: a list read's rows, a `single: true`
|
|
25
|
-
* read's one row. Only a schema-erased query (`AnyQuery`, a mixed registry) answers the union —
|
|
26
|
-
* a KNOWN list read typed as "rows or a row" made `.map` on its own answer a compile error.
|
|
27
|
-
*/
|
|
28
|
-
export type QueryToolAnswer<TSingle extends boolean> = [TSingle] extends [true]
|
|
29
|
-
? object
|
|
30
|
-
: [TSingle] extends [false]
|
|
31
|
-
? readonly object[]
|
|
32
|
-
: readonly object[] | object;
|
|
33
|
-
|
|
34
|
-
export interface QueryToolDescriptor<TSingle extends boolean = boolean> {
|
|
35
|
-
/**
|
|
36
|
-
* The export name VERBATIM, and identical to `query` below. `@ultimat3/mcp` serves a read
|
|
37
|
-
* under `queryName(target)` and answers `tools/call` for nothing else, so a snake_cased
|
|
38
|
-
* descriptor named a tool the server had never heard of — which it did until 2026-08.
|
|
39
|
-
*/
|
|
40
|
-
readonly name: string;
|
|
41
|
-
/** The query's `mcp.description`, or its name when the author gave none. */
|
|
42
|
-
readonly description: string;
|
|
43
|
-
readonly query: string;
|
|
44
|
-
/**
|
|
45
|
-
* The query's own policy object, not a copy — `tool().policy === query.policy`
|
|
46
|
-
* is what makes "an MCP call cannot reach a different authz path" checkable.
|
|
47
|
-
*/
|
|
48
|
-
readonly policy: QueryPolicy;
|
|
49
|
-
/** What `@ultimat3/mcp`'s `tools/list` serves for this read — `@ultimat3/schema`'s wire subset. */
|
|
50
|
-
readonly inputSchema: WireJsonSchema;
|
|
51
|
-
/** Always false: a query reads. Drives the rate-limit bucket in @ultimat3/mcp. */
|
|
52
|
-
readonly mutates: false;
|
|
53
|
-
/**
|
|
54
|
-
* The rows — or, for a `single: true` read, the one row itself and `X_NOT_FOUND` when there is
|
|
55
|
-
* none. The route's answer on both counts: an agent handed `[]` for a missing id reads "found,
|
|
56
|
-
* and empty" where the same read over HTTP said 404.
|
|
57
|
-
*/
|
|
58
|
-
read(input: unknown, options?: QueryToolReadOptions): Promise<QueryToolAnswer<TSingle>>;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function toQueryTool<TSingle extends boolean = boolean>(
|
|
62
|
-
target: AnyQuery & { readonly single?: TSingle },
|
|
63
|
-
): QueryToolDescriptor<TSingle> {
|
|
64
|
-
const name = queryName(target);
|
|
65
|
-
return {
|
|
66
|
-
name,
|
|
67
|
-
description: target.mcp?.description ?? name,
|
|
68
|
-
query: name,
|
|
69
|
-
policy: target.policy,
|
|
70
|
-
inputSchema: toWireSchema(target.input),
|
|
71
|
-
mutates: false,
|
|
72
|
-
read: async (input, options = {}) => {
|
|
73
|
-
// Executed without the cache tiers on purpose: an agent diffing two tool
|
|
74
|
-
// calls must be reading the rows, not a TTL.
|
|
75
|
-
const source = await sourceFor(target, input, {
|
|
76
|
-
...(options.ctx === undefined ? {} : { ctx: options.ctx }),
|
|
77
|
-
...(options.actor === undefined ? {} : { actor: options.actor }),
|
|
78
|
-
});
|
|
79
|
-
// The declaration decides the shape, and `TSingle` is that declaration as a type.
|
|
80
|
-
return readAnswer(target, await source.execute()) as QueryToolAnswer<TSingle>;
|
|
81
|
-
},
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Opt-in: a read hands rows to an agent, so silence exposes nothing. `mcp: { expose: true }` is
|
|
87
|
-
* the whole opt-in — `isMcpExposed` from `@ultimat3/core` is the framework's one answer, and an
|
|
88
|
-
* action's tool is now decided by the same call rather than by a second, looser rule.
|
|
89
|
-
*/
|
|
90
|
-
export function isExposed(target: AnyQuery): boolean {
|
|
91
|
-
return isMcpExposed(target.mcp);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Deterministic order — the tool list is part of the agent-visible contract. */
|
|
95
|
-
export function toQueryTools(
|
|
96
|
-
queries: readonly AnyQuery[] = listQueries(),
|
|
97
|
-
): readonly QueryToolDescriptor[] {
|
|
98
|
-
return queries
|
|
99
|
-
.filter(isExposed)
|
|
100
|
-
.map(toQueryTool)
|
|
101
|
-
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
102
|
-
}
|
package/src/naming.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The naming rule for reads, as this package has always exported it: a query's export name
|
|
3
|
-
* derives its HTTP path. The rule itself is `@ultimat3/core`'s `client-paths.ts` — one tier-0 file
|
|
4
|
-
* that `action`, `query` and `realtime` all read, so the path cannot be derived two ways. The MCP
|
|
5
|
-
* tool name is NOT derived — it is the export name verbatim, so there is one name to call.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { queryPath, splitWords } from '@ultimat3/core/page';
|
|
9
|
-
|
|
10
|
-
export { splitWords };
|
|
11
|
-
|
|
12
|
-
/** `liveFeed` -> `live-feed`. */
|
|
13
|
-
export function toKebabCase(name: string): string {
|
|
14
|
-
return splitWords(name).join('-');
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* `liveFeed` -> `/_x/query/live-feed`, read with `GET …?orgId=…`. A read is a GET under its own
|
|
19
|
-
* prefix so a CDN, a browser cache and a log line can all tell it apart from an action's
|
|
20
|
-
* `POST /api/...` without parsing a body.
|
|
21
|
-
*/
|
|
22
|
-
export const derivePath: (name: string) => string = queryPath;
|