@ultimat3/query 1.2.0 → 2.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/errors.ts CHANGED
@@ -9,9 +9,13 @@ export { CursorInvalidError } from '@ultimat3/core';
9
9
 
10
10
  /** Titles for the framework-wide code table — every one of them owned by this package. */
11
11
  const OWNED_TITLES: Readonly<Record<string, string>> = {
12
+ X_CURSOR_VALUE_UNSUPPORTED: 'a sort value cannot be carried in a cursor',
12
13
  X_MATCHER_UNSUPPORTED: 'live query shape cannot be patched incrementally',
14
+ X_QUERY_CACHE_TTL_INVALID: 'a query declares a cache ttlMs no tier can hold',
15
+ X_QUERY_DEPRECATION_INVALID: 'a query declares a deprecation whose dates cannot be rendered',
13
16
  X_QUERY_DUPLICATE: 'two queries are registered under one name',
14
17
  X_QUERY_FOREIGN: 'a value that is not a query was projected as one',
18
+ X_QUERY_INPUT_UNENCODABLE: 'a query input cannot be carried in a query string',
15
19
  X_QUERY_NOT_PAGEABLE: 'a read returned rows with no id, so a cursor cannot name a position',
16
20
  X_QUERY_POLICY_MISSING: 'a query was registered without a policy',
17
21
  X_QUERY_UNREGISTERED: 'a query was used before it was registered',
@@ -107,6 +111,49 @@ export class QueryForeignError extends UltimateError {
107
111
  }
108
112
  }
109
113
 
