@ultimat3/query 1.2.0 → 3.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 +404 -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 +56 -13
- 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
|
@@ -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,13 +55,13 @@ 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
|
|
|
63
63
|
if (event.op === 'delete' || (inSet && !belongs)) {
|
|
64
|
-
return inSet ? removeAt(shape, index, id, true) : [];
|
|
64
|
+
return inSet ? removeAt(shape, index, id, true, rows.length) : [];
|
|
65
65
|
}
|
|
66
66
|
if (!belongs) return [];
|
|
67
67
|
if (!inSet) return insert(shape, rows, event.row);
|
|
@@ -74,9 +74,22 @@ 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)];
|
|
79
|
-
|
|
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, rows.length);
|
|
88
|
+
}
|
|
89
|
+
return [
|
|
90
|
+
...removeAt<TRow>(shape, index, id, false, rows.length),
|
|
91
|
+
...insert(shape, without, event.row),
|
|
92
|
+
];
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
function insert<TRow extends object>(
|
|
@@ -91,36 +104,66 @@ function insert<TRow extends object>(
|
|
|
91
104
|
if (shape.limit !== null && rows.length >= shape.limit) {
|
|
92
105
|
const evicted = rows[shape.limit - 1];
|
|
93
106
|
if (evicted !== undefined) {
|
|
94
|
-
patches.push({ kind: 'remove', position: shape.limit, id: idOf(evicted) });
|
|
107
|
+
patches.push({ kind: 'remove', position: shape.limit, id: idOf(evicted, shape.entity) });
|
|
95
108
|
}
|
|
96
109
|
}
|
|
97
110
|
return patches;
|
|
98
111
|
}
|
|
99
112
|
|
|
113
|
+
/**
|
|
114
|
+
* `held` is how many rows the window actually holds, and it is the whole condition on the refill.
|
|
115
|
+
*
|
|
116
|
+
* A refill says the tail is unknown to the client, and it is answered by a full re-read: the bridge
|
|
117
|
+
* folds it into `BridgeResult.refill`, and the fanout then sends NO patch frame that round —
|
|
118
|
+
* suppressing the `remove` beside it and leaving a deleted row on screen until the next change to
|
|
119
|
+
* the same query. A window under `limit` has no unknown tail: the source served fewer rows than it
|
|
120
|
+
* was allowed to, so what the client holds IS the result set. Unconditional, this also named a
|
|
121
|
+
* position no result set has — `limit: 50` over three rows emitted `{ refill, from: 49 }`.
|
|
122
|
+
*/
|
|
100
123
|
function removeAt<TRow extends object>(
|
|
101
124
|
shape: QueryShape,
|
|
102
125
|
index: number,
|
|
103
126
|
id: string,
|
|
104
127
|
refill: boolean,
|
|
128
|
+
held: number,
|
|
105
129
|
): readonly Patch<TRow>[] {
|
|
106
130
|
const patches: Patch<TRow>[] = [{ kind: 'remove', position: index, id }];
|
|
107
|
-
// A
|
|
108
|
-
if (refill && shape.limit !== null
|
|
131
|
+
// A window that WAS full is now one row short, and that row lives on the server.
|
|
132
|
+
if (refill && shape.limit !== null && held >= shape.limit) {
|
|
133
|
+
patches.push({ kind: 'refill', from: shape.limit - 1 });
|
|
134
|
+
}
|
|
109
135
|
return patches;
|
|
110
136
|
}
|
|
111
137
|
|
|
112
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* Insertion index under the ordering the source serves — `totalOrder`, not the declared keys.
|
|
140
|
+
*
|
|
141
|
+
* A page arrives as `order by <declared keys>, "id" asc` and `isAfterKey` reads the next one the
|
|
142
|
+
* same way, so a row tied on every declared key belongs where its id puts it. Placing it at the
|
|
143
|
+
* end of the tie group instead is a position the database would never return: the client renders
|
|
144
|
+
* one order, a re-read answers another, and the cursor cut from the window's tail skips the ties
|
|
145
|
+
* the matcher pushed past it.
|
|
146
|
+
*
|
|
147
|
+
* An unordered query has no position to get wrong — SQL promises none — so it appends.
|
|
148
|
+
*/
|
|
113
149
|
export function positionFor<TRow extends object>(
|
|
114
150
|
shape: QueryShape,
|
|
115
151
|
rows: readonly TRow[],
|
|
116
152
|
row: TRow,
|
|
117
153
|
): number {
|
|
118
154
|
if (shape.orderBy.length === 0) return rows.length;
|
|
119
|
-
const
|
|
155
|
+
const order = totalOrder(shape.orderBy);
|
|
156
|
+
const found = rows.findIndex((current) => compareRows(row, current, order) < 0);
|
|
120
157
|
return found === -1 ? rows.length : found;
|
|
121
158
|
}
|
|
122
159
|
|
|
123
|
-
|
|
160
|
+
/**
|
|
161
|
+
* The row's identity, and the tiebreak `positionFor` sorts by. Refused when absent for the reason
|
|
162
|
+
* `seekKeyOf` refuses it: `String(undefined)` is `"undefined"`, an id every id-less row shares, so
|
|
163
|
+
* one row's patch lands on another's position and a `remove` names a row no client holds.
|
|
164
|
+
*/
|
|
165
|
+
function idOf(row: object, entity: string): string {
|
|
124
166
|
const value = columnOf(row, 'id');
|
|
167
|
+
if (value === undefined || value === null) throw new QueryNotPageableError(entity);
|
|
125
168
|
return typeof value === 'string' ? value : String(value);
|
|
126
169
|
}
|
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. */
|
package/src/naming.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one naming rule for reads: a query's export name derives its HTTP path
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The one naming rule for reads: a query's export name derives its HTTP path.
|
|
3
|
+
* Pure string math, so the browser client derives the same URL without importing
|
|
4
|
+
* a byte of server code. Ported rather than imported from @ultimat3/action: that
|
|
5
|
+
* package is the same tier, and tiers never go sideways. The MCP tool name is NOT
|
|
6
|
+
* derived — it is the export name verbatim, so there is one name to call.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
/** Every read is served under one prefix, so a router can claim it in one rule. */
|
|
@@ -31,8 +32,3 @@ export function toKebabCase(name: string): string {
|
|
|
31
32
|
export function derivePath(name: string): string {
|
|
32
33
|
return `${QUERY_PREFIX}/${toKebabCase(name)}`;
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
-
/** MCP tool names are `snake_case`: `liveFeed` -> `live_feed`. */
|
|
36
|
-
export function toToolName(name: string): string {
|
|
37
|
-
return splitWords(name).join('_');
|
|
38
|
-
}
|
package/src/pagination.ts
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* The codec is `@ultimat3/core`'s. This file only decides what a cursor is bound
|
|
10
10
|
* to — `queryHash(name, input)` — so one read's cursor cannot page another.
|
|
11
11
|
*/
|
|
12
|
-
import { decodeCursor, encodeCursor } from '@ultimat3/core';
|
|
12
|
+
import { assert, decodeCursor, encodeCursor } from '@ultimat3/core';
|
|
13
13
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
14
|
+
import { reviveSortKey, serializeSortValue } from './cursor-value';
|
|
14
15
|
import type { Query, SourceOptions } from './query';
|
|
15
16
|
import { queryHash, queryName, sourceFor } from './query';
|
|
16
17
|
import type { QueryShape, SeekKey } from './shape';
|
|
@@ -29,6 +30,15 @@ export interface PaginateArgs extends SourceOptions {
|
|
|
29
30
|
readonly after?: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The largest page a read will serve. A TWIN of `@ultimat3/entity`'s `MAX_PAGE_SIZE` — this
|
|
35
|
+
* package holds no dependency on that one, the same compromise `naming.ts` and `deprecation.ts`
|
|
36
|
+
* are ported under — and it exists for the same reason: `first` reaches here straight from an
|
|
37
|
+
* action's input or a route parameter, so `args.first + 1` bound whatever a client sent and one
|
|
38
|
+
* request could ask for five million rows.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_PAGE_SIZE = 10_000;
|
|
41
|
+
|
|
32
42
|
/**
|
|
33
43
|
* One page. Push-down when the source implements `seek()`; otherwise the rows are
|
|
34
44
|
* sliced after execution and the source is doing more work than it should.
|
|
@@ -38,12 +48,21 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
|
|
|
38
48
|
input: unknown,
|
|
39
49
|
args: PaginateArgs,
|
|
40
50
|
): Promise<Page<TRow>> {
|
|
51
|
+
assert(
|
|
52
|
+
Number.isInteger(args.first) && args.first >= 1 && args.first <= MAX_PAGE_SIZE,
|
|
53
|
+
`first must be a whole number of rows between 1 and ${MAX_PAGE_SIZE}`,
|
|
54
|
+
`read.page(input, { first: Math.min(requested, ${MAX_PAGE_SIZE}) }) — or bound it in the input schema: t.number.int().min(1).max(50)`,
|
|
55
|
+
);
|
|
41
56
|
const name = queryName(target);
|
|
42
57
|
const hash = queryHash(name, input);
|
|
43
58
|
// The scope is this read plus these arguments: a cursor from anywhere else is
|
|
44
59
|
// already `X_CURSOR_INVALID` by the time it gets here.
|
|
45
60
|
const decoded = args.after === undefined ? null : decodeCursor(args.after, hash);
|
|
46
|
-
|
|
61
|
+
// Revived to the types the columns hold, never left as the strings JSON handed back: a `Date`
|
|
62
|
+
// key decoded as an ISO string reaches `compareValues` as text and is compared against the
|
|
63
|
+
// row's own millisecond number, so page two matched nothing at all. See `cursor-value.ts`.
|
|
64
|
+
const after: SeekKey | null =
|
|
65
|
+
decoded === null ? null : { key: reviveSortKey(decoded.key), id: decoded.id };
|
|
47
66
|
const base = await sourceFor(target, input, args);
|
|
48
67
|
const shape = base.shape();
|
|
49
68
|
|
|
@@ -59,7 +78,10 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
|
|
|
59
78
|
|
|
60
79
|
return {
|
|
61
80
|
rows,
|
|
62
|
-
endCursor:
|
|
81
|
+
endCursor:
|
|
82
|
+
seek === null
|
|
83
|
+
? null
|
|
84
|
+
: encodeCursor({ scope: hash, key: seek.key.map(serializeSortValue), id: seek.id }),
|
|
63
85
|
hasNextPage: scoped.length > args.first,
|
|
64
86
|
};
|
|
65
87
|
}
|
package/src/policy-gate.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
8
8
|
import { assertNever, isAnonymous } from '@ultimat3/core';
|
|
9
9
|
import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
|
|
10
|
-
import { enforce } from '@ultimat3/policy';
|
|
10
|
+
import { enforce, policyPermissions as flattenedPermissions } from '@ultimat3/policy';
|
|
11
11
|
import { QueryDeniedError } from './errors';
|
|
12
12
|
|
|
13
13
|
/** Policies are opaque here: we evaluate them, we never introspect their rules. */
|
|
@@ -60,7 +60,18 @@ export function actorOf(ctx: Ctx): Actor | null {
|
|
|
60
60
|
return isAnonymous(ctx.actor) ? null : ctx.actor;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/** The capability a read requires, for manifests and the `/_x` dashboard. */
|
|
63
|
+
/** The capability a read requires, for manifests and the `/_x` dashboard. A DISPLAY label. */
|
|
64
64
|
export function policyCapability(policy: QueryPolicy): string {
|
|
65
65
|
return policy.label;
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every permission the policy tree references, flattened and deduped — and the only field a
|
|
70
|
+
* compliance report may match a grant against. `label` renders a composite as
|
|
71
|
+
* `or(feed:read, org:administer)`, which is a sentence and never equals a permission string, so
|
|
72
|
+
* matching on it reported every read guarded by a composite as enforcing nothing. The mirror of
|
|
73
|
+
* `@ultimat3/action`'s, because `x policy list` reads both lists the same way.
|
|
74
|
+
*/
|
|
75
|
+
export function policyPermissions(policy: QueryPolicy): readonly string[] {
|
|
76
|
+
return flattenedPermissions(policy);
|
|
77
|
+
}
|
package/src/query.ts
CHANGED
|
@@ -7,24 +7,40 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { CacheTag } from '@ultimat3/cache';
|
|
10
|
+
import { tagKeys } from '@ultimat3/cache';
|
|
10
11
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
11
12
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
13
|
+
import type { QueryCacheScope } from './cache';
|
|
12
14
|
import type { QueryClientMethod, QueryClientOptions } from './client';
|
|
15
|
+
import type { Deprecation } from './deprecation';
|
|
16
|
+
import { QueryCacheTtlInvalidError } from './errors';
|
|
13
17
|
import { facadeFor } from './facade';
|
|
18
|
+
import { assertEncodableInput } from './input-shape';
|
|
14
19
|
import type { LiveQuery, ToLiveOptions } from './live';
|
|
15
20
|
import type { QueryToolDescriptor } from './mcp-tool';
|
|
16
21
|
import type { Page, PaginateArgs } from './pagination';
|
|
17
22
|
import type { QueryPolicy, QuerySurface } from './policy-gate';
|
|
18
|
-
import { policyCapability } from './policy-gate';
|
|
23
|
+
import { policyCapability, policyPermissions } from './policy-gate';
|
|
19
24
|
import { hasDef, queryName, runQuery, stashDef } from './read';
|
|
20
25
|
import type { SqlSource } from './source';
|
|
21
26
|
import { fingerprint } from './stable';
|
|
22
|
-
import { tagKeys } from './tags';
|
|
23
27
|
|
|
24
28
|
export interface QueryCache {
|
|
25
29
|
/** Tags this read depends on. An action's `invalidates` drops exactly these keys. */
|
|
26
30
|
readonly tags: readonly CacheTag[];
|
|
31
|
+
/**
|
|
32
|
+
* Lifetime of a tier entry. Positive and finite — `Infinity`, `0` and `NaN` are refused at
|
|
33
|
+
* `query()` (`X_QUERY_CACHE_TTL_INVALID`), where the file that wrote them fails, rather than on
|
|
34
|
+
* every read of that query forever. Omitted takes `DEFAULT_READ_CACHE_TTL_MS`.
|
|
35
|
+
*/
|
|
27
36
|
readonly ttlMs?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Who a cached answer may be handed back to. **Defaults to `actor`**, and that default is the
|
|
39
|
+
* mechanism: a read that declares nothing gets the narrowest key, which is always correct.
|
|
40
|
+
* `tenant` and `global` are written statements that the rows do not depend on the caller beyond
|
|
41
|
+
* their org, or at all — see `readAuthority`.
|
|
42
|
+
*/
|
|
43
|
+
readonly scope?: QueryCacheScope;
|
|
28
44
|
}
|
|
29
45
|
|
|
30
46
|
export interface QueryMcp {
|
|
@@ -40,6 +56,17 @@ export interface QueryMcp {
|
|
|
40
56
|
readonly visibleTo?: readonly string[];
|
|
41
57
|
}
|
|
42
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The symmetric twin of `ActionRateLimit`, and the field whose absence meant a read could not be
|
|
61
|
+
* throttled at all: every `GET /_x/query/*` fell through to the `default` bucket (120 burst, 2/s
|
|
62
|
+
* per actor), so one authenticated caller could hold 120 cross-tenant aggregates in flight and
|
|
63
|
+
* then 2/s forever, from a single account, with no declaration able to say otherwise.
|
|
64
|
+
*/
|
|
65
|
+
export interface QueryRateLimit {
|
|
66
|
+
readonly limit: number;
|
|
67
|
+
readonly windowMs: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
43
70
|
export interface QueryDef<TInput extends StandardSchemaV1, TRow extends object> {
|
|
44
71
|
readonly input: TInput;
|
|
45
72
|
readonly policy: QueryPolicy;
|
|
@@ -48,12 +75,27 @@ export interface QueryDef<TInput extends StandardSchemaV1, TRow extends object>
|
|
|
48
75
|
sql(input: InferOutput<TInput>, ctx: Ctx): SqlSource<TRow>;
|
|
49
76
|
readonly cache?: QueryCache;
|
|
50
77
|
readonly mcp?: QueryMcp;
|
|
78
|
+
/** Burst and refill for THIS read's route, in the limiter's own vocabulary. */
|
|
79
|
+
readonly rateLimit?: QueryRateLimit;
|
|
80
|
+
/**
|
|
81
|
+
* On its way out. `Deprecation` and `Sunset` response headers (RFC 9745 / RFC 8594), a
|
|
82
|
+
* `rel="successor-version"` link when `replacedBy` names one, and a
|
|
83
|
+
* `deprecated_calls_total{name}` counter — the only way to answer "is anyone still reading
|
|
84
|
+
* it?" before deleting it. Versioning itself is two deployments behind one ingress, not a
|
|
85
|
+
* router feature; see the README.
|
|
86
|
+
*/
|
|
87
|
+
readonly deprecated?: Deprecation;
|
|
51
88
|
}
|
|
52
89
|
|
|
53
90
|
export interface QueryOptions {
|
|
54
91
|
readonly ctx?: Ctx;
|
|
55
92
|
readonly surface?: QuerySurface;
|
|
56
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Skips every cache for this call — the request memo as well as the tiers, since a memo is a
|
|
95
|
+
* cache with a request's lifetime. The one way to read past a write made earlier in the same
|
|
96
|
+
* request: what it reads replaces the memo entry, so the next plain read of this key in this
|
|
97
|
+
* request sees the write too. Live fanout always reads fresh.
|
|
98
|
+
*/
|
|
57
99
|
readonly fresh?: boolean;
|
|
58
100
|
/**
|
|
59
101
|
* Run as someone else. Omitted keeps the context's own actor; `null` is the
|
|
@@ -65,20 +107,42 @@ export interface QueryOptions {
|
|
|
65
107
|
|
|
66
108
|
export interface SourceOptions extends QueryOptions {
|
|
67
109
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
110
|
+
* Build this source WITHOUT evaluating its policy, and say WHY — a written reason, never a bare
|
|
111
|
+
* boolean. It was `enforce: false`, and a boolean is wrong here for the reason
|
|
112
|
+
* `@ultimat3/entity`'s `cross-tenant.ts` gives for the same shape: it reads exactly like
|
|
113
|
+
* forgetting the check. The reason is the mechanism, not documentation of it — a blank one is
|
|
114
|
+
* refused (`X_INVARIANT`), so an escape with no argument cannot be written at all, and every
|
|
115
|
+
* skipped policy in a codebase is one `grep` away with the justification attached.
|
|
116
|
+
*
|
|
117
|
+
* Two situations qualify, and both are reads with no subscriber to decide about: developer
|
|
118
|
+
* tooling that returns no rows (`explain`, `describeSql`) and the shared, subject-less window a
|
|
119
|
+
* sync node builds once per `(query, input)` — see `ToLiveOptions.enforce`, which is that one
|
|
120
|
+
* use spelled as a boolean because it has exactly one reason and this file already states it.
|
|
121
|
+
*
|
|
122
|
+
* It is deliberately NOT gated on a capability the way `crossTenant` is: `explain` runs from the
|
|
123
|
+
* CLI with no actor at all to check one against, so a scope requirement would close the one
|
|
124
|
+
* surface this exists for. The rows are still tenant-scoped by `@ultimat3/entity` under the
|
|
125
|
+
* caller's own context, which `read.ts` now installs.
|
|
71
126
|
*/
|
|
72
|
-
readonly
|
|
127
|
+
readonly unenforced?: string;
|
|
73
128
|
}
|
|
74
129
|
|
|
75
130
|
export interface QueryDescriptor {
|
|
76
131
|
readonly kind: 'query';
|
|
77
132
|
readonly name: string;
|
|
78
133
|
readonly live: boolean;
|
|
134
|
+
/** The policy's DISPLAY label. A composite renders as `or(a:b, c:d)` — never a permission. */
|
|
79
135
|
readonly capability: string;
|
|
136
|
+
/**
|
|
137
|
+
* Every permission the policy tree references, flattened. The field a compliance report
|
|
138
|
+
* matches a grant against; `capability` is a label and matching on it reported every
|
|
139
|
+
* composite-guarded read as enforcing nothing.
|
|
140
|
+
*/
|
|
141
|
+
readonly permissions: readonly string[];
|
|
80
142
|
readonly tags: readonly string[];
|
|
81
143
|
readonly ttlMs: number | null;
|
|
144
|
+
readonly rateLimit: QueryRateLimit | null;
|
|
145
|
+
readonly deprecated: Deprecation | null;
|
|
82
146
|
}
|
|
83
147
|
|
|
84
148
|
/**
|
|
@@ -93,6 +157,8 @@ export interface AnyQueryDef {
|
|
|
93
157
|
sql(input: unknown, ctx: Ctx): SqlSource<object>;
|
|
94
158
|
readonly cache?: QueryCache;
|
|
95
159
|
readonly mcp?: QueryMcp;
|
|
160
|
+
readonly rateLimit?: QueryRateLimit;
|
|
161
|
+
readonly deprecated?: Deprecation;
|
|
96
162
|
}
|
|
97
163
|
|
|
98
164
|
export interface AnyQuery {
|
|
@@ -105,6 +171,9 @@ export interface AnyQuery {
|
|
|
105
171
|
readonly policy: QueryPolicy;
|
|
106
172
|
readonly cache?: QueryCache;
|
|
107
173
|
readonly mcp?: QueryMcp;
|
|
174
|
+
/** Lifted so `toQueryRoute` reads the declaration without reaching through `defOf`. */
|
|
175
|
+
readonly rateLimit?: QueryRateLimit;
|
|
176
|
+
readonly deprecated?: Deprecation;
|
|
108
177
|
describe(): QueryDescriptor;
|
|
109
178
|
/** A twin under another name. Registration names through `named`. */
|
|
110
179
|
named(name: string): AnyQuery;
|
|
@@ -141,15 +210,42 @@ export interface Query<
|
|
|
141
210
|
/** The fluent half of a query: lifted declaration plus one method per projection. */
|
|
142
211
|
export type QueryFacade<TInput extends StandardSchemaV1, TRow extends object> = Pick<
|
|
143
212
|
Query<TInput, TRow>,
|
|
144
|
-
|
|
213
|
+
| 'input'
|
|
214
|
+
| 'policy'
|
|
215
|
+
| 'cache'
|
|
216
|
+
| 'mcp'
|
|
217
|
+
| 'rateLimit'
|
|
218
|
+
| 'deprecated'
|
|
219
|
+
| 'as'
|
|
220
|
+
| 'page'
|
|
221
|
+
| 'live'
|
|
222
|
+
| 'tool'
|
|
223
|
+
| 'client'
|
|
145
224
|
>;
|
|
146
225
|
|
|
147
226
|
export function query<TInput extends StandardSchemaV1, TRow extends object>(
|
|
148
227
|
def: QueryDef<TInput, TRow>,
|
|
149
228
|
): Query<TInput, TRow> {
|
|
229
|
+
// Here and not in `toQueryRoute`: a read is projected to `GET /_x/query/<kebab>` whether or not
|
|
230
|
+
// anyone mounts it, and the typed client derives that same URL — so an input a query string
|
|
231
|
+
// cannot carry is wrong for every call, and the file that declared it is where it is repaired.
|
|
232
|
+
assertEncodableInput(def.input);
|
|
233
|
+
assertCacheTtl(def.cache);
|
|
150
234
|
return build(def, '');
|
|
151
235
|
}
|
|
152
236
|
|
|
237
|
+
/**
|
|
238
|
+
* The same rule, for the same reason: a lease every `CacheTier` refuses is wrong for every read of
|
|
239
|
+
* this query, so it fails on the line that wrote it rather than on the first request. Positive and
|
|
240
|
+
* finite is `assertTtl`'s bar in `@ultimat3/cache`, restated as a refusal and never as a second
|
|
241
|
+
* resolution — there is no "never expires" to fall back to.
|
|
242
|
+
*/
|
|
243
|
+
function assertCacheTtl(cache: QueryCache | undefined): void {
|
|
244
|
+
const ttlMs = cache?.ttlMs;
|
|
245
|
+
if (ttlMs === undefined) return;
|
|
246
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new QueryCacheTtlInvalidError(ttlMs);
|
|
247
|
+
}
|
|
248
|
+
|
|
153
249
|
/**
|
|
154
250
|
* Structural, not nominal: an object only counts as a query if `query()` built it,
|
|
155
251
|
* because only then does a declaration exist for `sourceFor` to read. A look-alike
|
|
@@ -203,8 +299,12 @@ export function describeQuery(target: AnyQuery): QueryDescriptor {
|
|
|
203
299
|
name: queryName(target),
|
|
204
300
|
live: target.isLive,
|
|
205
301
|
capability: policyCapability(target.policy),
|
|
302
|
+
// The flattened list, beside the label and never instead of it: one is read, one is matched.
|
|
303
|
+
permissions: policyPermissions(target.policy),
|
|
206
304
|
tags: tagKeys(target.cache?.tags ?? []),
|
|
207
305
|
ttlMs: target.cache?.ttlMs ?? null,
|
|
306
|
+
rateLimit: target.rateLimit ?? null,
|
|
307
|
+
deprecated: target.deprecated ?? null,
|
|
208
308
|
};
|
|
209
309
|
}
|
|
210
310
|
|