@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/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
- createContext,
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 { actorOf, guard, guardBeforeInput } from './policy-gate';
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) => readRows(target, raw, ctx, options));
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) => buildSource(target, raw, ctx, options));
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(createContext(patch), inChild)
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
- const read = (): Promise<readonly object[]> => source.execute();
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
- guardBeforeInput(
212
- def.policy,
213
- { actor: actorOf(ctx), ctx, query: name },
214
- options.surface ?? 'server',
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
- guard(
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) {
@@ -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 { json } from '@ultimat3/http';
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) => json(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 json(encodeRecordEnvelope(answer, records), { headers: { [RECORDS_HEADER]: '1' } });
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) => json(row);
41
+ if (!answersRecords(rows)) return (row) => jsonResponse(row);
40
42
  return (row) =>
41
- json(encodeRecordEnvelope(row, collect(rows, [row])), { headers: { [RECORDS_HEADER]: '1' } });
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
@@ -64,6 +64,6 @@ export function describeQueries(): readonly QueryDescriptor[] {
64
64
  }
65
65
 
66
66
  /** Test-only. Production registers once at boot. */
67
- export function resetRegistry(): void {
67
+ export function resetQueries(): void {
68
68
  registry.clear();
69
69
  }
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, QueryMcp, QueryRateLimit } from './query';
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?: QueryMcp;
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
- * `hasNextPage: false` at its edge — rows served on no page at all, which is the defect 12.0.0 spent
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 `endCursor` and report `hasNextPage: true` — a connection
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 `hasNextPage` is false by construction — the true stop signal, and no cursor is ever
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 `hasNextPage` — so the window it names 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 `hasNextPage`. */
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 `explain()` while executing against the provided rows.
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
- * `explain()` prints it, `LiveQuery.sqlText` caches it, and tests pin it. Reordering two keys in
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. `explain` is the read path for `/_x` and for `x db explain`.
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: `explain` never returns rows, and the
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 explain<TInput extends StandardSchemaV1, TRow extends object>(
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. `x db gen` grants those relations
4
- * `REPLICA IDENTITY FULL`, so a name that has gone stale is worse than none — it grants it to the
5
- * wrong table and leaves the right one unable to produce a patch, in silence (#357).
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';
@@ -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;
@@ -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;