@ultimat3/query 23.0.0 → 25.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 +75 -37
- package/README.md +147 -32
- package/package.json +8 -8
- package/src/audit-errors.ts +43 -0
- package/src/audit-gate.ts +177 -0
- package/src/cache.ts +4 -4
- package/src/client.ts +19 -6
- package/src/column-kinds.ts +28 -0
- package/src/cursor-value.ts +3 -3
- package/src/errors.ts +6 -3
- package/src/facade.ts +6 -7
- package/src/http.ts +43 -33
- package/src/index.ts +37 -61
- package/src/input-shape.ts +21 -1
- package/src/live.ts +19 -3
- package/src/matcher.ts +14 -7
- package/src/openapi.ts +24 -17
- package/src/page-controls.ts +5 -9
- package/src/page-keys.ts +1 -1
- package/src/pagination.ts +88 -46
- package/src/policy-gate.ts +17 -49
- package/src/query.ts +22 -56
- package/src/rate-limit-gate.ts +71 -0
- package/src/read.ts +97 -14
- package/src/record-answer.ts +9 -5
- package/src/registry.ts +1 -1
- package/src/search.ts +53 -35
- package/src/shape.ts +52 -85
- package/src/single-answer.ts +22 -0
- package/src/source.ts +26 -11
- package/src/sql.ts +3 -3
- package/src/stable.ts +0 -3
- package/src/subscribes.ts +3 -3
- package/src/type-pins.ts +62 -0
- package/src/deprecation.ts +0 -76
- package/src/mcp-tool.ts +0 -81
- package/src/naming.ts +0 -22
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two ways an audited read fails because of its audit, and nothing else. Their own module so
|
|
3
|
+
* `errors.ts` keeps one job — the codes' TITLES are registered there, with every other code this
|
|
4
|
+
* package owns — and so a read that declares no audit never constructs either.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { UltimateError } from '@ultimat3/core';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `audit: true` with no sink installed. Raised before the input is parsed and before anything is
|
|
11
|
+
* read — the twin of `@ultimat3/action`'s `X_AUDIT_SINK_MISSING`, and for the same reason there is
|
|
12
|
+
* no logger-backed default to fall back on: a line nobody stores would satisfy the declaration
|
|
13
|
+
* while recording nothing, which for a read is "who saw what" silently answered "nobody".
|
|
14
|
+
*/
|
|
15
|
+
export class QueryAuditSinkMissingError extends UltimateError {
|
|
16
|
+
constructor(query: string) {
|
|
17
|
+
super({
|
|
18
|
+
code: 'X_QUERY_AUDIT_SINK_MISSING',
|
|
19
|
+
cause: `query "${query}" declares \`audit: true\` and no audit sink is installed`,
|
|
20
|
+
// The registered fix, verbatim: code first, so the line can be pasted (`wiki/Error-Codes.md`).
|
|
21
|
+
fix: 'setAuditSink(sink) # from @ultimat3/core, at boot before defineApi()',
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The sink refused the record of a read that SUCCEEDED, so the rows are withheld. The opposite of
|
|
28
|
+
* the action twin's position on one point: an action's handler had already committed and only the
|
|
29
|
+
* record is missing, while a read has changed nothing — so refusing the answer costs one retry,
|
|
30
|
+
* and handing back rows nobody recorded is exactly the gap an audited read exists to close.
|
|
31
|
+
*/
|
|
32
|
+
export class QueryAuditSinkFailedError extends UltimateError {
|
|
33
|
+
constructor(query: string, sourceError: unknown) {
|
|
34
|
+
super({
|
|
35
|
+
code: 'X_QUERY_AUDIT_SINK_FAILED',
|
|
36
|
+
cause: `"${query}" was read and its audit sink refused the record, so the rows were withheld — a read commits nothing, so nothing is left to reconcile`,
|
|
37
|
+
// The sink's own error is `sourceError`; a read commits nothing, so the retry is safe.
|
|
38
|
+
fix: 'setAuditSink(sink) # install a sink that accepts the record, then retry the read',
|
|
39
|
+
meta: { query },
|
|
40
|
+
sourceError,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only file in this package that calls an `AuditSink`: `query({ audit: true })` records every
|
|
3
|
+
* call — allowed, denied, failed — into the one sink `@ultimat3/core` holds, in the record shape
|
|
4
|
+
* `@ultimat3/action` writes. It observes the read path; it never forks it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AuditOutcome, AuditRecord, AuditSink, Ctx } from '@ultimat3/core';
|
|
8
|
+
import { getAuditSink, isUltimateError, logger } from '@ultimat3/core';
|
|
9
|
+
import { QueryAuditSinkFailedError, QueryAuditSinkMissingError } from './audit-errors';
|
|
10
|
+
import { QueryDeniedError } from './errors';
|
|
11
|
+
import type { QuerySurface } from './policy-gate';
|
|
12
|
+
import type { SqlSource } from './source';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What the read path learns on the way through, for the record — the same two facts `invoke`'s
|
|
16
|
+
* trace carries for an action, minus the idempotency key a read never has. Mutable on purpose:
|
|
17
|
+
* `buildSource` fills `input`, the memo fills `replayed`, and reading them back out here is what
|
|
18
|
+
* keeps the audited branch from becoming a second read path.
|
|
19
|
+
*/
|
|
20
|
+
export interface ReadTrace {
|
|
21
|
+
input: unknown;
|
|
22
|
+
replayed: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One audited call, resolved before anything is read: the sink, and what every record shares. */
|
|
26
|
+
export interface ReadAudit {
|
|
27
|
+
readonly sink: AuditSink;
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly ctx: Ctx;
|
|
30
|
+
readonly surface: QuerySurface;
|
|
31
|
+
readonly trace: ReadTrace;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `null` when the read is not audited — not declared, or built `unenforced`, which has no caller
|
|
36
|
+
* to attribute a sighting to (`explain`, the shared live window). Otherwise the sink, or
|
|
37
|
+
* `X_QUERY_AUDIT_SINK_MISSING` BEFORE the input parse: an audited read nothing can record refuses
|
|
38
|
+
* while it has still read nothing.
|
|
39
|
+
*/
|
|
40
|
+
export function readAuditFor(
|
|
41
|
+
audit: boolean | undefined,
|
|
42
|
+
unenforced: string | undefined,
|
|
43
|
+
name: string,
|
|
44
|
+
ctx: Ctx,
|
|
45
|
+
surface: QuerySurface,
|
|
46
|
+
): ReadAudit | null {
|
|
47
|
+
if (audit !== true || unenforced !== undefined) return null;
|
|
48
|
+
const sink = getAuditSink();
|
|
49
|
+
if (sink === null) throw new QueryAuditSinkMissingError(name);
|
|
50
|
+
return { sink, name, ctx, surface, trace: { input: undefined, replayed: false } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One call, one record. A memo or cache hit is recorded like a miss — with `replayed: true` — and
|
|
55
|
+
* that is the point of the option rather than an accident of where it sits: an audit of a read is
|
|
56
|
+
* about WHO SAW WHAT, and a reader handed rows from the request memo saw them exactly as much as
|
|
57
|
+
* the reader whose call executed the SQL. Recording only executions would answer "what did the
|
|
58
|
+
* database compute", which no auditor asks.
|
|
59
|
+
*
|
|
60
|
+
* The failure policies are `@ultimat3/action`'s: a refused record of a denied or failed read is
|
|
61
|
+
* logged and the original error still reaches the caller (replacing an `X_FORBIDDEN` would turn
|
|
62
|
+
* the audit backend's health into an oracle); a refused record of an ALLOWED read withholds the
|
|
63
|
+
* rows (`X_QUERY_AUDIT_SINK_FAILED`) — a read commits nothing, so refusing costs one retry.
|
|
64
|
+
*/
|
|
65
|
+
export async function audited<T>(audit: ReadAudit, run: () => Promise<T>): Promise<T> {
|
|
66
|
+
// When the attempt began, from the context's clock — never `new Date()`.
|
|
67
|
+
const at = audit.ctx.now();
|
|
68
|
+
let value: T;
|
|
69
|
+
try {
|
|
70
|
+
value = await run();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
await recordThrew(audit.sink, recordOf(audit, at, readOutcomeFor(error), error));
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
// Outside the `catch` on purpose, as in `invoke`: a refused record here describes the RECORD,
|
|
76
|
+
// and letting it fall into that branch would write a second record claiming the read failed.
|
|
77
|
+
await recordSettled(audit.sink, recordOf(audit, at, 'allowed', undefined));
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The public `sourceFor`'s half: a build that refuses (denied, unparsed, rate-limited) is a call
|
|
83
|
+
* that ended, recorded now because no `execute()` will follow; a build that succeeds records
|
|
84
|
+
* nothing yet and hands back a source whose every execution is recorded.
|
|
85
|
+
*/
|
|
86
|
+
export async function auditedBuild<TRow>(
|
|
87
|
+
audit: ReadAudit,
|
|
88
|
+
build: () => Promise<SqlSource<TRow>>,
|
|
89
|
+
): Promise<SqlSource<TRow>> {
|
|
90
|
+
const at = audit.ctx.now();
|
|
91
|
+
let source: SqlSource<TRow>;
|
|
92
|
+
try {
|
|
93
|
+
source = await build();
|
|
94
|
+
} catch (error) {
|
|
95
|
+
await recordThrew(audit.sink, recordOf(audit, at, readOutcomeFor(error), error));
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
return auditedSource(source, audit);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A source handed OUT of this package (`sourceFor`) for an audited read: every `execute()` is one
|
|
103
|
+
* call and one record — `@ultimat3/mcp`'s served tool builds through `sourceFor` and executes once,
|
|
104
|
+
* so its reads are recorded under `surface: 'mcp'` without a second path. `seek()` and `total()`
|
|
105
|
+
* answer audited sources too, since `paginate` and a live build execute what those return.
|
|
106
|
+
*/
|
|
107
|
+
export function auditedSource<TRow>(source: SqlSource<TRow>, audit: ReadAudit): SqlSource<TRow> {
|
|
108
|
+
// Each execution is its own call: a fresh trace, so one call's facts never leak into the next.
|
|
109
|
+
const call = (): ReadAudit => ({ ...audit, trace: { ...audit.trace, replayed: false } });
|
|
110
|
+
const { total, seek } = source;
|
|
111
|
+
return {
|
|
112
|
+
toSQL: () => source.toSQL(),
|
|
113
|
+
shape: () => source.shape(),
|
|
114
|
+
execute: () => audited(call(), () => source.execute()),
|
|
115
|
+
...(total === undefined ? {} : { total: () => auditedSource(total.call(source), audit) }),
|
|
116
|
+
...(seek === undefined
|
|
117
|
+
? {}
|
|
118
|
+
: { seek: (after, limit) => auditedSource(seek.call(source, after, limit), audit) }),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* An authz refusal is `denied`; everything else that threw is `failed`, an unparsed input and a
|
|
124
|
+
* spent rate limit included — the same split `@ultimat3/action`'s `auditOutcomeFor` makes, and
|
|
125
|
+
* TOTAL for the same reason: `instanceof` runs a `Proxy`'s trap, and a probe that threw inside
|
|
126
|
+
* this `catch` would replace the caller's error with its own.
|
|
127
|
+
*/
|
|
128
|
+
export function readOutcomeFor(error: unknown): AuditOutcome {
|
|
129
|
+
try {
|
|
130
|
+
return error instanceof QueryDeniedError ? 'denied' : 'failed';
|
|
131
|
+
} catch {
|
|
132
|
+
return 'failed';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function recordOf(audit: ReadAudit, at: Date, outcome: AuditOutcome, error: unknown): AuditRecord {
|
|
137
|
+
return {
|
|
138
|
+
at,
|
|
139
|
+
name: audit.name,
|
|
140
|
+
primitive: 'query',
|
|
141
|
+
mutator: false,
|
|
142
|
+
surface: audit.surface,
|
|
143
|
+
ctx: audit.ctx,
|
|
144
|
+
// The PARSED input, exactly as an action's record carries it — never the raw payload, and
|
|
145
|
+
// never the rows: a sink that persists redacts through `auditableInput`, the walk core's
|
|
146
|
+
// credential table and `Secret` decide, and a result is absent from the record by design.
|
|
147
|
+
input: audit.trace.input,
|
|
148
|
+
idempotencyKey: null,
|
|
149
|
+
replayed: audit.trace.replayed,
|
|
150
|
+
outcome,
|
|
151
|
+
failure:
|
|
152
|
+
outcome === 'allowed' ? null : { code: isUltimateError(error) ? error.code : null, error },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function recordSettled(sink: AuditSink, record: AuditRecord): Promise<void> {
|
|
157
|
+
try {
|
|
158
|
+
await sink.write(record);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
throw new QueryAuditSinkFailedError(record.name, error);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function recordThrew(sink: AuditSink, record: AuditRecord): Promise<void> {
|
|
165
|
+
try {
|
|
166
|
+
await sink.write(record);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
// The same line `@ultimat3/action` writes, so `audit.sink.failed` is one alert rule over every
|
|
169
|
+
// audited primitive. Never the record: rendering an input the sink just choked on is the
|
|
170
|
+
// second throw this branch exists to prevent.
|
|
171
|
+
logger.error('audit.sink.failed', {
|
|
172
|
+
query: record.name,
|
|
173
|
+
outcome: record.outcome,
|
|
174
|
+
error,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
package/src/cache.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { CacheStack, CacheTag, CacheTier } from '@ultimat3/cache';
|
|
9
|
-
import {
|
|
9
|
+
import { cacheStack, registeredTiers, tagKeys } from '@ultimat3/cache';
|
|
10
10
|
import type { Actor, Clock, Ctx } from '@ultimat3/core';
|
|
11
11
|
import { assertNever, fingerprint } from '@ultimat3/core';
|
|
12
12
|
|
|
@@ -164,7 +164,7 @@ async function publish<T>(
|
|
|
164
164
|
/**
|
|
165
165
|
* One stack per (registry, clock) — never one per read.
|
|
166
166
|
*
|
|
167
|
-
* `
|
|
167
|
+
* `cacheStack` owns a single-flight map, so a stack built per call joins nothing and the
|
|
168
168
|
* cross-request stampede guard would be a no-op. Keyed on the clock because the stack's expiry
|
|
169
169
|
* decision and the tiers' own have to agree: a tier registered with a frozen clock under a stack
|
|
170
170
|
* reading the wall clock calls every entry expired, which is the shape that made the old read
|
|
@@ -182,7 +182,7 @@ function stackFor(clock: Clock): CacheStack {
|
|
|
182
182
|
// read, and `resetTiers()` between suites. Compared element-wise by identity: a tier object is
|
|
183
183
|
// registered once and never mutated, so two equal lists are the same ladder.
|
|
184
184
|
if (held !== undefined && sameTiers(held.tiers, tiers)) return held.stack;
|
|
185
|
-
const stack =
|
|
185
|
+
const stack = cacheStack(tiers, { clock });
|
|
186
186
|
stacks.set(clock, { tiers, stack });
|
|
187
187
|
return stack;
|
|
188
188
|
}
|
|
@@ -208,7 +208,7 @@ export function readThrough<T>(
|
|
|
208
208
|
* package's own. Runs once per key per request; the rest join it at the memo above.
|
|
209
209
|
*
|
|
210
210
|
* Everything this used to do by hand — the fence sampled before the load, `bestEffort` around
|
|
211
|
-
* every tier call, the expiry — is `
|
|
211
|
+
* every tier call, the expiry — is `cacheStack`'s, which is the point: there was one read
|
|
212
212
|
* cache too many, and the one that lived here was in no registry, so `invalidateTags` could not
|
|
213
213
|
* reach it. A relative `ttlMs` and never an absolute expiry: the tier's own clock decides when
|
|
214
214
|
* the entry dies, so a tier registered with a frozen clock is drivable end to end.
|
package/src/client.ts
CHANGED
|
@@ -11,15 +11,14 @@
|
|
|
11
11
|
* transport, its rows adopted into the page's store, so the return type is the rows either way.
|
|
12
12
|
*
|
|
13
13
|
* `ClientFlight` is a TYPE here and never a value: dedup, retry, the deadline and the fence are
|
|
14
|
-
* `@ultimat3/core`'s `client-flight.ts`, and a caller that never calls `
|
|
14
|
+
* `@ultimat3/core`'s `client-flight.ts`, and a caller that never calls `clientFlight` does
|
|
15
15
|
* not pay a byte for any of them — an `import type` is erased and the value import would not be.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { ClientFlight, ClientRetry } from '@ultimat3/core';
|
|
19
19
|
import type { FetchLike, RecordEnvelope } from '@ultimat3/core/page';
|
|
20
|
-
import { clientTransport, isJsonObject } from '@ultimat3/core/page';
|
|
20
|
+
import { clientTransport, isJsonObject, queryPath } from '@ultimat3/core/page';
|
|
21
21
|
import type { InferInput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
22
|
-
import { derivePath } from './naming';
|
|
23
22
|
import type { PageControls } from './page-controls';
|
|
24
23
|
import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-keys';
|
|
25
24
|
import type { Page } from './pagination';
|
|
@@ -33,7 +32,7 @@ export interface QueryClientOptions {
|
|
|
33
32
|
readonly fetch?: FetchLike;
|
|
34
33
|
readonly headers?: Readonly<Record<string, string>>;
|
|
35
34
|
/**
|
|
36
|
-
* Opt-in flight control for every read this client makes — `
|
|
35
|
+
* Opt-in flight control for every read this client makes — `clientFlight({ principal })`.
|
|
37
36
|
* Absent, a read is one dispatch and nothing else, which is what every caller written before
|
|
38
37
|
* this option existed already gets.
|
|
39
38
|
*/
|
|
@@ -183,7 +182,7 @@ function read(
|
|
|
183
182
|
const search = searchOf(input, page);
|
|
184
183
|
return clientTransport({
|
|
185
184
|
method: 'GET',
|
|
186
|
-
url: `${base}${
|
|
185
|
+
url: `${base}${queryPath(name)}${search === '' ? '' : `?${search}`}`,
|
|
187
186
|
// The trace and budget are the transport's, from its server-side outbound slot, placed
|
|
188
187
|
// before these so an explicit `traceparent` still wins; a browser bundles none of it.
|
|
189
188
|
...(options.headers === undefined ? {} : { headers: options.headers }),
|
|
@@ -203,6 +202,9 @@ function read(
|
|
|
203
202
|
* The page controls come LAST, after the sorted input and in a fixed order, so a paged URL is the
|
|
204
203
|
* plain URL with a suffix — the same dedup key for the same page, and a log line a reader can
|
|
205
204
|
* split at `_first=` to recover the read underneath.
|
|
205
|
+
*
|
|
206
|
+
* An EMPTY array sends nothing, and the route reads a required array nobody sent as `[]`
|
|
207
|
+
* (`absentArraysOf`, `input-shape.ts`) — the two halves of one rule, so `{ tags: [] }` arrives.
|
|
206
208
|
*/
|
|
207
209
|
function searchOf(input: unknown, page?: PageControls): string {
|
|
208
210
|
const params = new URLSearchParams();
|
|
@@ -211,7 +213,7 @@ function searchOf(input: unknown, page?: PageControls): string {
|
|
|
211
213
|
const value = input[key];
|
|
212
214
|
if (value === undefined || value === null) continue;
|
|
213
215
|
for (const item of Array.isArray(value) ? (value as readonly unknown[]) : [value]) {
|
|
214
|
-
params.append(key,
|
|
216
|
+
params.append(key, wireText(item));
|
|
215
217
|
}
|
|
216
218
|
}
|
|
217
219
|
}
|
|
@@ -221,3 +223,14 @@ function searchOf(input: unknown, page?: PageControls): string {
|
|
|
221
223
|
}
|
|
222
224
|
return params.toString();
|
|
223
225
|
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* One value as the characters `coerceQuery` reads back. A `Date` is its ISO instant: through
|
|
229
|
+
* `JSON.stringify` it arrived wrapped in quotes, which `t.date` refuses on the far side. An
|
|
230
|
+
* Invalid Date has no instant, and `toISOString()` throws a bare `RangeError` on it, so it is sent
|
|
231
|
+
* as the text it prints and the read's own schema says what is wrong with it.
|
|
232
|
+
*/
|
|
233
|
+
function wireText(item: unknown): string {
|
|
234
|
+
if (item instanceof Date) return Number.isNaN(item.getTime()) ? String(item) : item.toISOString();
|
|
235
|
+
return typeof item === 'object' ? JSON.stringify(item) : String(item);
|
|
236
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Single responsibility: the DECLARED kind of each column of the relation a read names. Postgres
|
|
2
|
+
// compares two values by their column's type, so every comparison this package makes asks here
|
|
3
|
+
// first and hands the answer to `@ultimat3/entity`'s comparator — never `typeof` the value.
|
|
4
|
+
|
|
5
|
+
import type { ColumnKind } from '@ultimat3/entity';
|
|
6
|
+
import { entityForTable } from '@ultimat3/entity';
|
|
7
|
+
|
|
8
|
+
/** A column's declared kind, or `undefined` when nothing declares one. */
|
|
9
|
+
export type KindOf = (column: string) => ColumnKind | undefined;
|
|
10
|
+
|
|
11
|
+
const UNDECLARED: KindOf = () => undefined;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The kinds of `QueryShape.entity`'s columns. Resolved once per read, sort or change event — the
|
|
15
|
+
* registry is a scan — and never per comparison.
|
|
16
|
+
*
|
|
17
|
+
* A relation no entity declares (`from('report', rows)` over a projection) has no kinds: its
|
|
18
|
+
* values compare as `@ultimat3/entity` compares an undeclared column, by what they are. `money` is
|
|
19
|
+
* two physical columns behind one property, so the property alone names no single value to order.
|
|
20
|
+
*/
|
|
21
|
+
export function kindsOf(relation: string): KindOf {
|
|
22
|
+
const columns = entityForTable(relation)?.$columns;
|
|
23
|
+
if (columns === undefined) return UNDECLARED;
|
|
24
|
+
return (column) => {
|
|
25
|
+
const kind = Object.hasOwn(columns, column) ? columns[column]?.$meta.kind : undefined;
|
|
26
|
+
return kind === 'money' ? undefined : kind;
|
|
27
|
+
};
|
|
28
|
+
}
|
package/src/cursor-value.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Single responsibility: what a sort value becomes inside a cursor, and what it becomes again on
|
|
2
2
|
// the way out. The codec is `@ultimat3/core`'s and it is JSON, so a `Date` went in and an ISO
|
|
3
|
-
// STRING came back: `isAfterKey` then compared `"1769904000000"` against `"2026-02-01T…"`
|
|
4
|
-
//
|
|
3
|
+
// STRING came back: `isAfterKey` then compared `"1769904000000"` against `"2026-02-01T…"` as
|
|
4
|
+
// text and page two came back empty. A `bigint` was worse — a bare
|
|
5
5
|
// `TypeError` out of `JSON.stringify`, with no code and no fix.
|
|
6
6
|
//
|
|
7
7
|
// `@ultimat3/entity`'s `cursor.ts` solves the same problem by reading the column's declared kind.
|
|
8
|
-
// A
|
|
8
|
+
// A read over a relation NO entity declares has no kinds to read (`column-kinds.ts`), so the value
|
|
9
9
|
// carries its own tag instead. Self-describing, which is also what makes the revive total: nothing
|
|
10
10
|
// here has to know which read minted the cursor.
|
|
11
11
|
|
package/src/errors.ts
CHANGED
|
@@ -9,8 +9,8 @@ import type { SurfaceDenial } from '@ultimat3/policy';
|
|
|
9
9
|
// no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x answered
|
|
10
10
|
// 404, host included, on every read this package has ever refused.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
// `CursorInvalidError` is core's and imported from there: core owns the cursor codec, so core
|
|
13
|
+
// owns `X_CURSOR_INVALID` and its one import path.
|
|
14
14
|
|
|
15
15
|
/** Titles for the framework-wide code table — every one of them owned by this package. */
|
|
16
16
|
const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
@@ -28,6 +28,9 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
|
28
28
|
X_QUERY_SUBSCRIBES_INVALID: 'a query declares subscribed relations no live read can use',
|
|
29
29
|
X_QUERY_UNREGISTERED: 'a query was used before it was registered',
|
|
30
30
|
X_QUERY_SINGLE_INVALID: 'a query declares single: as something other than a boolean',
|
|
31
|
+
X_QUERY_AUDIT_SINK_MISSING: 'a query declares audit: true and no audit sink is installed',
|
|
32
|
+
X_QUERY_AUDIT_SINK_FAILED:
|
|
33
|
+
'an audited read was refused because the audit sink rejected its record',
|
|
31
34
|
};
|
|
32
35
|
|
|
33
36
|
/**
|
|
@@ -74,7 +77,7 @@ function denialReason(denial: SurfaceDenial): string {
|
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
/**
|
|
77
|
-
* An authz denial from `
|
|
80
|
+
* An authz denial from `guardQuery()`. The code and reason come from the policy
|
|
78
81
|
* decision — this package never invents an authz code — and the surface-shaped
|
|
79
82
|
* denial rides along so a live socket can close with 4403 rather than a 403 body.
|
|
80
83
|
*/
|
package/src/facade.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The fluent surface: every projection reachable as a method on the query
|
|
3
|
-
* `orgFeed.
|
|
4
|
-
* lifted off `def` so app code never reaches through `.def`. The projection
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* The fluent surface: every projection this package owns reachable as a method on the query
|
|
3
|
+
* itself, `orgFeed.live(input)` rather than `toLiveQuery(orgFeed, input)`, and every declared
|
|
4
|
+
* field lifted off `def` so app code never reaches through `.def`. The projection functions stay
|
|
5
|
+
* exported for the framework's own call sites — this file only binds them to the query, it never
|
|
6
|
+
* re-implements one. There is no `.tool()`: the MCP tool is `@ultimat3/mcp`'s one projection
|
|
7
|
+
* (`toolFrom(orgFeed)`), a tier this package cannot import.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
10
11
|
import type { QueryClientMethodOf } from './client';
|
|
11
12
|
import { queryClientMethodFor } from './client';
|
|
12
13
|
import { toLiveQuery } from './live';
|
|
13
|
-
import { toQueryTool } from './mcp-tool';
|
|
14
14
|
import { paginate } from './pagination';
|
|
15
15
|
import type { Query, QueryDef, QueryFacade } from './query';
|
|
16
16
|
import { queryName, runQuery } from './read';
|
|
@@ -45,7 +45,6 @@ export function facadeFor<
|
|
|
45
45
|
// signed cursor is only reachable through the query that issued it.
|
|
46
46
|
page: (input, args) => paginate(self(), input, args),
|
|
47
47
|
live: (input, options) => toLiveQuery(self(), input, options),
|
|
48
|
-
tool: () => toQueryTool(self()),
|
|
49
48
|
// One implementation for both wire shapes: the route decides the body, the transport parses
|
|
50
49
|
// it, and only the TYPE differs — a single read's method answers the row, and has no `page`.
|
|
51
50
|
client: (options) =>
|
package/src/http.ts
CHANGED
|
@@ -6,28 +6,24 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { tagKeys } from '@ultimat3/cache';
|
|
9
|
-
import type {
|
|
10
|
-
import {
|
|
9
|
+
import type { Deprecation } from '@ultimat3/core';
|
|
10
|
+
import { queryPath, recordDeprecatedCall, renderDeprecation, useContext } from '@ultimat3/core';
|
|
11
|
+
import type { RateLimitDecision, Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
|
|
12
|
+
import { publishRateLimit, toBucket } from '@ultimat3/http';
|
|
13
|
+
import { admitsAnonymous, policyCapability } from '@ultimat3/policy';
|
|
11
14
|
import { coerceQuery } from '@ultimat3/schema';
|
|
12
|
-
import
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
QueryDeprecationInvalidError,
|
|
16
|
-
QueryInputInvalidError,
|
|
17
|
-
QueryRowNotFoundError,
|
|
18
|
-
} from './errors';
|
|
19
|
-
import { derivePath } from './naming';
|
|
15
|
+
import { QueryDeprecationInvalidError, QueryInputInvalidError } from './errors';
|
|
16
|
+
import { absentArraysOf } from './input-shape';
|
|
20
17
|
import { PAGE_FIRST_KEY, pageControlsOf } from './page-controls';
|
|
21
|
-
import {
|
|
22
|
-
import type { AnyQuery } from './query';
|
|
18
|
+
import type { AnyQuery, QueryRateLimit } from './query';
|
|
23
19
|
import { queryName, runQuery } from './read';
|
|
24
20
|
import { recordAnswerFor, recordRowAnswerFor } from './record-answer';
|
|
21
|
+
import { oneRowOf } from './single-answer';
|
|
25
22
|
|
|
26
23
|
/**
|
|
27
24
|
* `liveFeed` -> `GET /_x/query/live-feed`. Named for the primitive rather than spelled
|
|
28
25
|
* `toRoute`, because a host mounts this beside `@ultimat3/action`'s and an alias at the
|
|
29
|
-
* import site is a name the reader has to hold
|
|
30
|
-
* here is `toQueryTool`.
|
|
26
|
+
* import site is a name the reader has to hold.
|
|
31
27
|
*/
|
|
32
28
|
export function toQueryRoute(target: AnyQuery): Route {
|
|
33
29
|
const name = queryName(target);
|
|
@@ -62,8 +58,16 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
62
58
|
//
|
|
63
59
|
// The two page controls come OUT first (`page-controls.ts`): they are the route's, not the
|
|
64
60
|
// read's, and a schema that refused unknown keys would otherwise refuse every paged call.
|
|
61
|
+
// The read spends its declared limit itself (`spendReadLimit`, on every surface), keyed on
|
|
62
|
+
// this request's address for an anonymous reader, and hands the decision back for the headers.
|
|
63
|
+
const spending = {
|
|
64
|
+
surface: 'http',
|
|
65
|
+
clientAddress: request.ctx.ip,
|
|
66
|
+
onRateLimit: (decision: RateLimitDecision) =>
|
|
67
|
+
publishRateLimit(request.ctx, decision, useContext().now().getTime()),
|
|
68
|
+
} as const;
|
|
65
69
|
const { input: values, page } = pageControlsOf(name, request.queryRaw());
|
|
66
|
-
const input = coerceQuery(target.input, values);
|
|
70
|
+
const input = absentArraysOf(target.input, coerceQuery(target.input, values));
|
|
67
71
|
if (target.single === true) {
|
|
68
72
|
// Refused, not ignored: a caller paging a read of one object has the wrong read in mind, and
|
|
69
73
|
// a silently dropped `_first` would answer a shape it did not ask for.
|
|
@@ -75,21 +79,18 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
75
79
|
}
|
|
76
80
|
// The first row — what every in-process `[0]` of the same read already takes. None is the
|
|
77
81
|
// 404 a detail URL means, where a list read answers `200 []`.
|
|
78
|
-
|
|
79
|
-
if (row === undefined) throw new QueryRowNotFoundError(name);
|
|
80
|
-
return answerRow(row);
|
|
82
|
+
return answerRow(oneRowOf(name, await runQuery(target, input, spending)));
|
|
81
83
|
}
|
|
82
84
|
// With a page control the answer is the `Page` envelope `query.page()` answers a server
|
|
83
|
-
// caller with — `{ rows, nextCursor, hasMore
|
|
84
|
-
// last two aliases of the first two), so a cursor read off the
|
|
85
|
+
// caller with — `{ rows, nextCursor, hasMore }`, the same names, so a cursor read off the
|
|
85
86
|
// wire and one read off a direct call are the same string in the same field. Without one the
|
|
86
87
|
// answer is the bare array it has always been: every client written before the controls
|
|
87
|
-
// existed keeps reading rows, and `
|
|
88
|
-
//
|
|
88
|
+
// existed keeps reading rows, and `hasMore` never rides on a row where a page marker has no
|
|
89
|
+
// business being.
|
|
89
90
|
return answer(
|
|
90
91
|
page === undefined
|
|
91
|
-
? await runQuery(target, input,
|
|
92
|
-
: await target.page(input, { ...page,
|
|
92
|
+
? await runQuery(target, input, spending)
|
|
93
|
+
: await target.page(input, { ...page, ...spending }),
|
|
93
94
|
);
|
|
94
95
|
};
|
|
95
96
|
|
|
@@ -106,6 +107,9 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
106
107
|
// rule reads (`ownsOrg(actor, input.orgId)`); the stage would decide the same policy
|
|
107
108
|
// from raw strings, and would need an `authorize` hook wired to decide at all.
|
|
108
109
|
enforcedBy: 'handler',
|
|
110
|
+
// Which primitive this is, for `@ultimat3/http`'s bucket check (a bucket named after a query
|
|
111
|
+
// limits nothing since 25.0.0, and is refused rather than ignored).
|
|
112
|
+
primitive: 'query',
|
|
109
113
|
// `input` stays absent, deliberately: the pipeline's body stage validates `meta.input`
|
|
110
114
|
// against the BODY, and a GET has none — declaring it would fail every read on an
|
|
111
115
|
// absent body before the handler ran. The schema is not skipped, it is applied in the
|
|
@@ -118,23 +122,29 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
118
122
|
// ride along so a purge can still name the read the tier keys by.
|
|
119
123
|
cache: { mode: 'no-store', tags: tagKeys(target.cache?.tags ?? []) },
|
|
120
124
|
tags: ['query'],
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
// and a read declaring
|
|
124
|
-
//
|
|
125
|
-
//
|
|
125
|
+
// The read spends its own declared bucket on every surface (`read.ts`), so the stage spends
|
|
126
|
+
// no caller bucket here: not this one (the old HTTP-only enforcement point the MCP tool never
|
|
127
|
+
// reached), and not `default` (a ceiling a read declaring more than it never had). The tenant
|
|
128
|
+
// allowance stays the stage's. Refused at mount, never on the first read, when the pair is one
|
|
129
|
+
// the limiter cannot run on: `toBucket` is `@ultimat3/http`'s, so the numbers cannot drift.
|
|
126
130
|
...(target.rateLimit === undefined
|
|
127
131
|
? {}
|
|
128
|
-
: {
|
|
132
|
+
: { rateLimitedBy: rateLimitedByHandler(name, target.rateLimit) }),
|
|
129
133
|
...(target.mcp?.description === undefined ? {} : { description: target.mcp.description }),
|
|
130
134
|
};
|
|
131
135
|
|
|
132
|
-
return { method: 'GET', path:
|
|
136
|
+
return { method: 'GET', path: queryPath(name), handler, meta };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Validates the declaration through the limiter's own conversion, then hands the route its flag. */
|
|
140
|
+
function rateLimitedByHandler(name: string, limit: QueryRateLimit): 'handler' {
|
|
141
|
+
toBucket(name, limit);
|
|
142
|
+
return 'handler';
|
|
133
143
|
}
|
|
134
144
|
|
|
135
145
|
/**
|
|
136
146
|
* The headers this read's `deprecated:` block renders to, or nothing. The successor's URL comes
|
|
137
|
-
* from `
|
|
147
|
+
* from `queryPath` — the same derivation `client()` uses, never a second one.
|
|
138
148
|
*/
|
|
139
149
|
function deprecationHeadersFor(
|
|
140
150
|
name: string,
|
|
@@ -142,7 +152,7 @@ function deprecationHeadersFor(
|
|
|
142
152
|
): Readonly<Record<string, string>> | undefined {
|
|
143
153
|
if (deprecated === undefined) return undefined;
|
|
144
154
|
const successor =
|
|
145
|
-
deprecated.replacedBy === undefined ? undefined :
|
|
155
|
+
deprecated.replacedBy === undefined ? undefined : queryPath(deprecated.replacedBy);
|
|
146
156
|
const rendered = renderDeprecation(deprecated, successor);
|
|
147
157
|
if (!rendered.ok) throw new QueryDeprecationInvalidError(name, rendered.field, rendered.value);
|
|
148
158
|
return rendered.headers;
|