@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 developerz.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# @ultimat3/query π
|
|
2
|
+
|
|
3
|
+
A read. Optionally live. Never a mutation β writes are `action`.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { query, t } from '@ultimat3/query';
|
|
7
|
+
|
|
8
|
+
export const liveFeed = query({
|
|
9
|
+
input: t.object({ orgId: t.uuid }),
|
|
10
|
+
policy: can('feed:read'),
|
|
11
|
+
live: true,
|
|
12
|
+
mcp: { expose: true, description: 'The org feed' },
|
|
13
|
+
sql: ({ orgId }) => db.posts.where({ orgId }).orderBy('createdAt').limit(50),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const rows = await liveFeed({ orgId }); // typed rows, policy enforced
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Register once at boot: `registerQueries(await import('./live'))`. Export names become
|
|
20
|
+
query names, which the manifest, the live protocol and `/_x` all address.
|
|
21
|
+
|
|
22
|
+
## One declaration, five projections
|
|
23
|
+
|
|
24
|
+
Every projection is a method on the query itself. A query has no `.def`.
|
|
25
|
+
|
|
26
|
+
| Call | Gives |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `liveFeed({ orgId })` | the rows, policy enforced, through the cache tiers |
|
|
29
|
+
| `liveFeed.as(actor, { orgId })` | the same read as another actor β the surrounding context is untouched, `null` is signed out |
|
|
30
|
+
| `liveFeed.page({ orgId }, { first: 20, after })` | one bounded page plus the signed cursor that continues it. There is no `offset` |
|
|
31
|
+
| `liveFeed.live({ orgId })` | the `LiveQuery` `@ultimat3/realtime` subscribes to, carrying the same policy object |
|
|
32
|
+
| `liveFeed.tool()` | the MCP read tool. `tool().policy === liveFeed.policy`, and it reads fresh |
|
|
33
|
+
| `liveFeed.client({ baseUrl })` | `GET /_x/query/live-feed?orgId=β¦`, typed both ways |
|
|
34
|
+
| `liveFeed.describe()` | the manifest row |
|
|
35
|
+
|
|
36
|
+
The declaration is lifted too: `.input`, `.policy`, `.cache`, `.mcp`, `.isLive`. `sql` is not
|
|
37
|
+
among them β it lives in a private store inside `read.ts`, so `sourceFor` is the only thing
|
|
38
|
+
that can build a source and there is nowhere for a second authz path to hide. Something that
|
|
39
|
+
merely looks like a query (`kind: 'query'`, no declaration) is `X_QUERY_FOREIGN`.
|
|
40
|
+
|
|
41
|
+
## What each file owns
|
|
42
|
+
|
|
43
|
+
| File | Job |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `query.ts` | the primitive, `describeQuery`, `queryHash` |
|
|
46
|
+
| `read.ts` | the one read path β `runQuery`, `sourceFor` β and the declaration store |
|
|
47
|
+
| `facade.ts` | binds each projection to the query; re-implements none of them |
|
|
48
|
+
| `mcp-tool.ts` | the MCP read descriptor |
|
|
49
|
+
| `client.ts` | the typed read client (browser-safe) |
|
|
50
|
+
| `naming.ts` | export name β wire path + tool name |
|
|
51
|
+
| `live.ts` | the `LiveQuery` descriptor `@ultimat3/realtime` subscribes to |
|
|
52
|
+
| `matcher.ts` | change event β minimal patch (`add` / `update` / `remove` / `refill`) |
|
|
53
|
+
| `pagination.ts` | `paginate()` β keyset pages over core's cursor codec |
|
|
54
|
+
| `sql.ts` | `explain()` β the generated SQL, verbatim |
|
|
55
|
+
| `cache.ts` | request memo + tag-keyed tier, one invalidation graph |
|
|
56
|
+
| `source.ts` | the `SqlSource` contract + `from()`, the in-memory reference |
|
|
57
|
+
|
|
58
|
+
## Live queries
|
|
59
|
+
|
|
60
|
+
`live: true` produces a descriptor with four parts:
|
|
61
|
+
|
|
62
|
+
| Part | Why |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `shape` | the matcher patches from the shape, never by re-parsing SQL |
|
|
65
|
+
| `reads` | entities + tags this read depends on β the change-feed filter |
|
|
66
|
+
| `policy` | evaluated **per subscriber**, on subscribe and on every fanout |
|
|
67
|
+
| cursor | reconnect state: epoch + query hash + version + seek key |
|
|
68
|
+
|
|
69
|
+
### Cursor tradeoff (the reconnect risk, stated plainly)
|
|
70
|
+
|
|
71
|
+
A cursor is not a snapshot. Resume re-runs the **bounded** query from the seek key
|
|
72
|
+
(`limit` rows, one indexed keyset read) instead of replaying a per-subscriber change
|
|
73
|
+
log, so a sync node holds no history and reconnect cost is O(limit). The price: the
|
|
74
|
+
cursor cannot prove that rows sorting *before* it are unchanged, so any epoch change β
|
|
75
|
+
new build, new policy, new schema β forces a full refetch instead of a resume. Bounded
|
|
76
|
+
server memory bought with an occasional extra page fetch.
|
|
77
|
+
|
|
78
|
+
## Matcher support
|
|
79
|
+
|
|
80
|
+
| Shape | Result |
|
|
81
|
+
|---|---|
|
|
82
|
+
| equality / `!=` / `in` / range filters | patched incrementally |
|
|
83
|
+
| `orderBy` (any number of keys) | insert position computed, moves become remove + add |
|
|
84
|
+
| `limit` | tail eviction on insert, `refill` patch on removal |
|
|
85
|
+
| joins, aggregates, `group by`, subqueries | `X_MATCHER_UNSUPPORTED` with a fix line |
|
|
86
|
+
|
|
87
|
+
An honest refusal beats a silently wrong result set, so unsupported shapes fail at
|
|
88
|
+
**subscribe** time, not on the first change event.
|
|
89
|
+
|
|
90
|
+
## Pagination is cursor-only
|
|
91
|
+
|
|
92
|
+
`offset` does not exist in this package on purpose: it makes the database count rows it
|
|
93
|
+
throws away (O(offset) per page), and any concurrent insert or delete before the offset
|
|
94
|
+
shifts every later page, so users see duplicates and holes. Cursors are opaque,
|
|
95
|
+
HMAC-signed, and bound to one query + arguments β a cursor from another query is
|
|
96
|
+
`X_CURSOR_INVALID`.
|
|
97
|
+
|
|
98
|
+
`query.page(input, { first, after })` is the only way to ask for one β `paginate` backs it and is
|
|
99
|
+
not exported, because a page is the read's own answer rather than an imported helper.
|
|
100
|
+
|
|
101
|
+
The codec lives in `@ultimat3/core`, not here: `encodeCursor`, `decodeCursor`,
|
|
102
|
+
`configureCursorSigning` (set the signing secret once at boot; rotating it invalidates every
|
|
103
|
+
open cursor) and `usesDevCursorSecret` are all imported from there. `As of 2026-08`, `x doctor`
|
|
104
|
+
reports `X_CURSOR_SECRET_DEV` when a production process is still signing with the key shipped in
|
|
105
|
+
the package, and rotating the secret is what invalidates every open cursor. This package supplies
|
|
106
|
+
the only thing that is its business β the scope, `queryHash(name, input)` β and re-exports
|
|
107
|
+
`CursorInvalidError` so the failure keeps its name on this surface.
|
|
108
|
+
|
|
109
|
+
A cursor names a **position in the ordering**, never a row and never a count. Both seek paths
|
|
110
|
+
answer "is this row after that position?" through the one predicate, `isAfterKey`: `Builder.seek()`
|
|
111
|
+
compiles it to SQL β spelled out per key, so a mixed `createdAt desc, id asc` listing is a real
|
|
112
|
+
predicate rather than an id tiebreak β and `paginate()` applies the same comparison when a source
|
|
113
|
+
has no `seek()`. Filtering by position is what makes a delete between two pages harmless; locating
|
|
114
|
+
the cursor's row by id and slicing after it silently restarts the listing the moment that row is
|
|
115
|
+
gone. A row with no `id` cannot name a position at all: that is `X_QUERY_NOT_PAGEABLE`, not a
|
|
116
|
+
cursor signed over `"undefined"`.
|
|
117
|
+
|
|
118
|
+
Because the predicate always carries that id, the ordering carries it too: a paged read is served
|
|
119
|
+
`order by <declared keys>, "id" asc`, and the in-memory path sorts by the same list. Ordering by
|
|
120
|
+
the declared keys alone leaves rows with equal sort values in whatever order the database chose,
|
|
121
|
+
while the cursor reads them as if id had decided β so one of a tied pair comes back on both pages
|
|
122
|
+
and the other on neither.
|
|
123
|
+
|
|
124
|
+
## Caching
|
|
125
|
+
|
|
126
|
+
Request memo (same read twice in one render β one round trip), then the tier behind
|
|
127
|
+
`ReadCache`. Keys are `query:<name>:<input fingerprint>:<tags>`. An action's
|
|
128
|
+
`cache.invalidates` and a query's `cache.tags` meet in the one graph owned by
|
|
129
|
+
`@ultimat3/cache`.
|
|
130
|
+
|
|
131
|
+
## Errors
|
|
132
|
+
|
|
133
|
+
| Code | When | Fix |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `X_QUERY_DUPLICATE` | two queries under one name | rename one export |
|
|
136
|
+
| `X_QUERY_POLICY_MISSING` | registration without `policy:` | add `policy: can('β¦')` |
|
|
137
|
+
| `X_MATCHER_UNSUPPORTED` | live query the matcher cannot patch | reshape it, or `live: false` |
|
|
138
|
+
| `X_CURSOR_INVALID` | tampered / foreign / malformed cursor | request the first page again |
|
|
139
|
+
| `X_QUERY_NOT_PAGEABLE` | a paged or live read returned a row with no `id` | select the primary key: `db.<rows>.select({ id: true, β¦ })` |
|
|
140
|
+
| `X_INPUT_INVALID` | input failed the Standard Schema | `x queries describe <name> --json` |
|
|
141
|
+
| `X_QUERY_UNREGISTERED` | used before `registerQueries()` ran | register at boot |
|
|
142
|
+
| `X_QUERY_FOREIGN` | a look-alike was projected as a query | declare it with `query({ β¦ })` |
|
|
143
|
+
| `X_RPC_FAILED` | `.client()` got a non-`problem+json` failure | check the gateway in front of the app |
|
|
144
|
+
|
|
145
|
+
Denials re-throw the policy layer's own codes and keep the surface denial on
|
|
146
|
+
`QueryDeniedError.denial`, so a live socket closes with 4403 instead of guessing.
|
|
147
|
+
|
|
148
|
+
## Boundaries
|
|
149
|
+
|
|
150
|
+
Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `policy`. Never imports `action`,
|
|
151
|
+
`jobs` or `realtime` (same tier) β `realtime` consumes `LiveQuery` from here.
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ultimat3/query",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
10
|
+
"directory": "packages/query"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"provenance": true
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
30
|
+
"test": "bun test"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@ultimat3/cache": "1.0.0",
|
|
34
|
+
"@ultimat3/core": "1.0.0",
|
|
35
|
+
"@ultimat3/policy": "1.0.0",
|
|
36
|
+
"@ultimat3/schema": "1.0.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read caching, two layers: a per-request memo (same query twice in one render
|
|
3
|
+
* costs one round trip) and a tag-keyed tier behind the `ReadCache` interface.
|
|
4
|
+
* Invalidation is never local β it goes through @ultimat3/cache so an action's
|
|
5
|
+
* `invalidates` and a query's `tags` meet in one graph.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CacheTag } from '@ultimat3/cache';
|
|
9
|
+
import { invalidateTags } from '@ultimat3/cache';
|
|
10
|
+
import type { Ctx } from '@ultimat3/core';
|
|
11
|
+
import { fingerprint } from './stable';
|
|
12
|
+
import { tagKeys } from './tags';
|
|
13
|
+
|
|
14
|
+
export interface ReadCacheEntry {
|
|
15
|
+
readonly value: unknown;
|
|
16
|
+
readonly expiresAt: number | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ReadCache {
|
|
20
|
+
get(key: string): Promise<ReadCacheEntry | undefined>;
|
|
21
|
+
set(key: string, entry: ReadCacheEntry): Promise<void>;
|
|
22
|
+
delete(key: string): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** In-memory default. Production installs the tiered cache from @ultimat3/cache. */
|
|
26
|
+
export class MemoryReadCache implements ReadCache {
|
|
27
|
+
readonly #entries = new Map<string, ReadCacheEntry>();
|
|
28
|
+
|
|
29
|
+
async get(key: string): Promise<ReadCacheEntry | undefined> {
|
|
30
|
+
const entry = this.#entries.get(key);
|
|
31
|
+
if (entry === undefined) return undefined;
|
|
32
|
+
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
33
|
+
this.#entries.delete(key);
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return entry;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async set(key: string, entry: ReadCacheEntry): Promise<void> {
|
|
40
|
+
this.#entries.set(key, entry);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async delete(key: string): Promise<void> {
|
|
44
|
+
this.#entries.delete(key);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let tier: ReadCache = new MemoryReadCache();
|
|
49
|
+
|
|
50
|
+
export function setReadCache(cache: ReadCache): void {
|
|
51
|
+
tier = cache;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getReadCache(): ReadCache {
|
|
55
|
+
return tier;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Request-scoped memo. Keyed by ctx identity so it dies with the request. */
|
|
59
|
+
const memos = new WeakMap<object, Map<string, unknown>>();
|
|
60
|
+
|
|
61
|
+
export function requestMemo(ctx: Ctx): Map<string, unknown> {
|
|
62
|
+
const key: object = ctx;
|
|
63
|
+
const existing = memos.get(key);
|
|
64
|
+
if (existing !== undefined) return existing;
|
|
65
|
+
const created = new Map<string, unknown>();
|
|
66
|
+
memos.set(key, created);
|
|
67
|
+
return created;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Deterministic: same query + same input + same tags => same key. */
|
|
71
|
+
export function cacheKeyFor(name: string, input: unknown, tags: readonly CacheTag[]): string {
|
|
72
|
+
return `query:${name}:${fingerprint(input)}:${tagKeys(tags).join(',')}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Memo first, then the tier, then the source. */
|
|
76
|
+
export async function readThrough<T>(
|
|
77
|
+
ctx: Ctx,
|
|
78
|
+
key: string,
|
|
79
|
+
ttlMs: number | null,
|
|
80
|
+
run: () => Promise<T>,
|
|
81
|
+
): Promise<T> {
|
|
82
|
+
const memo = requestMemo(ctx);
|
|
83
|
+
const memoized = memo.get(key);
|
|
84
|
+
if (memoized !== undefined) return memoized as T;
|
|
85
|
+
|
|
86
|
+
const cached = await tier.get(key);
|
|
87
|
+
if (cached !== undefined) {
|
|
88
|
+
memo.set(key, cached.value);
|
|
89
|
+
return cached.value as T;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const value = await run();
|
|
93
|
+
memo.set(key, value);
|
|
94
|
+
await tier.set(key, { value, expiresAt: ttlMs === null ? null : Date.now() + ttlMs });
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The one invalidation path. Actions call the same function via their `cache`. */
|
|
99
|
+
export async function invalidateQueryTags(tags: readonly CacheTag[]): Promise<void> {
|
|
100
|
+
await invalidateTags(tags);
|
|
101
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The typed read client. Types come from the query's own declaration, the URL from
|
|
3
|
+
* the same pure derivation the server uses, so a renamed query is a compile error
|
|
4
|
+
* in a Solid component rather than a 404 at runtime. Browser-safe on purpose: no
|
|
5
|
+
* server imports, nothing here touches a context, a policy or a database.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { InferInput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
9
|
+
import { QueryRequestFailedError } from './errors';
|
|
10
|
+
import { derivePath } from './naming';
|
|
11
|
+
import { isJsonObject } from './stable';
|
|
12
|
+
|
|
13
|
+
export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
|
14
|
+
|
|
15
|
+
export interface QueryClientOptions {
|
|
16
|
+
readonly baseUrl: string;
|
|
17
|
+
readonly fetch?: FetchLike;
|
|
18
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface QueryCallOptions {
|
|
22
|
+
readonly signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `feed({ orgId })` with the input schema and the row type both inferred. */
|
|
26
|
+
export type QueryClientMethod<TInput extends StandardSchemaV1, TRow extends object> = (
|
|
27
|
+
input: InferInput<TInput>,
|
|
28
|
+
options?: QueryCallOptions,
|
|
29
|
+
) => Promise<readonly TRow[]>;
|
|
30
|
+
|
|
31
|
+
/** One query's method β what `query.client()` returns. */
|
|
32
|
+
export function queryClientMethodFor<TInput extends StandardSchemaV1, TRow extends object>(
|
|
33
|
+
name: string,
|
|
34
|
+
options: QueryClientOptions,
|
|
35
|
+
): QueryClientMethod<TInput, TRow> {
|
|
36
|
+
const doFetch: FetchLike = options.fetch ?? ((input, init) => fetch(input, init));
|
|
37
|
+
const base = options.baseUrl.replace(/\/+$/, '');
|
|
38
|
+
// Erased at the wire seam; the row type is this query's by construction.
|
|
39
|
+
return (input, callOptions = {}) =>
|
|
40
|
+
read(doFetch, base, options, name, input, callOptions) as Promise<readonly TRow[]>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function read(
|
|
44
|
+
doFetch: FetchLike,
|
|
45
|
+
base: string,
|
|
46
|
+
options: QueryClientOptions,
|
|
47
|
+
name: string,
|
|
48
|
+
input: unknown,
|
|
49
|
+
callOptions: QueryCallOptions,
|
|
50
|
+
): Promise<unknown> {
|
|
51
|
+
const search = searchOf(input);
|
|
52
|
+
const url = `${base}${derivePath(name)}${search === '' ? '' : `?${search}`}`;
|
|
53
|
+
const init: RequestInit = {
|
|
54
|
+
method: 'GET',
|
|
55
|
+
headers: { accept: 'application/json', ...options.headers },
|
|
56
|
+
...(callOptions.signal === undefined ? {} : { signal: callOptions.signal }),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const response = await doFetch(url, init);
|
|
60
|
+
if (!response.ok)
|
|
61
|
+
throw new QueryRequestFailedError(name, response.status, await problemOf(response));
|
|
62
|
+
const body: unknown = await response.json();
|
|
63
|
+
return body;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Input as a query string. Keys are sorted so the same input always produces the
|
|
68
|
+
* same URL β a GET is a cache key, and an unstable one caches nothing.
|
|
69
|
+
*/
|
|
70
|
+
function searchOf(input: unknown): string {
|
|
71
|
+
if (!isJsonObject(input)) return '';
|
|
72
|
+
const params = new URLSearchParams();
|
|
73
|
+
for (const key of Object.keys(input).sort()) {
|
|
74
|
+
const value = input[key];
|
|
75
|
+
if (value === undefined || value === null) continue;
|
|
76
|
+
for (const item of Array.isArray(value) ? (value as readonly unknown[]) : [value]) {
|
|
77
|
+
params.append(key, typeof item === 'object' ? JSON.stringify(item) : String(item));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return params.toString();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `application/problem+json`, or nothing when a proxy answered instead of the app. */
|
|
84
|
+
async function problemOf(response: Response): Promise<Record<string, unknown>> {
|
|
85
|
+
const body: unknown = await response.json().catch(() => null);
|
|
86
|
+
return isJsonObject(body) ? body : {};
|
|
87
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/** Every failure @ultimat3/query can produce, one subclass per stable code. */
|
|
2
|
+
import { assertNever, registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
3
|
+
import type { SurfaceDenial } from '@ultimat3/policy';
|
|
4
|
+
|
|
5
|
+
const docs = (code: string): string => `https://ultimate.dev/errors/${code}`;
|
|
6
|
+
|
|
7
|
+
/** One class, one code: core owns the cursor codec, so core owns `X_CURSOR_INVALID`. */
|
|
8
|
+
export { CursorInvalidError } from '@ultimat3/core';
|
|
9
|
+
|
|
10
|
+
/** Titles for the framework-wide code table β every one of them owned by this package. */
|
|
11
|
+
const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
12
|
+
X_MATCHER_UNSUPPORTED: 'live query shape cannot be patched incrementally',
|
|
13
|
+
X_QUERY_DUPLICATE: 'two queries are registered under one name',
|
|
14
|
+
X_QUERY_FOREIGN: 'a value that is not a query was projected as one',
|
|
15
|
+
X_QUERY_NOT_PAGEABLE: 'a read returned rows with no id, so a cursor cannot name a position',
|
|
16
|
+
X_QUERY_POLICY_MISSING: 'a query was registered without a policy',
|
|
17
|
+
X_QUERY_UNREGISTERED: 'a query was used before it was registered',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Codes `@ultimat3/action` owns that this package only throws. Both describe an action's job β
|
|
22
|
+
* enforcing an input schema, and speaking the typed RPC wire β so action declares the title and
|
|
23
|
+
* query never re-declares it: two copies of a title are two titles, one of which is stale.
|
|
24
|
+
*/
|
|
25
|
+
export const QUERY_BORROWED_ERROR_CODES = ['X_INPUT_INVALID', 'X_RPC_FAILED'] as const;
|
|
26
|
+
|
|
27
|
+
// One unconditional call: a presence guard would turn "another package claims one of these codes"
|
|
28
|
+
// from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
|
|
29
|
+
registerErrorCodes(
|
|
30
|
+
Object.fromEntries(Object.entries(OWNED_TITLES).map(([code, title]) => [code, { title }])),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
function denialCode(denial: SurfaceDenial): string {
|
|
34
|
+
switch (denial.surface) {
|
|
35
|
+
case 'http':
|
|
36
|
+
return denial.problem.code;
|
|
37
|
+
case 'live':
|
|
38
|
+
case 'job':
|
|
39
|
+
return denial.code;
|
|
40
|
+
case 'mcp':
|
|
41
|
+
return denial.content[0]?.text.split(':')[0] ?? 'X_FORBIDDEN';
|
|
42
|
+
default:
|
|
43
|
+
return assertNever(denial);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function denialReason(denial: SurfaceDenial): string {
|
|
48
|
+
switch (denial.surface) {
|
|
49
|
+
case 'http':
|
|
50
|
+
return denial.problem.detail;
|
|
51
|
+
case 'live':
|
|
52
|
+
case 'job':
|
|
53
|
+
return denial.reason;
|
|
54
|
+
case 'mcp':
|
|
55
|
+
return denial.content[0]?.text ?? 'denied';
|
|
56
|
+
default:
|
|
57
|
+
return assertNever(denial);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* An authz denial from `guard()`. The code and reason come from the policy
|
|
63
|
+
* decision β this package never invents an authz code β and the surface-shaped
|
|
64
|
+
* denial rides along so a live socket can close with 4403 rather than a 403 body.
|
|
65
|
+
*/
|
|
66
|
+
export class QueryDeniedError extends UltimateError {
|
|
67
|
+
readonly denial: SurfaceDenial;
|
|
68
|
+
|
|
69
|
+
constructor(query: string, denial: SurfaceDenial) {
|
|
70
|
+
const code = denialCode(denial);
|
|
71
|
+
super({
|
|
72
|
+
code,
|
|
73
|
+
cause: `${query} denied: ${denialReason(denial)}`,
|
|
74
|
+
fix: `x policy explain ${query} --json # shows which clause decided and why`,
|
|
75
|
+
docs: docs(code),
|
|
76
|
+
});
|
|
77
|
+
this.denial = denial;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Thrown when a read is used before `registerQueries()` gave it a name. */
|
|
82
|
+
export class QueryUnregisteredError extends UltimateError {
|
|
83
|
+
constructor() {
|
|
84
|
+
super({
|
|
85
|
+
code: 'X_QUERY_UNREGISTERED',
|
|
86
|
+
cause: 'a query was used before it was registered, so it has no name',
|
|
87
|
+
fix: "call registerQueries(await import('./live')) at boot, before serving reads",
|
|
88
|
+
docs: docs('X_QUERY_UNREGISTERED'),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Thrown when a projection is handed something that never came out of `query()`.
|
|
95
|
+
* The declaration is private to `read.ts`, so an object that merely looks like a
|
|
96
|
+
* query has no `sql` to build and no policy to evaluate β refusing it here is how
|
|
97
|
+
* "there is one read path" stays true at runtime, not just in the types.
|
|
98
|
+
*/
|
|
99
|
+
export class QueryForeignError extends UltimateError {
|
|
100
|
+
constructor(name: string) {
|
|
101
|
+
super({
|
|
102
|
+
code: 'X_QUERY_FOREIGN',
|
|
103
|
+
cause: `"${name === '' ? 'anonymous' : name}" is not a query built by query()`,
|
|
104
|
+
fix: "declare it as `export const name = query({ input, policy, sql })` from '@ultimat3/query'",
|
|
105
|
+
docs: docs('X_QUERY_FOREIGN'),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class QueryDuplicateError extends UltimateError {
|
|
111
|
+
constructor(name: string) {
|
|
112
|
+
super({
|
|
113
|
+
code: 'X_QUERY_DUPLICATE',
|
|
114
|
+
cause: `two queries are registered under the name "${name}"`,
|
|
115
|
+
fix: 'rename one export β query names are globally unique: x queries list --json',
|
|
116
|
+
docs: docs('X_QUERY_DUPLICATE'),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export class QueryPolicyMissingError extends UltimateError {
|
|
122
|
+
constructor(name: string) {
|
|
123
|
+
super({
|
|
124
|
+
code: 'X_QUERY_POLICY_MISSING',
|
|
125
|
+
cause: `query "${name}" was registered without a policy`,
|
|
126
|
+
fix: `add \`policy: can('${name}')\` to the query definition in the file that exports it`,
|
|
127
|
+
docs: docs('X_QUERY_POLICY_MISSING'),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Thrown when a paged or live read hands back a row with no `id`. The id is the tiebreak that
|
|
134
|
+
* makes the sort order total, so without one the position a cursor names is ambiguous β and the
|
|
135
|
+
* old behaviour, `String(undefined)`, signed `"undefined"` into every cursor the read issued.
|
|
136
|
+
*/
|
|
137
|
+
export class QueryNotPageableError extends UltimateError {
|
|
138
|
+
constructor(entity: string | undefined) {
|
|
139
|
+
const subject = entity === undefined ? 'this read' : `"${entity}"`;
|
|
140
|
+
super({
|
|
141
|
+
code: 'X_QUERY_NOT_PAGEABLE',
|
|
142
|
+
cause: `a row from ${subject} has no "id", so a cursor cannot name its position`,
|
|
143
|
+
fix: `return the primary key from the query's sql: db.${entity ?? 'rows'}.select({ id: true, β¦ })`,
|
|
144
|
+
docs: docs('X_QUERY_NOT_PAGEABLE'),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The honest fallback: the matcher refuses to guess rather than patch wrongly. */
|
|
150
|
+
export class MatcherUnsupportedError extends UltimateError {
|
|
151
|
+
constructor(name: string, feature: string) {
|
|
152
|
+
super({
|
|
153
|
+
code: 'X_MATCHER_UNSUPPORTED',
|
|
154
|
+
cause: `live query "${name}" uses ${feature}, which the incremental matcher cannot patch`,
|
|
155
|
+
fix: `set \`live: false\` and poll, or reshape the query to equality filters + orderBy + limit`,
|
|
156
|
+
docs: docs('X_MATCHER_UNSUPPORTED'),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export class QueryInputInvalidError extends UltimateError {
|
|
162
|
+
constructor(name: string, detail: string) {
|
|
163
|
+
super({
|
|
164
|
+
code: 'X_INPUT_INVALID',
|
|
165
|
+
cause: `input for query "${name}" failed validation: ${detail}`,
|
|
166
|
+
fix: `x queries describe ${name} --json # prints the expected input schema`,
|
|
167
|
+
docs: docs('X_INPUT_INVALID'),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The `problem+json` fields a failing read can send back. All optional: a proxy sends none. */
|
|
173
|
+
export interface QueryProblem {
|
|
174
|
+
readonly code?: unknown;
|
|
175
|
+
readonly cause?: unknown;
|
|
176
|
+
readonly detail?: unknown;
|
|
177
|
+
readonly fix?: unknown;
|
|
178
|
+
readonly docs?: unknown;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The typed client's failure. A `problem+json` body is re-thrown verbatim β the
|
|
183
|
+
* server already said what broke and how to fix it, and inventing a second story
|
|
184
|
+
* here would bury it. Anything else answered instead of the app, so it is
|
|
185
|
+
* `X_RPC_FAILED` and the fix line points at the gateway.
|
|
186
|
+
*/
|
|
187
|
+
export class QueryRequestFailedError extends UltimateError {
|
|
188
|
+
constructor(name: string, status: number, problem: QueryProblem = {}) {
|
|
189
|
+
const code = text(problem.code) ?? 'X_RPC_FAILED';
|
|
190
|
+
super({
|
|
191
|
+
code,
|
|
192
|
+
cause: text(problem.cause) ?? text(problem.detail) ?? `${name} returned HTTP ${status}`,
|
|
193
|
+
fix:
|
|
194
|
+
text(problem.fix) ??
|
|
195
|
+
`check the gateway in front of the app, then: x queries describe ${name} --json`,
|
|
196
|
+
docs: text(problem.docs) ?? docs(code),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function text(value: unknown): string | undefined {
|
|
202
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
203
|
+
}
|
package/src/facade.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fluent surface: every projection reachable as a method on the query itself,
|
|
3
|
+
* `orgFeed.tool()` rather than `toQueryTool(orgFeed)`, and every declared field
|
|
4
|
+
* lifted off `def` so app code never reaches through `.def`. The projection
|
|
5
|
+
* functions stay exported for the framework's own call sites β this file only
|
|
6
|
+
* binds them to the query, it never re-implements one.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
10
|
+
import { queryClientMethodFor } from './client';
|
|
11
|
+
import { toLiveQuery } from './live';
|
|
12
|
+
import { toQueryTool } from './mcp-tool';
|
|
13
|
+
import { paginate } from './pagination';
|
|
14
|
+
import type { Query, QueryDef, QueryFacade } from './query';
|
|
15
|
+
import { queryName, runQuery } from './read';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `self` is a thunk on purpose: the faΓ§ade is attached while the query is still
|
|
19
|
+
* being assembled, so every method resolves the query when it is called, not now.
|
|
20
|
+
*/
|
|
21
|
+
export function facadeFor<TInput extends StandardSchemaV1, TRow extends object>(
|
|
22
|
+
def: QueryDef<TInput, TRow>,
|
|
23
|
+
self: () => Query<TInput, TRow>,
|
|
24
|
+
): QueryFacade<TInput, TRow> {
|
|
25
|
+
return {
|
|
26
|
+
input: def.input,
|
|
27
|
+
policy: def.policy,
|
|
28
|
+
...(def.cache === undefined ? {} : { cache: def.cache }),
|
|
29
|
+
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
30
|
+
// `.as()` is impersonation on the one read path: `runQuery` keeps the
|
|
31
|
+
// surrounding context whole and swaps only the actor.
|
|
32
|
+
as: (actor, input, options) => runQuery(self(), input, { ...options, actor }),
|
|
33
|
+
// A page is the read's own answer, not a helper someone has to import: the
|
|
34
|
+
// signed cursor is only reachable through the query that issued it.
|
|
35
|
+
page: (input, args) => paginate(self(), input, args),
|
|
36
|
+
live: (input, options) => toLiveQuery(self(), input, options),
|
|
37
|
+
tool: () => toQueryTool(self()),
|
|
38
|
+
client: (options) => queryClientMethodFor(queryName(self()), options),
|
|
39
|
+
};
|
|
40
|
+
}
|