@ultimat3/query 1.1.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/CLAUDE.md +393 -0
- package/README.md +179 -10
- package/package.json +7 -5
- package/src/cache.ts +180 -71
- package/src/client.ts +83 -2
- package/src/cursor-value.ts +65 -0
- package/src/deprecation.ts +81 -0
- package/src/errors.ts +97 -1
- package/src/facade.ts +2 -0
- package/src/http.ts +109 -0
- package/src/index.ts +40 -9
- package/src/input-shape.ts +74 -0
- package/src/live.ts +27 -3
- package/src/matcher.ts +36 -9
- package/src/mcp-tool.ts +11 -5
- package/src/naming.ts +5 -9
- package/src/pagination.ts +25 -3
- package/src/policy-gate.ts +13 -2
- package/src/query.ts +108 -8
- package/src/read.ts +116 -16
- package/src/shape.ts +103 -6
- package/src/source.ts +137 -49
- package/src/sql.ts +2 -2
- package/src/stable.ts +23 -12
- package/src/tags.ts +0 -17
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('
|
|
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 {
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
61
|
-
*
|
|
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
|
|
70
|
+
return isMcpExposed(target.mcp);
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
/** Deterministic order — the tool list is part of the agent-visible contract. */
|