114
+ /**
115
+ * A read whose declared input cannot survive its own route. Thrown at `query()`, so the file that
116
+ * wrote it is the file that fails.
117
+ *
118
+ * The `fix` names the three edits that exist, because which one applies depends on what the key
119
+ * means: a structure belongs in an `action`'s JSON body, a filter can be flattened into scalar
120
+ * keys, and an explicitly-null argument is spelled as an absent optional one.
121
+ */
122
+ export class QueryInputUnencodableError extends UltimateError {
123
+ constructor(offender: string) {
124
+ super({
125
+ code: 'X_QUERY_INPUT_UNENCODABLE',
126
+ cause: `${offender}, and a read is served as GET /_x/query/<name> — a query string carries characters, not structures or nulls`,
127
+ fix: 'flatten the key into scalar arguments (status: t.string, limit: t.number), spell an absent value as `.optional()` rather than `t.nullable(...)`, or declare it as an action() if it really needs a JSON body',
128
+ docs: docs('X_QUERY_INPUT_UNENCODABLE'),
129
+ });
130
+ }
131
+ }
132
+
133
+ /**
134
+ * A `cache.ttlMs` no tier will accept, refused at `query()` — so the file that wrote it fails, and
135
+ * not every read of that query for the life of the process.
136
+ *
137
+ * Every `CacheTier` refuses a non-positive or non-finite lease (`assertTtl`, `X_CACHE_TTL_INVALID`)
138
+ * and the read path's only catch absorbs `X_CACHE_TOO_LARGE`, so `ttlMs: Infinity` used to make a
139
+ * working read fail permanently with a cause naming a cache key. The value is a number the author
140
+ * typed, so it is echoed: it is the one fact that repairs the line.
141
+ *
142
+ * The query has no name yet — `query()` runs before `registerQueries()` stamps one — which is why
143
+ * the cause describes the declaration, exactly as `X_QUERY_INPUT_UNENCODABLE` does.
144
+ */
145
+ export class QueryCacheTtlInvalidError extends UltimateError {
146
+ constructor(ttlMs: number) {
147
+ super({
148
+ code: 'X_QUERY_CACHE_TTL_INVALID',
149
+ cause: `a query declares cache.ttlMs as ${ttlMs}, and every cache tier refuses a lease that is not positive and finite`,
150
+ fix: 'set `cache: { ttlMs: 60_000 }` to a positive whole number of milliseconds, or drop ttlMs to take the read cache default',
151
+ docs: docs('X_QUERY_CACHE_TTL_INVALID'),
152
+ meta: { ttlMs },
153
+ });
154
+ }
155
+ }
156
+
110
157
  export class QueryDuplicateError extends UltimateError {
111
158
  constructor(name: string) {
112
159
  super({
@@ -118,17 +165,43 @@ export class QueryDuplicateError extends UltimateError {
118
165
  }
119
166
  }
120
167
 
168
+ /**
169
+ * The `fix` pastes a PERMISSION and the `cause` names the query, and the two are not
170
+ * interchangeable: `can()` takes `resource:verb` (`Permission` is `` `${string}:${string}` ``), so
171
+ * the `can('${name}')` this used to emit did not compile, and `assertPermission` would refuse it
172
+ * the moment the app declared its own set. The reader needs the query's name to find the file and
173
+ * the permission's SHAPE to fill the argument — the same split `@ultimat3/policy`'s own
174
+ * `policyMissing()` already makes.
175
+ */
121
176
  export class QueryPolicyMissingError extends UltimateError {
122
177
  constructor(name: string) {
123
178
  super({
124
179
  code: 'X_QUERY_POLICY_MISSING',
125
180
  cause: `query "${name}" was registered without a policy`,
126
- fix: `add \`policy: can('${name}')\` to the query definition in the file that exports it`,
181
+ fix: `add \`policy: can('<resource>:<verb>')\` to the query() that exports "${name}" — a permission your definePermissions() call declares, never the query's own name — or \`allow('<resource>:<verb>')\` to state that the read is public`,
127
182
  docs: docs('X_QUERY_POLICY_MISSING'),
128
183
  });
129
184
  }
130
185
  }
131
186
 
187
+ /**
188
+ * A `deprecated:` block whose dates cannot become the headers it promises. Refused where the
189
+ * declaration is converted, so every projection that reads it refuses the same value — the mirror
190
+ * of `@ultimat3/action`'s `X_ACTION_DEPRECATION_INVALID`, and for the same reason: a `Sunset`
191
+ * header rendering `Invalid Date` is a contract statement no client can act on.
192
+ */
193
+ export class QueryDeprecationInvalidError extends UltimateError {
194
+ constructor(name: string, field: string, value: string) {
195
+ super({
196
+ code: 'X_QUERY_DEPRECATION_INVALID',
197
+ cause: `query "${name}" declares deprecated.${field} as "${value}", which is not a date`,
198
+ fix: `edit \`deprecated: { ${field}: … }\` on ${name} to an ISO-8601 instant — e.g. '2026-12-31T23:59:59Z'`,
199
+ docs: docs('X_QUERY_DEPRECATION_INVALID'),
200
+ meta: { query: name, field, value },
201
+ });
202
+ }
203
+ }
204
+
132
205
  /**
133
206
  * Thrown when a paged or live read hands back a row with no `id`. The id is the tiebreak that
134
207
  * makes the sort order total, so without one the position a cursor names is ambiguous — and the
@@ -146,6 +219,29 @@ export class QueryNotPageableError extends UltimateError {
146
219
  }
147
220
  }
148
221
 
222
+ /**
223
+ * A sort key the cursor codec cannot carry, refused where the cursor is MINTED.
224
+ *
225
+ * Deliberately not `X_CURSOR_INVALID`: that code means "the cursor you sent is not one of ours",
226
+ * and its fix — request the first page again — repairs nothing here. This is the read's own
227
+ * `orderBy` naming a column whose values are objects, `NaN` or `±Infinity`, so the repair is one
228
+ * edit to the declaration and no retry will ever help. `Date` and `bigint` are NOT in this set:
229
+ * `cursor-value.ts` tags both and revives them, which is the whole reason it exists.
230
+ *
231
+ * The description says the SHAPE and never the value — a cursor's key is row data, and a `cause`
232
+ * reaches the log index and the problem document alike.
233
+ */
234
+ export class CursorValueUnsupportedError extends UltimateError {
235
+ constructor(description: string) {
236
+ super({
237
+ code: 'X_CURSOR_VALUE_UNSUPPORTED',
238
+ cause: `a sort key holds ${description}, which no cursor can carry`,
239
+ fix: 'order by a scalar column — .orderBy("createdAt") or .orderBy("id") — and project the composite value into the row instead',
240
+ docs: docs('X_CURSOR_VALUE_UNSUPPORTED'),
241
+ });
242
+ }
243
+ }
244
+
149
245
  /** The honest fallback: the matcher refuses to guess rather than patch wrongly. */
150
246
  export class MatcherUnsupportedError extends UltimateError {
151
247
  constructor(name: string, feature: string) {
package/src/facade.ts CHANGED
@@ -27,6 +27,8 @@ export function facadeFor<TInput extends StandardSchemaV1, TRow extends object>(
27
27
  policy: def.policy,
28
28
  ...(def.cache === undefined ? {} : { cache: def.cache }),
29
29
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
30
+ ...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
31
+ ...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
30
32
  // `.as()` is impersonation on the one read path: `runQuery` keeps the
31
33
  // surrounding context whole and swaps only the actor.
32
34
  as: (actor, input, options) => runQuery(self(), input, { ...options, actor }),
package/src/http.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Projection: a query becomes `GET /_x/query/<kebab>` — the URL `client.ts` already
3
+ * derives and fetches. The search string is the input, decoded at the wire and judged
4
+ * by `runQuery`, so the endpoint cannot drift from the MCP tool, the live window or a
5
+ * direct server call, and cannot acquire a second authz path while doing it.
6
+ */
7
+
8
+ import { tagKeys } from '@ultimat3/cache';
9
+ import { isUltimateError } from '@ultimat3/core';
10
+ import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
11
+ import { json, problem, toBucket } from '@ultimat3/http';
12
+ import { coerceQuery } from '@ultimat3/schema';
13
+ import type { Deprecation } from './deprecation';
14
+ import { applyHeaders, recordDeprecatedCall, renderDeprecation } from './deprecation';
15
+ import { QueryDeprecationInvalidError } from './errors';
16
+ import { derivePath } from './naming';
17
+ import { policyCapability } from './policy-gate';
18
+ import type { AnyQuery } from './query';
19
+ import { queryName, runQuery } from './read';
20
+
21
+ /**
22
+ * `liveFeed` -> `GET /_x/query/live-feed`. Named for the primitive rather than spelled
23
+ * `toRoute`, because a host mounts this beside `@ultimat3/action`'s and an alias at the
24
+ * import site is a name the reader has to hold — the same reason the tool projection
25
+ * here is `toQueryTool`.
26
+ */
27
+ export function toQueryRoute(target: AnyQuery): Route {
28
+ const name = queryName(target);
29
+ // Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
30
+ // not a surprise on the first read.
31
+ const sunsetting = deprecationHeadersFor(name, target.deprecated);
32
+
33
+ const handler = async (request: UltimateRequest): Promise<Response> => {
34
+ if (sunsetting !== undefined) recordDeprecatedCall('query', name);
35
+ try {
36
+ // Coerced, then validated — two different jobs, and only the first one belongs to a
37
+ // wire. A search string is characters, so `t.number` and `t.boolean` need the HTTP
38
+ // boundary's decode (`coerceQuery` never invents data: what it cannot convert it
39
+ // hands on untouched). VALIDATING here — `request.query(schema)` — would be the
40
+ // second parser: the same read would answer `X_BODY_INVALID` where every other
41
+ // surface answers `X_INPUT_INVALID` with the line that prints its schema. `runQuery`
42
+ // is the one that decides, exactly as it does for a direct server call.
43
+ const input = coerceQuery(target.input, request.queryRaw());
44
+ const response = json(await runQuery(target, input, { surface: 'http' }));
45
+ // On the failure path too, below: a client polling a deprecated read that is currently
46
+ // 403ing still has to learn the read is going away.
47
+ if (sunsetting !== undefined) applyHeaders(response, sunsetting);
48
+ return response;
49
+ } catch (error) {
50
+ // Framework errors carry their own code, status and fix line; anything else is
51
+ // a bug and belongs to the server's error boundary, not to this route.
52
+ if (!isUltimateError(error)) throw error;
53
+ const response = problem(error);
54
+ if (sunsetting !== undefined) applyHeaders(response, sunsetting);
55
+ return response;
56
+ }
57
+ };
58
+
59
+ const meta: RouteMeta = {
60
+ name,
61
+ // `allow(...)` is the only way a read is public, and saying so explicitly is what
62
+ // keeps "forgot the policy" from ever looking like "meant to be readable".
63
+ auth: target.policy.kind === 'allow' ? 'public' : 'required',
64
+ policy: policyCapability(target.policy),
65
+ // `runQuery` is this route's one evaluation and it decides from the PARSED input the
66
+ // rule reads (`ownsOrg(actor, input.orgId)`); the stage would decide the same policy
67
+ // from raw strings, and would need an `authorize` hook wired to decide at all.
68
+ enforcedBy: 'handler',
69
+ // `input` stays absent, deliberately: the pipeline's body stage validates `meta.input`
70
+ // against the BODY, and a GET has none — declaring it would fail every read on an
71
+ // absent body before the handler ran. The schema is not skipped, it is applied in the
72
+ // handler, by the same `runQuery` every other surface goes through.
73
+
74
+ // A read is answered per actor — the policy decided for this caller, and `sql` may
75
+ // scope the rows to them — while the URL names no actor at all. `public` would hand
76
+ // one actor's rows to the next caller of that URL, so a read is `no-store` and a
77
+ // shared cache is something a CDN in front of the app configures knowingly. The tags
78
+ // ride along so a purge can still name the read the tier keys by.
79
+ cache: { mode: 'no-store', tags: tagKeys(target.cache?.tags ?? []) },
80
+ tags: ['query'],
81
+ // Name AND numbers, exactly as an action's route sets them — the name alone selects a bucket
82
+ // the limiter's table never held, so `bucketFor` falls through to `default` (120 burst, 2/s)
83
+ // and a read declaring 5 runs on 120. `withRouteBuckets` registers the pair at construction.
84
+ // `toBucket` is `@ultimat3/http`'s: the limiter owns the maths, and a copy here would be a
85
+ // second conversion able to publish numbers the limiter refuses.
86
+ ...(target.rateLimit === undefined
87
+ ? {}
88
+ : { rateLimit: name, rateLimitBucket: toBucket(name, target.rateLimit) }),
89
+ ...(target.mcp?.description === undefined ? {} : { description: target.mcp.description }),
90
+ };
91
+
92
+ return { method: 'GET', path: derivePath(name), handler, meta };
93
+ }
94
+
95
+ /**
96
+ * The headers this read's `deprecated:` block renders to, or nothing. The successor's URL comes
97
+ * from `derivePath` — the same derivation `client()` uses, never a second one.
98
+ */
99
+ function deprecationHeadersFor(
100
+ name: string,
101
+ deprecated: Deprecation | undefined,
102
+ ): Readonly<Record<string, string>> | undefined {
103
+ if (deprecated === undefined) return undefined;
104
+ const successor =
105
+ deprecated.replacedBy === undefined ? undefined : derivePath(deprecated.replacedBy);
106
+ const rendered = renderDeprecation(deprecated, successor);
107
+ if (!rendered.ok) throw new QueryDeprecationInvalidError(name, rendered.field, rendered.value);
108
+ return rendered.headers;
109
+ }
package/src/index.ts CHANGED
@@ -9,43 +9,59 @@
9
9
  /** Re-exported so a `query` file needs one import, not two. Same object as schema's. */
10
10
  export type { Infer } from '@ultimat3/schema';
11
11
  export { t } from '@ultimat3/schema';
12
- export type { ReadCache, ReadCacheEntry } from './cache';
12
+ export type { QueryCacheScope } from './cache';
13
+ /** `readAuthority` is the ONLY producer of `cacheKeyFor`'s authority — never spell one by hand. */
13
14
  export {
14
15
  cacheKeyFor,
15
- getReadCache,
16
- invalidateQueryTags,
17
- MemoryReadCache,
16
+ DEFAULT_READ_CACHE_TTL_MS,
17
+ readAuthority,
18
+ readOnce,
18
19
  readThrough,
19
20
  requestMemo,
20
- setReadCache,
21
21
  } from './cache';
22
22
  export type {
23
23
  FetchLike,
24
24
  QueryCallOptions,
25
+ QueryClient,
25
26
  QueryClientMethod,
26
27
  QueryClientOptions,
28
+ QueryLike,
29
+ QueryMap,
27
30
  } from './client';
28
- export { queryClientMethodFor } from './client';
31
+ /** `queryClient` is the map-wide read client; `queryClientMethodFor` is what `.client()` binds. */
32
+ export { queryClient, queryClientMethodFor } from './client';
33
+ /** The compat window a retirement gets. Versioning is two deployments, not a router feature. */
34
+ export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
35
+ export { recordDeprecatedCall, renderDeprecation } from './deprecation';
29
36
  export type { QueryProblem } from './errors';
30
37
  export {
31
38
  CursorInvalidError,
39
+ CursorValueUnsupportedError,
32
40
  MatcherUnsupportedError,
33
41
  QueryDeniedError,
42
+ QueryDeprecationInvalidError,
34
43
  QueryDuplicateError,
35
44
  QueryForeignError,
36
45
  QueryInputInvalidError,
46
+ QueryInputUnencodableError,
37
47
  QueryNotPageableError,
38
48
  QueryPolicyMissingError,
39
49
  QueryRequestFailedError,
40
50
  QueryUnregisteredError,
41
51
  } from './errors';
52
+ /** The HTTP projection: `GET /_x/query/<kebab>`, the URL `client()` derives. */
53
+ export { toQueryRoute } from './http';
42
54
  export type { LiveCursor, LiveQuery, ResumeMode, ResumePlan, ToLiveOptions } from './live';
43
55
  export { advanceCursor, liveEpoch, planResume, seekOf, toLiveQuery } from './live';
44
56
  export type { ChangeEvent, ChangeOp, Patch } from './matcher';
45
57
  export { assertMatchable, match, positionFor } from './matcher';
46
58
  export type { QueryToolDescriptor, QueryToolReadOptions } from './mcp-tool';
47
59
  export { isExposed, toQueryTool, toQueryTools } from './mcp-tool';
48
- export { derivePath, toKebabCase, toToolName } from './naming';
60
+ /**
61
+ * Path derivation only. There is no `toToolName`: an MCP tool is served under the export name
62
+ * verbatim, and an exported derivation would be a second way to spell one tool.
63
+ */
64
+ export { derivePath, toKebabCase } from './naming';
49
65
  /**
50
66
  * The shapes `query.page(input, { first, after })` takes and answers with. `paginate` itself is
51
67
  * deliberately unexported: a page is the read's own answer, and a second, importable way to ask
@@ -54,7 +70,8 @@ export { derivePath, toKebabCase, toToolName } from './naming';
54
70
  */
55
71
  export type { Page, PaginateArgs } from './pagination';
56
72
  export type { QueryPolicy, QuerySubject, QuerySurface } from './policy-gate';
57
- export { actorOf, guard, policyCapability } from './policy-gate';
73
+ /** `policyCapability` is the display label; `policyPermissions` is what a report MATCHES on. */
74
+ export { actorOf, guard, policyCapability, policyPermissions } from './policy-gate';
58
75
  export type {
59
76
  AnyQuery,
60
77
  Query,
@@ -64,11 +81,13 @@ export type {
64
81
  QueryFacade,
65
82
  QueryMcp,
66
83
  QueryOptions,
84
+ QueryRateLimit,
67
85
  SourceOptions,
68
86
  } from './query';
69
87
  export { describeQuery, isQuery, nameQuery, query, queryHash } from './query';
70
88
  /** The one read path. `defOf` stays unexported — that is the enforcement. */
71
89
  export { queryName, runQuery, sourceFor } from './read';
90
+
72
91
  export {
73
92
  describeQueries,
74
93
  getQuery,
@@ -78,7 +97,19 @@ export {
78
97
  resetRegistry,
79
98
  } from './registry';
80
99
  export type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape';
81
- export { compareRows, compareValues, matchesFilter, matchesFilters, seekKeyOf } from './shape';
100
+ /**
101
+ * `isNull` is the one definition of SQL NULL a custom `SqlSource` has to agree with, and
102
+ * `totalOrder` is the one definition of the order it must serve a page in.
103
+ */
104
+ export {
105
+ compareRows,
106
+ compareValues,
107
+ isNull,
108
+ matchesFilter,
109
+ matchesFilters,
110
+ seekKeyOf,
111
+ totalOrder,
112
+ } from './shape';
82
113
  export type { RowProvider, SqlSource, SqlText } from './source';
83
114
  /** `isAfterKey` is the one definition of "after this position" — both seek paths use it. */
84
115
  export { Builder, from, isAfterKey } from './source';
@@ -0,0 +1,74 @@
1
+ // Single responsibility: what a read's `input:` may be, given that a read is projected to
2
+ // `GET /_x/query/<kebab>` and a query string is characters.
3
+ //
4
+ // `client.ts` encoded a nested member as `JSON.stringify(item)` and skipped a `null`; the server's
5
+ // own route decodes with `coerceQuery`, which has no inverse for either — `case 'object'` hands
6
+ // the raw value back untouched and there is no `JSON.parse` on that path. So the typed client
7
+ // type-checked calls the server then answered `X_INPUT_INVALID` for, which is precisely the
8
+ // failure `client.ts`'s header claims to prevent ("a compile error in a Solid component rather
9
+ // than a 404 at runtime").
10
+ //
11
+ // The fix is the DECLARATION, not the encoder. Teaching `coerceQuery` to `JSON.parse` a string
12
+ // would make the one HTTP-boundary decoder invent structure for every surface that shares it —
13
+ // forms and route params included — against that file's own rule that it "never invents data";
14
+ // and a `null` sentinel would be a reserved string colliding with the legitimate value `"null"`.
15
+ // Refusing here means the client can never encode something the server rejects, because such an
16
+ // input cannot be written.
17
+
18
+ import { type SchemaNode, tryIntrospect } from '@ultimat3/schema';
19
+ import { QueryInputUnencodableError } from './errors';
20
+
21
+ /** Node kinds whose value is a structure, not characters. `money` is `{ minor, currency }`. */
22
+ const STRUCTURAL = new Set(['object', 'record', 'money']);
23
+
24
+ /**
25
+ * The first key this input could not put on a wire, or `undefined`. One key, not a list: a fix
26
+ * line names one edit, and the next declaration attempt reports the next key.
27
+ */
28
+ function unencodable(node: SchemaNode): string | undefined {
29
+ for (const [key, child] of Object.entries(node.properties ?? {})) {
30
+ // Required AND nullable: `searchOf` sends nothing for a `null`, and absence is what the far
31
+ // side then sees — so the value the caller explicitly chose fails validation on arrival.
32
+ // Optional or defaulted, absence is already the schema's own answer and nothing is lost.
33
+ if (child.nullable === true && child.optional !== true && child.hasDefault !== true) {
34
+ return `${key} is nullable and required`;
35
+ }
36
+ const kind = structuralKind(child);
37
+ if (kind !== undefined) return `${key} is a ${kind}`;
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ /** The structural kind inside a member, through an array or a union. */
43
+ function structuralKind(node: SchemaNode): string | undefined {
44
+ if (STRUCTURAL.has(node.kind)) return node.kind;
45
+ if (node.kind === 'array' && node.items !== undefined) return structuralKind(node.items);
46
+ if (node.kind === 'union') {
47
+ for (const member of node.anyOf ?? []) {
48
+ const kind = structuralKind(member);
49
+ if (kind !== undefined) return kind;
50
+ }
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ /**
56
+ * Refused at `query()`, which is the first import of the authoring file — the same place
57
+ * `@ultimat3/schema` refuses a `discriminatedUnion` it could never route, and for the same reason:
58
+ * the declaration is wrong for every input, so the earliest honest moment is where it is written.
59
+ *
60
+ * A schema this package cannot introspect is left alone rather than guessed at: `tryIntrospect`
61
+ * answering `undefined` means a foreign Standard Schema, and refusing one would make the seam
62
+ * `configureSchemaProvider` exists for unusable.
63
+ */
64
+ export function assertEncodableInput(input: unknown): void {
65
+ const node = tryIntrospect(input);
66
+ if (node === undefined) return;
67
+ if (node.kind !== 'object') {
68
+ // `searchOf` answers `''` for anything that is not a plain object, so the server receives no
69
+ // arguments at all — a read declared this way is called with an input nothing ever carries.
70
+ throw new QueryInputUnencodableError(`the input is a ${node.kind}, not an object`);
71
+ }
72
+ const offender = unencodable(node);
73
+ if (offender !== undefined) throw new QueryInputUnencodableError(offender);
74
+ }
package/src/live.ts CHANGED
@@ -2,8 +2,11 @@
2
2
  * What `live: true` actually produces: the descriptor @ultimat3/realtime
3
3
  * subscribes to. It carries the SQL shape (for the matcher), the dependency set
4
4
  * (which entities/tags a change feed must touch to matter), the policy (re-run
5
- * per subscriber, never once for a channel) and a cursor for cheap reconnects.
5
+ * per subscriber, never once for a channel) and a cursor for cheap reconnects
6
+ * and it runs that read (`execute`), so the shared window and the matcher over it
7
+ * come from one build of one `(query, input)` rather than two that agree by luck.
6
8
  */
9
+ import { tagKeys } from '@ultimat3/cache';
7
10
  import type { Ctx } from '@ultimat3/core';
8
11
  import type { StandardSchemaV1 } from '@ultimat3/schema';
9
12
  import type { Patch } from './matcher';
@@ -15,7 +18,6 @@ import { queryHash } from './query';
15
18
  import { queryName, sourceFor } from './read';
16
19
  import type { QueryShape, SeekKey } from './shape';
17
20
  import { seekKeyOf } from './shape';
18
- import { tagKeys } from './tags';
19
21
 
20
22
  /**
21
23
  * Reconnect state. Deliberately tiny — an id, a sort key and a version — because
@@ -58,6 +60,17 @@ export interface LiveQuery {
58
60
  readonly policy: QueryPolicy;
59
61
  readonly sqlText: string;
60
62
  readonly limit: number | null;
63
+ /**
64
+ * The read this descriptor describes, run. It is the *same* source the shape, the reads and the
65
+ * SQL text were taken from, which is the point: a subscriber's window and the matcher that
66
+ * patches it have to come from one build of one `(query, input)`. A caller that wanted rows and
67
+ * called `sourceFor` itself would be a second build — twice the parse, twice the `sql()`, and
68
+ * two descriptions of one read that are only equal by luck.
69
+ *
70
+ * It executes on every call rather than memoising: a client joining an existing subscription
71
+ * must see the rows as they are now, not the window someone else opened.
72
+ */
73
+ execute(): Promise<readonly object[]>;
61
74
  /** Per-subscriber authorization. Called on subscribe *and* on every fanout. */
62
75
  authorize(subject: QuerySubject): Promise<void>;
63
76
  initialCursor(rows: readonly object[]): LiveCursor;
@@ -81,6 +94,13 @@ export interface ToLiveOptions {
81
94
  readonly enforce?: boolean;
82
95
  }
83
96
 
97
+ /**
98
+ * Why a shared window builds with no policy evaluation. Spelled once, here, so the reason a sync
99
+ * node gives `sourceFor` cannot drift from the one `ToLiveOptions.enforce` documents.
100
+ */
101
+ const SHARED_WINDOW_REASON =
102
+ 'the shared subject-less window has no subscriber to decide about; authorize() runs per subscriber';
103
+
84
104
  /** Changing the build changes the epoch, which forces reconnects to refetch. */
85
105
  export function liveEpoch(): string {
86
106
  return Bun.env['X_BUILD_ID'] ?? 'dev';
@@ -94,7 +114,10 @@ export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends
94
114
  const name = queryName(target);
95
115
  const source = await sourceFor(target, input, {
96
116
  ...(options.ctx === undefined ? {} : { ctx: options.ctx }),
97
- ...(options.enforce === undefined ? {} : { enforce: options.enforce }),
117
+ // The boolean stays this layer's spelling because it has exactly ONE reason, stated on
118
+ // `ToLiveOptions.enforce` above; `sourceFor` takes that reason as a string so every skipped
119
+ // policy in the framework is greppable with its justification attached.
120
+ ...(options.enforce === false ? { unenforced: SHARED_WINDOW_REASON } : {}),
98
121
  surface: 'live',
99
122
  });
100
123
  const shape = source.shape();
@@ -115,6 +138,7 @@ export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends
115
138
  policy,
116
139
  sqlText: source.toSQL().sql,
117
140
  limit: shape.limit,
141
+ execute: () => source.execute(),
118
142
  authorize: async (subject) => {
119
143
  guard(policy, subject, 'live');
120
144
  },
package/src/matcher.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  * filters + orderBy + limit; anything else throws X_MATCHER_UNSUPPORTED, because
5
5
  * an honest refusal beats a silently wrong result set.
6
6
  */
7
- import { MatcherUnsupportedError } from './errors';
7
+ import { MatcherUnsupportedError, QueryNotPageableError } from './errors';
8
8
  import type { QueryShape } from './shape';
9
- import { compareRows, matchesFilters } from './shape';
9
+ import { compareRows, matchesFilters, totalOrder } from './shape';
10
10
  import { columnOf } from './stable';
11
11
 
12
12
  export type ChangeOp = 'insert' | 'update' | 'delete';
@@ -55,8 +55,8 @@ export function match<TRow extends object>(
55
55
  assertMatchable(name, shape);
56
56
  if (event.entity !== shape.entity) return [];
57
57
 
58
- const id = idOf(event.row);
59
- const index = rows.findIndex((row) => idOf(row) === id);
58
+ const id = idOf(event.row, shape.entity);
59
+ const index = rows.findIndex((row) => idOf(row, shape.entity) === id);
60
60
  const inSet = index >= 0;
61
61
  const belongs = event.op !== 'delete' && matchesFilters(event.row, shape.filters);
62
62
 
@@ -74,8 +74,18 @@ export function match<TRow extends object>(
74
74
  compareRows(event.row, current, shape.orderBy) !== 0;
75
75
  if (!moved) return [{ kind: 'update', position: index, row: event.row }];
76
76
 
77
- // A move keeps the window full, so it never needs a refill.
78
77
  const without = [...rows.slice(0, index), ...rows.slice(index + 1)];
78
+ // A move to the TAIL of a full window is a position only the server can fill. `insert()` places
79
+ // the row among the `limit - 1` rows the client still holds, so its position can never reach
80
+ // `shape.limit` and the `position >= shape.limit` bail below is unreachable on this path — the
81
+ // row was re-inserted INSIDE the window. Proven with `limit: 3`, window `[a:1, b:2, c:3]` and a
82
+ // server also holding `d:4, e:5`: moving `a` to `99` rendered `[b, c, a:99]` where the true
83
+ // window is `[b, c, d]`. Whether the moved row is still in the window is the server's answer
84
+ // too, so the refill covers both.
85
+ const wasFull = shape.limit !== null && rows.length >= shape.limit;
86
+ if (wasFull && positionFor(shape, without, event.row) >= without.length) {
87
+ return removeAt<TRow>(shape, index, id, true);
88
+ }
79
89
  return [...removeAt<TRow>(shape, index, id, false), ...insert(shape, without, event.row)];
80
90
  }
81
91
 
@@ -91,7 +101,7 @@ function insert<TRow extends object>(
91
101
  if (shape.limit !== null && rows.length >= shape.limit) {
92
102
  const evicted = rows[shape.limit - 1];
93
103
  if (evicted !== undefined) {
94
- patches.push({ kind: 'remove', position: shape.limit, id: idOf(evicted) });
104
+ patches.push({ kind: 'remove', position: shape.limit, id: idOf(evicted, shape.entity) });
95
105
  }
96
106
  }
97
107
  return patches;
@@ -109,18 +119,35 @@ function removeAt<TRow extends object>(
109
119
  return patches;
110
120
  }
111
121
 
112
- /** Insertion index under the query's ordering; append when the query is unordered. */
122
+ /**
123
+ * Insertion index under the ordering the source serves — `totalOrder`, not the declared keys.
124
+ *
125
+ * A page arrives as `order by <declared keys>, "id" asc` and `isAfterKey` reads the next one the
126
+ * same way, so a row tied on every declared key belongs where its id puts it. Placing it at the
127
+ * end of the tie group instead is a position the database would never return: the client renders
128
+ * one order, a re-read answers another, and the cursor cut from the window's tail skips the ties
129
+ * the matcher pushed past it.
130
+ *
131
+ * An unordered query has no position to get wrong — SQL promises none — so it appends.
132
+ */
113
133
  export function positionFor<TRow extends object>(
114
134
  shape: QueryShape,
115
135
  rows: readonly TRow[],
116
136
  row: TRow,
117
137
  ): number {
118
138
  if (shape.orderBy.length === 0) return rows.length;
119
- const found = rows.findIndex((current) => compareRows(row, current, shape.orderBy) < 0);
139
+ const order = totalOrder(shape.orderBy);
140
+ const found = rows.findIndex((current) => compareRows(row, current, order) < 0);
120
141
  return found === -1 ? rows.length : found;
121
142
  }
122
143
 
123
- function idOf(row: object): string {
144
+ /**
145
+ * The row's identity, and the tiebreak `positionFor` sorts by. Refused when absent for the reason
146
+ * `seekKeyOf` refuses it: `String(undefined)` is `"undefined"`, an id every id-less row shares, so
147
+ * one row's patch lands on another's position and a `remove` names a row no client holds.
148
+ */
149
+ function idOf(row: object, entity: string): string {
124
150
  const value = columnOf(row, 'id');
151
+ if (value === undefined || value === null) throw new QueryNotPageableError(entity);
125
152
  return typeof value === 'string' ? value : String(value);
126
153
  }
package/src/mcp-tool.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  */
6
6
 
7
7
  import type { Actor, Ctx } from '@ultimat3/core';
8
+ import { isMcpExposed } from '@ultimat3/core';
8
9
  import type { JsonSchema } from '@ultimat3/schema';
9
10
  import { toMcpInputSchema } from '@ultimat3/schema';
10
- import { toToolName } from './naming';
11
11
  import type { QueryPolicy } from './policy-gate';
12
12
  import type { AnyQuery } from './query';
13
13
  import { queryName, sourceFor } from './read';
@@ -20,6 +20,11 @@ export interface QueryToolReadOptions {
20
20
  }
21
21
 
22
22
  export interface QueryToolDescriptor {
23
+ /**
24
+ * The export name VERBATIM, and identical to `query` below. `@ultimat3/mcp` serves a read
25
+ * under `queryName(target)` and answers `tools/call` for nothing else, so a snake_cased
26
+ * descriptor named a tool the server had never heard of — which it did until 2026-08.
27
+ */
23
28
  readonly name: string;
24
29
  /** The query's `mcp.description`, or its name when the author gave none. */
25
30
  readonly description: string;
@@ -38,7 +43,7 @@ export interface QueryToolDescriptor {
38
43
  export function toQueryTool(target: AnyQuery): QueryToolDescriptor {
39
44
  const name = queryName(target);
40
45
  return {
41
- name: toToolName(name),
46
+ name,
42
47
  description: target.mcp?.description ?? name,
43
48
  query: name,
44
49
  policy: target.policy,
@@ -57,11 +62,12 @@ export function toQueryTool(target: AnyQuery): QueryToolDescriptor {
57
62
  }
58
63
 
59
64
  /**
60
- * Opt-in, unlike an action's tool: a read hands rows to an agent, so silence
61
- * exposes nothing. `mcp: { expose: true }` is the whole opt-in.
65
+ * Opt-in: a read hands rows to an agent, so silence exposes nothing. `mcp: { expose: true }` is
66
+ * the whole opt-in `isMcpExposed` from `@ultimat3/core` is the framework's one answer, and an
67
+ * action's tool is now decided by the same call rather than by a second, looser rule.
62
68
  */
63
69
  export function isExposed(target: AnyQuery): boolean {
64
- return target.mcp?.expose === true;
70
+ return isMcpExposed(target.mcp);
65
71
  }
66
72
 
67
73
  /** Deterministic order — the tool list is part of the agent-visible contract. */