@ultimat3/query 1.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/LICENSE +21 -0
- package/README.md +151 -0
- package/package.json +38 -0
- package/src/cache.ts +101 -0
- package/src/client.ts +87 -0
- package/src/errors.ts +203 -0
- package/src/facade.ts +40 -0
- package/src/index.ts +86 -0
- package/src/live.ts +165 -0
- package/src/matcher.ts +126 -0
- package/src/mcp-tool.ts +75 -0
- package/src/naming.ts +38 -0
- package/src/pagination.ts +81 -0
- package/src/policy-gate.ts +66 -0
- package/src/query.ts +221 -0
- package/src/read.ts +143 -0
- package/src/registry.ts +69 -0
- package/src/shape.ts +111 -0
- package/src/source.ts +211 -0
- package/src/sql.ts +74 -0
- package/src/stable.ts +56 -0
- package/src/tags.ts +17 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API of @ultimat3/query: reads, live reads, and the tools around them.
|
|
3
|
+
*
|
|
4
|
+
* `sql` is deliberately absent. A query's declaration lives in `read.ts`'s private
|
|
5
|
+
* store, and `sourceFor` is the only thing that reads it — so no adapter can parse,
|
|
6
|
+
* authorize or execute on its own. One authz system, structurally.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Re-exported so a `query` file needs one import, not two. Same object as schema's. */
|
|
10
|
+
export type { Infer } from '@ultimat3/schema';
|
|
11
|
+
export { t } from '@ultimat3/schema';
|
|
12
|
+
export type { ReadCache, ReadCacheEntry } from './cache';
|
|
13
|
+
export {
|
|
14
|
+
cacheKeyFor,
|
|
15
|
+
getReadCache,
|
|
16
|
+
invalidateQueryTags,
|
|
17
|
+
MemoryReadCache,
|
|
18
|
+
readThrough,
|
|
19
|
+
requestMemo,
|
|
20
|
+
setReadCache,
|
|
21
|
+
} from './cache';
|
|
22
|
+
export type {
|
|
23
|
+
FetchLike,
|
|
24
|
+
QueryCallOptions,
|
|
25
|
+
QueryClientMethod,
|
|
26
|
+
QueryClientOptions,
|
|
27
|
+
} from './client';
|
|
28
|
+
export { queryClientMethodFor } from './client';
|
|
29
|
+
export type { QueryProblem } from './errors';
|
|
30
|
+
export {
|
|
31
|
+
CursorInvalidError,
|
|
32
|
+
MatcherUnsupportedError,
|
|
33
|
+
QueryDeniedError,
|
|
34
|
+
QueryDuplicateError,
|
|
35
|
+
QueryForeignError,
|
|
36
|
+
QueryInputInvalidError,
|
|
37
|
+
QueryNotPageableError,
|
|
38
|
+
QueryPolicyMissingError,
|
|
39
|
+
QueryRequestFailedError,
|
|
40
|
+
QueryUnregisteredError,
|
|
41
|
+
} from './errors';
|
|
42
|
+
export type { LiveCursor, LiveQuery, ResumeMode, ResumePlan, ToLiveOptions } from './live';
|
|
43
|
+
export { advanceCursor, liveEpoch, planResume, seekOf, toLiveQuery } from './live';
|
|
44
|
+
export type { ChangeEvent, ChangeOp, Patch } from './matcher';
|
|
45
|
+
export { assertMatchable, match, positionFor } from './matcher';
|
|
46
|
+
export type { QueryToolDescriptor, QueryToolReadOptions } from './mcp-tool';
|
|
47
|
+
export { isExposed, toQueryTool, toQueryTools } from './mcp-tool';
|
|
48
|
+
export { derivePath, toKebabCase, toToolName } from './naming';
|
|
49
|
+
/**
|
|
50
|
+
* The shapes `query.page(input, { first, after })` takes and answers with. `paginate` itself is
|
|
51
|
+
* deliberately unexported: a page is the read's own answer, and a second, importable way to ask
|
|
52
|
+
* for one is a second way to do the thing `.page()` already does. The codec is
|
|
53
|
+
* `@ultimat3/core`'s — one place to encode, decode or re-key a cursor.
|
|
54
|
+
*/
|
|
55
|
+
export type { Page, PaginateArgs } from './pagination';
|
|
56
|
+
export type { QueryPolicy, QuerySubject, QuerySurface } from './policy-gate';
|
|
57
|
+
export { actorOf, guard, policyCapability } from './policy-gate';
|
|
58
|
+
export type {
|
|
59
|
+
AnyQuery,
|
|
60
|
+
Query,
|
|
61
|
+
QueryCache,
|
|
62
|
+
QueryDef,
|
|
63
|
+
QueryDescriptor,
|
|
64
|
+
QueryFacade,
|
|
65
|
+
QueryMcp,
|
|
66
|
+
QueryOptions,
|
|
67
|
+
SourceOptions,
|
|
68
|
+
} from './query';
|
|
69
|
+
export { describeQuery, isQuery, nameQuery, query, queryHash } from './query';
|
|
70
|
+
/** The one read path. `defOf` stays unexported — that is the enforcement. */
|
|
71
|
+
export { queryName, runQuery, sourceFor } from './read';
|
|
72
|
+
export {
|
|
73
|
+
describeQueries,
|
|
74
|
+
getQuery,
|
|
75
|
+
listQueries,
|
|
76
|
+
registerQueries,
|
|
77
|
+
registerQuery,
|
|
78
|
+
resetRegistry,
|
|
79
|
+
} from './registry';
|
|
80
|
+
export type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape';
|
|
81
|
+
export { compareRows, compareValues, matchesFilter, matchesFilters, seekKeyOf } from './shape';
|
|
82
|
+
export type { RowProvider, SqlSource, SqlText } from './source';
|
|
83
|
+
/** `isAfterKey` is the one definition of "after this position" — both seek paths use it. */
|
|
84
|
+
export { Builder, from, isAfterKey } from './source';
|
|
85
|
+
export type { ExplainResult, QuerySqlInfo } from './sql';
|
|
86
|
+
export { describeSql, explain } from './sql';
|
package/src/live.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `live: true` actually produces: the descriptor @ultimat3/realtime
|
|
3
|
+
* subscribes to. It carries the SQL shape (for the matcher), the dependency set
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
import type { Ctx } from '@ultimat3/core';
|
|
8
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
9
|
+
import type { Patch } from './matcher';
|
|
10
|
+
import { assertMatchable } from './matcher';
|
|
11
|
+
import type { QueryPolicy, QuerySubject } from './policy-gate';
|
|
12
|
+
import { guard } from './policy-gate';
|
|
13
|
+
import type { Query } from './query';
|
|
14
|
+
import { queryHash } from './query';
|
|
15
|
+
import { queryName, sourceFor } from './read';
|
|
16
|
+
import type { QueryShape, SeekKey } from './shape';
|
|
17
|
+
import { seekKeyOf } from './shape';
|
|
18
|
+
import { tagKeys } from './tags';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reconnect state. Deliberately tiny — an id, a sort key and a version — because
|
|
22
|
+
* the reconnect path is this framework's biggest identified risk.
|
|
23
|
+
*
|
|
24
|
+
* Tradeoff: a cursor is not a snapshot. Resuming re-runs the *bounded* query
|
|
25
|
+
* (`limit` rows from the sort key onward) instead of replaying a change log, so
|
|
26
|
+
* the server keeps no per-subscriber history and reconnect costs one indexed
|
|
27
|
+
* keyset read. The price: changes to rows the client holds but that sort *before*
|
|
28
|
+
* the cursor cannot be detected from the cursor alone, so any epoch change —
|
|
29
|
+
* new build, policy change, schema change — forces a full refetch rather than a
|
|
30
|
+
* resume. We buy bounded server memory with an occasional full page fetch.
|
|
31
|
+
*/
|
|
32
|
+
export interface LiveCursor {
|
|
33
|
+
/** Build/policy generation. A mismatch means refetch, never resume. */
|
|
34
|
+
readonly epoch: string;
|
|
35
|
+
readonly queryHash: string;
|
|
36
|
+
/** Monotonic per subscription; bumps once per applied patch batch. */
|
|
37
|
+
readonly version: number;
|
|
38
|
+
/** Sort-key values of the last row the client holds, plus its id tiebreak. */
|
|
39
|
+
readonly seek: SeekKey | null;
|
|
40
|
+
readonly rows: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ResumeMode = 'resume' | 'refetch';
|
|
44
|
+
|
|
45
|
+
export interface ResumePlan {
|
|
46
|
+
readonly mode: ResumeMode;
|
|
47
|
+
readonly reason: string;
|
|
48
|
+
/** Present when `mode === 'resume'`: where the bounded re-read starts. */
|
|
49
|
+
readonly seek: SeekKey | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface LiveQuery {
|
|
53
|
+
readonly name: string;
|
|
54
|
+
readonly queryHash: string;
|
|
55
|
+
readonly shape: QueryShape;
|
|
56
|
+
/** Entities and cache tags this read depends on — the change-feed filter. */
|
|
57
|
+
readonly reads: readonly string[];
|
|
58
|
+
readonly policy: QueryPolicy;
|
|
59
|
+
readonly sqlText: string;
|
|
60
|
+
readonly limit: number | null;
|
|
61
|
+
/** Per-subscriber authorization. Called on subscribe *and* on every fanout. */
|
|
62
|
+
authorize(subject: QuerySubject): Promise<void>;
|
|
63
|
+
initialCursor(rows: readonly object[]): LiveCursor;
|
|
64
|
+
/** `tail` is the last row of the window after the patches applied, if any. */
|
|
65
|
+
advance(cursor: LiveCursor, patches: readonly Patch<object>[], tail?: object): LiveCursor;
|
|
66
|
+
resume(cursor: LiveCursor): ResumePlan;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ToLiveOptions {
|
|
70
|
+
readonly ctx?: Ctx;
|
|
71
|
+
/** Overrides the process epoch. Tests pin it; the server derives it from the build. */
|
|
72
|
+
readonly epoch?: string;
|
|
73
|
+
/**
|
|
74
|
+
* `false` builds the descriptor with **no subject at all** — the shape, the SQL text and the
|
|
75
|
+
* matcher, and nothing that decided about an actor. Only a sync node passes it, and only for
|
|
76
|
+
* the window it shares across every subscriber of one `(query, input)`: a descriptor built
|
|
77
|
+
* under the first subscriber's authority and then cached by query id is the first subscriber's
|
|
78
|
+
* entitlements becoming everyone's. `authorize` below is still the subscribe-time decision and
|
|
79
|
+
* still runs per subscriber, so this removes no check — it removes a *shared* one.
|
|
80
|
+
*/
|
|
81
|
+
readonly enforce?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Changing the build changes the epoch, which forces reconnects to refetch. */
|
|
85
|
+
export function liveEpoch(): string {
|
|
86
|
+
return Bun.env['X_BUILD_ID'] ?? 'dev';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends object>(
|
|
90
|
+
target: Query<TInput, TRow>,
|
|
91
|
+
input: unknown,
|
|
92
|
+
options: ToLiveOptions = {},
|
|
93
|
+
): Promise<LiveQuery> {
|
|
94
|
+
const name = queryName(target);
|
|
95
|
+
const source = await sourceFor(target, input, {
|
|
96
|
+
...(options.ctx === undefined ? {} : { ctx: options.ctx }),
|
|
97
|
+
...(options.enforce === undefined ? {} : { enforce: options.enforce }),
|
|
98
|
+
surface: 'live',
|
|
99
|
+
});
|
|
100
|
+
const shape = source.shape();
|
|
101
|
+
// Fail at subscribe time, not on the first change event nobody can patch.
|
|
102
|
+
assertMatchable(name, shape);
|
|
103
|
+
|
|
104
|
+
const epoch = options.epoch ?? liveEpoch();
|
|
105
|
+
const hash = queryHash(name, input);
|
|
106
|
+
const reads = [...new Set([shape.entity, ...tagKeys(target.cache?.tags ?? [])])].sort();
|
|
107
|
+
// The query's own policy object, not a copy: the same authz a direct read runs.
|
|
108
|
+
const policy = target.policy;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
name,
|
|
112
|
+
queryHash: hash,
|
|
113
|
+
shape,
|
|
114
|
+
reads,
|
|
115
|
+
policy,
|
|
116
|
+
sqlText: source.toSQL().sql,
|
|
117
|
+
limit: shape.limit,
|
|
118
|
+
authorize: async (subject) => {
|
|
119
|
+
guard(policy, subject, 'live');
|
|
120
|
+
},
|
|
121
|
+
initialCursor: (rows) => ({
|
|
122
|
+
epoch,
|
|
123
|
+
queryHash: hash,
|
|
124
|
+
version: 0,
|
|
125
|
+
seek: seekOf(rows[rows.length - 1], shape),
|
|
126
|
+
rows: rows.length,
|
|
127
|
+
}),
|
|
128
|
+
advance: (cursor, patches, tail) => advanceCursor(cursor, patches, shape, tail),
|
|
129
|
+
resume: (cursor) => planResume(cursor, epoch, hash),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Cursor arithmetic kept pure so the sync node can replay it deterministically. */
|
|
134
|
+
export function advanceCursor(
|
|
135
|
+
cursor: LiveCursor,
|
|
136
|
+
patches: readonly Patch<object>[],
|
|
137
|
+
shape: QueryShape,
|
|
138
|
+
tail?: object,
|
|
139
|
+
): LiveCursor {
|
|
140
|
+
let rows = cursor.rows;
|
|
141
|
+
for (const patch of patches) {
|
|
142
|
+
if (patch.kind === 'add') rows += 1;
|
|
143
|
+
if (patch.kind === 'remove') rows = Math.max(0, rows - 1);
|
|
144
|
+
}
|
|
145
|
+
const seek = tail === undefined ? cursor.seek : seekOf(tail, shape);
|
|
146
|
+
return { ...cursor, version: cursor.version + 1, rows, seek };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function planResume(cursor: LiveCursor, epoch: string, hash: string): ResumePlan {
|
|
150
|
+
if (cursor.epoch !== epoch) {
|
|
151
|
+
return { mode: 'refetch', reason: 'epoch changed (new build, policy or schema)', seek: null };
|
|
152
|
+
}
|
|
153
|
+
if (cursor.queryHash !== hash) {
|
|
154
|
+
return { mode: 'refetch', reason: 'query arguments changed', seek: null };
|
|
155
|
+
}
|
|
156
|
+
if (cursor.seek === null) {
|
|
157
|
+
return { mode: 'refetch', reason: 'no sort key held', seek: null };
|
|
158
|
+
}
|
|
159
|
+
return { mode: 'resume', reason: 'bounded keyset re-read', seek: cursor.seek };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The sort-key values of a row under the query's ordering, id as the tiebreak. */
|
|
163
|
+
export function seekOf(row: object | undefined, shape: QueryShape): SeekKey | null {
|
|
164
|
+
return row === undefined ? null : seekKeyOf(row, shape);
|
|
165
|
+
}
|
package/src/matcher.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The incremental matcher: given one change event, decide whether a live query's
|
|
3
|
+
* result set moves and emit the minimal patch. Supported shapes are equality-ish
|
|
4
|
+
* filters + orderBy + limit; anything else throws X_MATCHER_UNSUPPORTED, because
|
|
5
|
+
* an honest refusal beats a silently wrong result set.
|
|
6
|
+
*/
|
|
7
|
+
import { MatcherUnsupportedError } from './errors';
|
|
8
|
+
import type { QueryShape } from './shape';
|
|
9
|
+
import { compareRows, matchesFilters } from './shape';
|
|
10
|
+
import { columnOf } from './stable';
|
|
11
|
+
|
|
12
|
+
export type ChangeOp = 'insert' | 'update' | 'delete';
|
|
13
|
+
|
|
14
|
+
export interface ChangeEvent<TRow extends object> {
|
|
15
|
+
readonly entity: string;
|
|
16
|
+
readonly op: ChangeOp;
|
|
17
|
+
readonly row: TRow;
|
|
18
|
+
/** Previous image, required for `update` to know whether the row was in the set. */
|
|
19
|
+
readonly before?: TRow;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type Patch<TRow extends object> =
|
|
23
|
+
| { readonly kind: 'add'; readonly position: number; readonly row: TRow }
|
|
24
|
+
| { readonly kind: 'update'; readonly position: number; readonly row: TRow }
|
|
25
|
+
| { readonly kind: 'remove'; readonly position: number; readonly id: string }
|
|
26
|
+
/**
|
|
27
|
+
* The window lost a row and the tail is unknown to the client: ask the server
|
|
28
|
+
* for rows from `from` onward. Only reachable for `limit`ed queries.
|
|
29
|
+
*/
|
|
30
|
+
| { readonly kind: 'refill'; readonly from: number };
|
|
31
|
+
|
|
32
|
+
/** Filter operators the matcher can evaluate incrementally. */
|
|
33
|
+
const SUPPORTED_OPS = new Set(['=', '!=', 'in', '>', '>=', '<', '<=']);
|
|
34
|
+
|
|
35
|
+
export function assertMatchable(name: string, shape: QueryShape): void {
|
|
36
|
+
if (shape.unsupported.length > 0) {
|
|
37
|
+
throw new MatcherUnsupportedError(name, shape.unsupported.join(', '));
|
|
38
|
+
}
|
|
39
|
+
for (const filter of shape.filters) {
|
|
40
|
+
if (!SUPPORTED_OPS.has(filter.op)) {
|
|
41
|
+
throw new MatcherUnsupportedError(name, `filter operator "${filter.op}"`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `rows` is the subscriber's current, ordered result set. Patches apply in order.
|
|
48
|
+
*/
|
|
49
|
+
export function match<TRow extends object>(
|
|
50
|
+
name: string,
|
|
51
|
+
shape: QueryShape,
|
|
52
|
+
rows: readonly TRow[],
|
|
53
|
+
event: ChangeEvent<TRow>,
|
|
54
|
+
): readonly Patch<TRow>[] {
|
|
55
|
+
assertMatchable(name, shape);
|
|
56
|
+
if (event.entity !== shape.entity) return [];
|
|
57
|
+
|
|
58
|
+
const id = idOf(event.row);
|
|
59
|
+
const index = rows.findIndex((row) => idOf(row) === id);
|
|
60
|
+
const inSet = index >= 0;
|
|
61
|
+
const belongs = event.op !== 'delete' && matchesFilters(event.row, shape.filters);
|
|
62
|
+
|
|
63
|
+
if (event.op === 'delete' || (inSet && !belongs)) {
|
|
64
|
+
return inSet ? removeAt(shape, index, id, true) : [];
|
|
65
|
+
}
|
|
66
|
+
if (!belongs) return [];
|
|
67
|
+
if (!inSet) return insert(shape, rows, event.row);
|
|
68
|
+
|
|
69
|
+
// Present and still matching: a change to an ordering column is a move, not an update.
|
|
70
|
+
const current = rows[index];
|
|
71
|
+
const moved =
|
|
72
|
+
shape.orderBy.length > 0 &&
|
|
73
|
+
current !== undefined &&
|
|
74
|
+
compareRows(event.row, current, shape.orderBy) !== 0;
|
|
75
|
+
if (!moved) return [{ kind: 'update', position: index, row: event.row }];
|
|
76
|
+
|
|
77
|
+
// A move keeps the window full, so it never needs a refill.
|
|
78
|
+
const without = [...rows.slice(0, index), ...rows.slice(index + 1)];
|
|
79
|
+
return [...removeAt<TRow>(shape, index, id, false), ...insert(shape, without, event.row)];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function insert<TRow extends object>(
|
|
83
|
+
shape: QueryShape,
|
|
84
|
+
rows: readonly TRow[],
|
|
85
|
+
row: TRow,
|
|
86
|
+
): readonly Patch<TRow>[] {
|
|
87
|
+
const position = positionFor(shape, rows, row);
|
|
88
|
+
// Sorted past the end of a full window: the row exists but nobody sees it.
|
|
89
|
+
if (shape.limit !== null && position >= shape.limit) return [];
|
|
90
|
+
const patches: Patch<TRow>[] = [{ kind: 'add', position, row }];
|
|
91
|
+
if (shape.limit !== null && rows.length >= shape.limit) {
|
|
92
|
+
const evicted = rows[shape.limit - 1];
|
|
93
|
+
if (evicted !== undefined) {
|
|
94
|
+
patches.push({ kind: 'remove', position: shape.limit, id: idOf(evicted) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return patches;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function removeAt<TRow extends object>(
|
|
101
|
+
shape: QueryShape,
|
|
102
|
+
index: number,
|
|
103
|
+
id: string,
|
|
104
|
+
refill: boolean,
|
|
105
|
+
): readonly Patch<TRow>[] {
|
|
106
|
+
const patches: Patch<TRow>[] = [{ kind: 'remove', position: index, id }];
|
|
107
|
+
// A limited window may now be one row short, and the tail lives on the server.
|
|
108
|
+
if (refill && shape.limit !== null) patches.push({ kind: 'refill', from: shape.limit - 1 });
|
|
109
|
+
return patches;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Insertion index under the query's ordering; append when the query is unordered. */
|
|
113
|
+
export function positionFor<TRow extends object>(
|
|
114
|
+
shape: QueryShape,
|
|
115
|
+
rows: readonly TRow[],
|
|
116
|
+
row: TRow,
|
|
117
|
+
): number {
|
|
118
|
+
if (shape.orderBy.length === 0) return rows.length;
|
|
119
|
+
const found = rows.findIndex((current) => compareRows(row, current, shape.orderBy) < 0);
|
|
120
|
+
return found === -1 ? rows.length : found;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function idOf(row: object): string {
|
|
124
|
+
const value = columnOf(row, 'id');
|
|
125
|
+
return typeof value === 'string' ? value : String(value);
|
|
126
|
+
}
|
package/src/mcp-tool.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A query as an MCP read tool. Its `read` goes through `sourceFor` — the same
|
|
3
|
+
* authorized front half the HTTP read and the live subscription use — so the tool
|
|
4
|
+
* cannot drift from the endpoint and cannot acquire a second authz path.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Actor, Ctx } from '@ultimat3/core';
|
|
8
|
+
import type { JsonSchema } from '@ultimat3/schema';
|
|
9
|
+
import { toMcpInputSchema } from '@ultimat3/schema';
|
|
10
|
+
import { toToolName } from './naming';
|
|
11
|
+
import type { QueryPolicy } from './policy-gate';
|
|
12
|
+
import type { AnyQuery } from './query';
|
|
13
|
+
import { queryName, sourceFor } from './read';
|
|
14
|
+
import { listQueries } from './registry';
|
|
15
|
+
|
|
16
|
+
export interface QueryToolReadOptions {
|
|
17
|
+
readonly ctx?: Ctx;
|
|
18
|
+
/** The agent behind the call. `null` is the signed-out caller. */
|
|
19
|
+
readonly actor?: Actor | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface QueryToolDescriptor {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
/** The query's `mcp.description`, or its name when the author gave none. */
|
|
25
|
+
readonly description: string;
|
|
26
|
+
readonly query: string;
|
|
27
|
+
/**
|
|
28
|
+
* The query's own policy object, not a copy — `tool().policy === query.policy`
|
|
29
|
+
* is what makes "an MCP call cannot reach a different authz path" checkable.
|
|
30
|
+
*/
|
|
31
|
+
readonly policy: QueryPolicy;
|
|
32
|
+
readonly inputSchema: JsonSchema;
|
|
33
|
+
/** Always false: a query reads. Drives the rate-limit bucket in @ultimat3/mcp. */
|
|
34
|
+
readonly mutates: false;
|
|
35
|
+
read(input: unknown, options?: QueryToolReadOptions): Promise<readonly object[]>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function toQueryTool(target: AnyQuery): QueryToolDescriptor {
|
|
39
|
+
const name = queryName(target);
|
|
40
|
+
return {
|
|
41
|
+
name: toToolName(name),
|
|
42
|
+
description: target.mcp?.description ?? name,
|
|
43
|
+
query: name,
|
|
44
|
+
policy: target.policy,
|
|
45
|
+
inputSchema: toMcpInputSchema(target.input),
|
|
46
|
+
mutates: false,
|
|
47
|
+
read: async (input, options = {}) => {
|
|
48
|
+
// Executed without the cache tiers on purpose: an agent diffing two tool
|
|
49
|
+
// calls must be reading the rows, not a TTL.
|
|
50
|
+
const source = await sourceFor(target, input, {
|
|
51
|
+
...(options.ctx === undefined ? {} : { ctx: options.ctx }),
|
|
52
|
+
...(options.actor === undefined ? {} : { actor: options.actor }),
|
|
53
|
+
});
|
|
54
|
+
return source.execute();
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
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.
|
|
62
|
+
*/
|
|
63
|
+
export function isExposed(target: AnyQuery): boolean {
|
|
64
|
+
return target.mcp?.expose === true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Deterministic order — the tool list is part of the agent-visible contract. */
|
|
68
|
+
export function toQueryTools(
|
|
69
|
+
queries: readonly AnyQuery[] = listQueries(),
|
|
70
|
+
): readonly QueryToolDescriptor[] {
|
|
71
|
+
return queries
|
|
72
|
+
.filter(isExposed)
|
|
73
|
+
.map(toQueryTool)
|
|
74
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
75
|
+
}
|
package/src/naming.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one naming rule for reads: a query's export name derives its HTTP path and
|
|
3
|
+
* its MCP tool name. Pure string math, so the browser client derives the same URL
|
|
4
|
+
* without importing a byte of server code. Ported rather than imported from
|
|
5
|
+
* @ultimat3/action: that package is the same tier, and tiers never go sideways.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Every read is served under one prefix, so a router can claim it in one rule. */
|
|
9
|
+
const QUERY_PREFIX = '/_x/query';
|
|
10
|
+
|
|
11
|
+
/** camelCase / PascalCase / SCREAMING_SNAKE -> lowercase words. */
|
|
12
|
+
export function splitWords(name: string): string[] {
|
|
13
|
+
return name
|
|
14
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
15
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
16
|
+
.split(/[\s_-]+/)
|
|
17
|
+
.filter((word) => word.length > 0)
|
|
18
|
+
.map((word) => word.toLowerCase());
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `liveFeed` -> `live-feed`. */
|
|
22
|
+
export function toKebabCase(name: string): string {
|
|
23
|
+
return splitWords(name).join('-');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `liveFeed` -> `/_x/query/live-feed`, read with `GET …?orgId=…`. A read is a GET
|
|
28
|
+
* under its own prefix so a CDN, a browser cache and a log line can all tell it
|
|
29
|
+
* apart from an action's `POST /api/...` without parsing a body.
|
|
30
|
+
*/
|
|
31
|
+
export function derivePath(name: string): string {
|
|
32
|
+
return `${QUERY_PREFIX}/${toKebabCase(name)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** MCP tool names are `snake_case`: `liveFeed` -> `live_feed`. */
|
|
36
|
+
export function toToolName(name: string): string {
|
|
37
|
+
return splitWords(name).join('_');
|
|
38
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor pagination, and only cursor pagination.
|
|
3
|
+
*
|
|
4
|
+
* OFFSET IS NOT AVAILABLE ON PURPOSE: `offset` makes the database count rows it
|
|
5
|
+
* will throw away (O(offset) per page), and any insert or delete before the
|
|
6
|
+
* offset shifts every later page, so users see duplicates and holes. A keyset
|
|
7
|
+
* cursor is O(log n) on the ordering index and stable under concurrent writes.
|
|
8
|
+
*
|
|
9
|
+
* The codec is `@ultimat3/core`'s. This file only decides what a cursor is bound
|
|
10
|
+
* to — `queryHash(name, input)` — so one read's cursor cannot page another.
|
|
11
|
+
*/
|
|
12
|
+
import { decodeCursor, encodeCursor } from '@ultimat3/core';
|
|
13
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
14
|
+
import type { Query, SourceOptions } from './query';
|
|
15
|
+
import { queryHash, queryName, sourceFor } from './query';
|
|
16
|
+
import type { QueryShape, SeekKey } from './shape';
|
|
17
|
+
import { seekKeyOf } from './shape';
|
|
18
|
+
import type { SqlSource } from './source';
|
|
19
|
+
import { isAfterKey } from './source';
|
|
20
|
+
|
|
21
|
+
export interface Page<TRow> {
|
|
22
|
+
readonly rows: readonly TRow[];
|
|
23
|
+
readonly endCursor: string | null;
|
|
24
|
+
readonly hasNextPage: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PaginateArgs extends SourceOptions {
|
|
28
|
+
readonly first: number;
|
|
29
|
+
readonly after?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One page. Push-down when the source implements `seek()`; otherwise the rows are
|
|
34
|
+
* sliced after execution and the source is doing more work than it should.
|
|
35
|
+
*/
|
|
36
|
+
export async function paginate<TInput extends StandardSchemaV1, TRow extends object>(
|
|
37
|
+
target: Query<TInput, TRow>,
|
|
38
|
+
input: unknown,
|
|
39
|
+
args: PaginateArgs,
|
|
40
|
+
): Promise<Page<TRow>> {
|
|
41
|
+
const name = queryName(target);
|
|
42
|
+
const hash = queryHash(name, input);
|
|
43
|
+
// The scope is this read plus these arguments: a cursor from anywhere else is
|
|
44
|
+
// already `X_CURSOR_INVALID` by the time it gets here.
|
|
45
|
+
const decoded = args.after === undefined ? null : decodeCursor(args.after, hash);
|
|
46
|
+
const after: SeekKey | null = decoded === null ? null : { key: decoded.key, id: decoded.id };
|
|
47
|
+
const base = await sourceFor(target, input, args);
|
|
48
|
+
const shape = base.shape();
|
|
49
|
+
|
|
50
|
+
// Fetch one extra row: its presence *is* `hasNextPage`, with no count query.
|
|
51
|
+
const window = args.first + 1;
|
|
52
|
+
const source: SqlSource<object> = base.seek === undefined ? base : base.seek(after, window);
|
|
53
|
+
const executed = await source.execute();
|
|
54
|
+
const scoped = base.seek === undefined ? sliceAfter(executed, after, shape) : executed;
|
|
55
|
+
// The source came from this query's own `sql()`, so its rows are TRow.
|
|
56
|
+
const rows = scoped.slice(0, args.first) as unknown as readonly TRow[];
|
|
57
|
+
const last = rows[rows.length - 1];
|
|
58
|
+
const seek = last === undefined ? null : seekKeyOf(last, shape);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
rows,
|
|
62
|
+
endCursor: seek === null ? null : encodeCursor({ scope: hash, key: seek.key, id: seek.id }),
|
|
63
|
+
hasNextPage: scoped.length > args.first,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The cursor names a POSITION in the ordering, so the fallback filters by that position — the
|
|
69
|
+
* same comparison `Builder.seek()` pushes into SQL. Locating the cursor's row by id instead
|
|
70
|
+
* looks equivalent and is not: the row can be gone by the next request, `findIndex` answers -1,
|
|
71
|
+
* and every row from the top comes back as page two. Under a delete between two pages that is a
|
|
72
|
+
* silent restart, which is the failure keyset pagination exists to make impossible.
|
|
73
|
+
*/
|
|
74
|
+
function sliceAfter(
|
|
75
|
+
rows: readonly object[],
|
|
76
|
+
after: SeekKey | null,
|
|
77
|
+
shape: QueryShape,
|
|
78
|
+
): readonly object[] {
|
|
79
|
+
if (after === null) return rows;
|
|
80
|
+
return rows.filter((row) => isAfterKey(row, after, shape.orderBy));
|
|
81
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single point of contact with @ultimat3/policy. A live query evaluates its
|
|
3
|
+
* policy **per subscriber**, never once at subscribe time for everyone — so this
|
|
4
|
+
* gate is called on every fanout decision and its result is never cached.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Actor, Ctx } from '@ultimat3/core';
|
|
8
|
+
import { assertNever, isAnonymous } from '@ultimat3/core';
|
|
9
|
+
import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
|
|
10
|
+
import { enforce } from '@ultimat3/policy';
|
|
11
|
+
import { QueryDeniedError } from './errors';
|
|
12
|
+
|
|
13
|
+
/** Policies are opaque here: we evaluate them, we never introspect their rules. */
|
|
14
|
+
export type QueryPolicy = Policy<unknown>;
|
|
15
|
+
|
|
16
|
+
export type QuerySurface = 'server' | 'http' | 'live';
|
|
17
|
+
|
|
18
|
+
export interface QuerySubject {
|
|
19
|
+
readonly actor: Actor | null;
|
|
20
|
+
readonly input: unknown;
|
|
21
|
+
/**
|
|
22
|
+
* The already-loaded row a row-level rule decides about — the live row gate supplies it
|
|
23
|
+
* per subscriber per row. Omitted for a subscribe-time or whole-query decision. It is a
|
|
24
|
+
* field of its own and never folded into `input`: the predicate reads `args.row`.
|
|
25
|
+
*/
|
|
26
|
+
readonly row?: unknown;
|
|
27
|
+
readonly ctx: Ctx;
|
|
28
|
+
readonly query: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function guard(policy: QueryPolicy, subject: QuerySubject, surface: QuerySurface): void {
|
|
32
|
+
const denial = enforce(policySurface(surface), policy, {
|
|
33
|
+
input: subject.input,
|
|
34
|
+
actor: subject.actor,
|
|
35
|
+
row: subject.row,
|
|
36
|
+
ctx: subject.ctx,
|
|
37
|
+
});
|
|
38
|
+
if (denial !== undefined) throw new QueryDeniedError(subject.query, denial);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A direct server read is the job surface: no request, no socket to close. */
|
|
42
|
+
function policySurface(surface: QuerySurface): PolicySurface {
|
|
43
|
+
switch (surface) {
|
|
44
|
+
case 'http':
|
|
45
|
+
return 'http';
|
|
46
|
+
case 'live':
|
|
47
|
+
return 'live';
|
|
48
|
+
case 'server':
|
|
49
|
+
return 'job';
|
|
50
|
+
default:
|
|
51
|
+
return assertNever(surface);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Core models "nobody" as an anonymous actor; policy models it as `null`, which is
|
|
57
|
+
* what turns a missing session into `X_UNAUTHENTICATED` instead of a bare denial.
|
|
58
|
+
*/
|
|
59
|
+
export function actorOf(ctx: Ctx): Actor | null {
|
|
60
|
+
return isAnonymous(ctx.actor) ? null : ctx.actor;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The capability a read requires, for manifests and the `/_x` dashboard. */
|
|
64
|
+
export function policyCapability(policy: QueryPolicy): string {
|
|
65
|
+
return policy.label;
|
|
66
|
+
}
|