@ultimat3/query 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +393 -0
- package/README.md +179 -10
- package/package.json +7 -5
- package/src/cache.ts +180 -71
- package/src/client.ts +83 -2
- package/src/cursor-value.ts +65 -0
- package/src/deprecation.ts +81 -0
- package/src/errors.ts +97 -1
- package/src/facade.ts +2 -0
- package/src/http.ts +109 -0
- package/src/index.ts +40 -9
- package/src/input-shape.ts +74 -0
- package/src/live.ts +27 -3
- package/src/matcher.ts +36 -9
- package/src/mcp-tool.ts +11 -5
- package/src/naming.ts +5 -9
- package/src/pagination.ts +25 -3
- package/src/policy-gate.ts +13 -2
- package/src/query.ts +108 -8
- package/src/read.ts +116 -16
- package/src/shape.ts +103 -6
- package/src/source.ts +137 -49
- package/src/sql.ts +2 -2
- package/src/stable.ts +23 -12
- package/src/tags.ts +0 -17
package/src/naming.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one naming rule for reads: a query's export name derives its HTTP path
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The one naming rule for reads: a query's export name derives its HTTP path.
|
|
3
|
+
* Pure string math, so the browser client derives the same URL without importing
|
|
4
|
+
* a byte of server code. Ported rather than imported from @ultimat3/action: that
|
|
5
|
+
* package is the same tier, and tiers never go sideways. The MCP tool name is NOT
|
|
6
|
+
* derived — it is the export name verbatim, so there is one name to call.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
/** Every read is served under one prefix, so a router can claim it in one rule. */
|
|
@@ -31,8 +32,3 @@ export function toKebabCase(name: string): string {
|
|
|
31
32
|
export function derivePath(name: string): string {
|
|
32
33
|
return `${QUERY_PREFIX}/${toKebabCase(name)}`;
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
-
/** MCP tool names are `snake_case`: `liveFeed` -> `live_feed`. */
|
|
36
|
-
export function toToolName(name: string): string {
|
|
37
|
-
return splitWords(name).join('_');
|
|
38
|
-
}
|
package/src/pagination.ts
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* The codec is `@ultimat3/core`'s. This file only decides what a cursor is bound
|
|
10
10
|
* to — `queryHash(name, input)` — so one read's cursor cannot page another.
|
|
11
11
|
*/
|
|
12
|
-
import { decodeCursor, encodeCursor } from '@ultimat3/core';
|
|
12
|
+
import { assert, decodeCursor, encodeCursor } from '@ultimat3/core';
|
|
13
13
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
14
|
+
import { reviveSortKey, serializeSortValue } from './cursor-value';
|
|
14
15
|
import type { Query, SourceOptions } from './query';
|
|
15
16
|
import { queryHash, queryName, sourceFor } from './query';
|
|
16
17
|
import type { QueryShape, SeekKey } from './shape';
|
|
@@ -29,6 +30,15 @@ export interface PaginateArgs extends SourceOptions {
|
|
|
29
30
|
readonly after?: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The largest page a read will serve. A TWIN of `@ultimat3/entity`'s `MAX_PAGE_SIZE` — this
|
|
35
|
+
* package holds no dependency on that one, the same compromise `naming.ts` and `deprecation.ts`
|
|
36
|
+
* are ported under — and it exists for the same reason: `first` reaches here straight from an
|
|
37
|
+
* action's input or a route parameter, so `args.first + 1` bound whatever a client sent and one
|
|
38
|
+
* request could ask for five million rows.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_PAGE_SIZE = 10_000;
|
|
41
|
+
|
|
32
42
|
/**
|
|
33
43
|
* One page. Push-down when the source implements `seek()`; otherwise the rows are
|
|
34
44
|
* sliced after execution and the source is doing more work than it should.
|
|
@@ -38,12 +48,21 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
|
|
|
38
48
|
input: unknown,
|
|
39
49
|
args: PaginateArgs,
|
|
40
50
|
): Promise<Page<TRow>> {
|
|
51
|
+
assert(
|
|
52
|
+
Number.isInteger(args.first) && args.first >= 1 && args.first <= MAX_PAGE_SIZE,
|
|
53
|
+
`first must be a whole number of rows between 1 and ${MAX_PAGE_SIZE}`,
|
|
54
|
+
`read.page(input, { first: Math.min(requested, ${MAX_PAGE_SIZE}) }) — or bound it in the input schema: t.number.int().min(1).max(50)`,
|
|
55
|
+
);
|
|
41
56
|
const name = queryName(target);
|
|
42
57
|
const hash = queryHash(name, input);
|
|
43
58
|
// The scope is this read plus these arguments: a cursor from anywhere else is
|
|
44
59
|
// already `X_CURSOR_INVALID` by the time it gets here.
|
|
45
60
|
const decoded = args.after === undefined ? null : decodeCursor(args.after, hash);
|
|
46
|
-
|
|
61
|
+
// Revived to the types the columns hold, never left as the strings JSON handed back: a `Date`
|
|
62
|
+
// key decoded as an ISO string reaches `compareValues` as text and is compared against the
|
|
63
|
+
// row's own millisecond number, so page two matched nothing at all. See `cursor-value.ts`.
|
|
64
|
+
const after: SeekKey | null =
|
|
65
|
+
decoded === null ? null : { key: reviveSortKey(decoded.key), id: decoded.id };
|
|
47
66
|
const base = await sourceFor(target, input, args);
|
|
48
67
|
const shape = base.shape();
|
|
49
68
|
|
|
@@ -59,7 +78,10 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
|
|
|
59
78
|
|
|
60
79
|
return {
|
|
61
80
|
rows,
|
|
62
|
-
endCursor:
|
|
81
|
+
endCursor:
|
|
82
|
+
seek === null
|
|
83
|
+
? null
|
|
84
|
+
: encodeCursor({ scope: hash, key: seek.key.map(serializeSortValue), id: seek.id }),
|
|
63
85
|
hasNextPage: scoped.length > args.first,
|
|
64
86
|
};
|
|
65
87
|
}
|
package/src/policy-gate.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
8
8
|
import { assertNever, isAnonymous } from '@ultimat3/core';
|
|
9
9
|
import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
|
|
10
|
-
import { enforce } from '@ultimat3/policy';
|
|
10
|
+
import { enforce, policyPermissions as flattenedPermissions } from '@ultimat3/policy';
|
|
11
11
|
import { QueryDeniedError } from './errors';
|
|
12
12
|
|
|
13
13
|
/** Policies are opaque here: we evaluate them, we never introspect their rules. */
|
|
@@ -60,7 +60,18 @@ export function actorOf(ctx: Ctx): Actor | null {
|
|
|
60
60
|
return isAnonymous(ctx.actor) ? null : ctx.actor;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/** The capability a read requires, for manifests and the `/_x` dashboard. */
|
|
63
|
+
/** The capability a read requires, for manifests and the `/_x` dashboard. A DISPLAY label. */
|
|
64
64
|
export function policyCapability(policy: QueryPolicy): string {
|
|
65
65
|
return policy.label;
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every permission the policy tree references, flattened and deduped — and the only field a
|
|
70
|
+
* compliance report may match a grant against. `label` renders a composite as
|
|
71
|
+
* `or(feed:read, org:administer)`, which is a sentence and never equals a permission string, so
|
|
72
|
+
* matching on it reported every read guarded by a composite as enforcing nothing. The mirror of
|
|
73
|
+
* `@ultimat3/action`'s, because `x policy list` reads both lists the same way.
|
|
74
|
+
*/
|
|
75
|
+
export function policyPermissions(policy: QueryPolicy): readonly string[] {
|
|
76
|
+
return flattenedPermissions(policy);
|
|
77
|
+
}
|
package/src/query.ts
CHANGED
|
@@ -7,24 +7,40 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { CacheTag } from '@ultimat3/cache';
|
|
10
|
+
import { tagKeys } from '@ultimat3/cache';
|
|
10
11
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
11
12
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
13
|
+
import type { QueryCacheScope } from './cache';
|
|
12
14
|
import type { QueryClientMethod, QueryClientOptions } from './client';
|
|
15
|
+
import type { Deprecation } from './deprecation';
|
|
16
|
+
import { QueryCacheTtlInvalidError } from './errors';
|
|
13
17
|
import { facadeFor } from './facade';
|
|
18
|
+
import { assertEncodableInput } from './input-shape';
|
|
14
19
|
import type { LiveQuery, ToLiveOptions } from './live';
|
|
15
20
|
import type { QueryToolDescriptor } from './mcp-tool';
|
|
16
21
|
import type { Page, PaginateArgs } from './pagination';
|
|
17
22
|
import type { QueryPolicy, QuerySurface } from './policy-gate';
|
|
18
|
-
import { policyCapability } from './policy-gate';
|
|
23
|
+
import { policyCapability, policyPermissions } from './policy-gate';
|
|
19
24
|
import { hasDef, queryName, runQuery, stashDef } from './read';
|
|
20
25
|
import type { SqlSource } from './source';
|
|
21
26
|
import { fingerprint } from './stable';
|
|
22
|
-
import { tagKeys } from './tags';
|
|
23
27
|
|
|
24
28
|
export interface QueryCache {
|
|
25
29
|
/** Tags this read depends on. An action's `invalidates` drops exactly these keys. */
|
|
26
30
|
readonly tags: readonly CacheTag[];
|
|
31
|
+
/**
|
|
32
|
+
* Lifetime of a tier entry. Positive and finite — `Infinity`, `0` and `NaN` are refused at
|
|
33
|
+
* `query()` (`X_QUERY_CACHE_TTL_INVALID`), where the file that wrote them fails, rather than on
|
|
34
|
+
* every read of that query forever. Omitted takes `DEFAULT_READ_CACHE_TTL_MS`.
|
|
35
|
+
*/
|
|
27
36
|
readonly ttlMs?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Who a cached answer may be handed back to. **Defaults to `actor`**, and that default is the
|
|
39
|
+
* mechanism: a read that declares nothing gets the narrowest key, which is always correct.
|
|
40
|
+
* `tenant` and `global` are written statements that the rows do not depend on the caller beyond
|
|
41
|
+
* their org, or at all — see `readAuthority`.
|
|
42
|
+
*/
|
|
43
|
+
readonly scope?: QueryCacheScope;
|
|
28
44
|
}
|
|
29
45
|
|
|
30
46
|
export interface QueryMcp {
|
|
@@ -40,6 +56,17 @@ export interface QueryMcp {
|
|
|
40
56
|
readonly visibleTo?: readonly string[];
|
|
41
57
|
}
|
|
42
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The symmetric twin of `ActionRateLimit`, and the field whose absence meant a read could not be
|
|
61
|
+
* throttled at all: every `GET /_x/query/*` fell through to the `default` bucket (120 burst, 2/s
|
|
62
|
+
* per actor), so one authenticated caller could hold 120 cross-tenant aggregates in flight and
|
|
63
|
+
* then 2/s forever, from a single account, with no declaration able to say otherwise.
|
|
64
|
+
*/
|
|
65
|
+
export interface QueryRateLimit {
|
|
66
|
+
readonly limit: number;
|
|
67
|
+
readonly windowMs: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
43
70
|
export interface QueryDef<TInput extends StandardSchemaV1, TRow extends object> {
|
|
44
71
|
readonly input: TInput;
|
|
45
72
|
readonly policy: QueryPolicy;
|
|
@@ -48,12 +75,27 @@ export interface QueryDef<TInput extends StandardSchemaV1, TRow extends object>
|
|
|
48
75
|
sql(input: InferOutput<TInput>, ctx: Ctx): SqlSource<TRow>;
|
|
49
76
|
readonly cache?: QueryCache;
|
|
50
77
|
readonly mcp?: QueryMcp;
|
|
78
|
+
/** Burst and refill for THIS read's route, in the limiter's own vocabulary. */
|
|
79
|
+
readonly rateLimit?: QueryRateLimit;
|
|
80
|
+
/**
|
|
81
|
+
* On its way out. `Deprecation` and `Sunset` response headers (RFC 9745 / RFC 8594), a
|
|
82
|
+
* `rel="successor-version"` link when `replacedBy` names one, and a
|
|
83
|
+
* `deprecated_calls_total{name}` counter — the only way to answer "is anyone still reading
|
|
84
|
+
* it?" before deleting it. Versioning itself is two deployments behind one ingress, not a
|
|
85
|
+
* router feature; see the README.
|
|
86
|
+
*/
|
|
87
|
+
readonly deprecated?: Deprecation;
|
|
51
88
|
}
|
|
52
89
|
|
|
53
90
|
export interface QueryOptions {
|
|
54
91
|
readonly ctx?: Ctx;
|
|
55
92
|
readonly surface?: QuerySurface;
|
|
56
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Skips every cache for this call — the request memo as well as the tiers, since a memo is a
|
|
95
|
+
* cache with a request's lifetime. The one way to read past a write made earlier in the same
|
|
96
|
+
* request: what it reads replaces the memo entry, so the next plain read of this key in this
|
|
97
|
+
* request sees the write too. Live fanout always reads fresh.
|
|
98
|
+
*/
|
|
57
99
|
readonly fresh?: boolean;
|
|
58
100
|
/**
|
|
59
101
|
* Run as someone else. Omitted keeps the context's own actor; `null` is the
|
|
@@ -65,20 +107,42 @@ export interface QueryOptions {
|
|
|
65
107
|
|
|
66
108
|
export interface SourceOptions extends QueryOptions {
|
|
67
109
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
110
|
+
* Build this source WITHOUT evaluating its policy, and say WHY — a written reason, never a bare
|
|
111
|
+
* boolean. It was `enforce: false`, and a boolean is wrong here for the reason
|
|
112
|
+
* `@ultimat3/entity`'s `cross-tenant.ts` gives for the same shape: it reads exactly like
|
|
113
|
+
* forgetting the check. The reason is the mechanism, not documentation of it — a blank one is
|
|
114
|
+
* refused (`X_INVARIANT`), so an escape with no argument cannot be written at all, and every
|
|
115
|
+
* skipped policy in a codebase is one `grep` away with the justification attached.
|
|
116
|
+
*
|
|
117
|
+
* Two situations qualify, and both are reads with no subscriber to decide about: developer
|
|
118
|
+
* tooling that returns no rows (`explain`, `describeSql`) and the shared, subject-less window a
|
|
119
|
+
* sync node builds once per `(query, input)` — see `ToLiveOptions.enforce`, which is that one
|
|
120
|
+
* use spelled as a boolean because it has exactly one reason and this file already states it.
|
|
121
|
+
*
|
|
122
|
+
* It is deliberately NOT gated on a capability the way `crossTenant` is: `explain` runs from the
|
|
123
|
+
* CLI with no actor at all to check one against, so a scope requirement would close the one
|
|
124
|
+
* surface this exists for. The rows are still tenant-scoped by `@ultimat3/entity` under the
|
|
125
|
+
* caller's own context, which `read.ts` now installs.
|
|
71
126
|
*/
|
|
72
|
-
readonly
|
|
127
|
+
readonly unenforced?: string;
|
|
73
128
|
}
|
|
74
129
|
|
|
75
130
|
export interface QueryDescriptor {
|
|
76
131
|
readonly kind: 'query';
|
|
77
132
|
readonly name: string;
|
|
78
133
|
readonly live: boolean;
|
|
134
|
+
/** The policy's DISPLAY label. A composite renders as `or(a:b, c:d)` — never a permission. */
|
|
79
135
|
readonly capability: string;
|
|
136
|
+
/**
|
|
137
|
+
* Every permission the policy tree references, flattened. The field a compliance report
|
|
138
|
+
* matches a grant against; `capability` is a label and matching on it reported every
|
|
139
|
+
* composite-guarded read as enforcing nothing.
|
|
140
|
+
*/
|
|
141
|
+
readonly permissions: readonly string[];
|
|
80
142
|
readonly tags: readonly string[];
|
|
81
143
|
readonly ttlMs: number | null;
|
|
144
|
+
readonly rateLimit: QueryRateLimit | null;
|
|
145
|
+
readonly deprecated: Deprecation | null;
|
|
82
146
|
}
|
|
83
147
|
|
|
84
148
|
/**
|
|
@@ -93,6 +157,8 @@ export interface AnyQueryDef {
|
|
|
93
157
|
sql(input: unknown, ctx: Ctx): SqlSource<object>;
|
|
94
158
|
readonly cache?: QueryCache;
|
|
95
159
|
readonly mcp?: QueryMcp;
|
|
160
|
+
readonly rateLimit?: QueryRateLimit;
|
|
161
|
+
readonly deprecated?: Deprecation;
|
|
96
162
|
}
|
|
97
163
|
|
|
98
164
|
export interface AnyQuery {
|
|
@@ -105,6 +171,9 @@ export interface AnyQuery {
|
|
|
105
171
|
readonly policy: QueryPolicy;
|
|
106
172
|
readonly cache?: QueryCache;
|
|
107
173
|
readonly mcp?: QueryMcp;
|
|
174
|
+
/** Lifted so `toQueryRoute` reads the declaration without reaching through `defOf`. */
|
|
175
|
+
readonly rateLimit?: QueryRateLimit;
|
|
176
|
+
readonly deprecated?: Deprecation;
|
|
108
177
|
describe(): QueryDescriptor;
|
|
109
178
|
/** A twin under another name. Registration names through `named`. */
|
|
110
179
|
named(name: string): AnyQuery;
|
|
@@ -141,15 +210,42 @@ export interface Query<
|
|
|
141
210
|
/** The fluent half of a query: lifted declaration plus one method per projection. */
|
|
142
211
|
export type QueryFacade<TInput extends StandardSchemaV1, TRow extends object> = Pick<
|
|
143
212
|
Query<TInput, TRow>,
|
|
144
|
-
|
|
213
|
+
| 'input'
|
|
214
|
+
| 'policy'
|
|
215
|
+
| 'cache'
|
|
216
|
+
| 'mcp'
|
|
217
|
+
| 'rateLimit'
|
|
218
|
+
| 'deprecated'
|
|
219
|
+
| 'as'
|
|
220
|
+
| 'page'
|
|
221
|
+
| 'live'
|
|
222
|
+
| 'tool'
|
|
223
|
+
| 'client'
|
|
145
224
|
>;
|
|
146
225
|
|
|
147
226
|
export function query<TInput extends StandardSchemaV1, TRow extends object>(
|
|
148
227
|
def: QueryDef<TInput, TRow>,
|
|
149
228
|
): Query<TInput, TRow> {
|
|
229
|
+
// Here and not in `toQueryRoute`: a read is projected to `GET /_x/query/<kebab>` whether or not
|
|
230
|
+
// anyone mounts it, and the typed client derives that same URL — so an input a query string
|
|
231
|
+
// cannot carry is wrong for every call, and the file that declared it is where it is repaired.
|
|
232
|
+
assertEncodableInput(def.input);
|
|
233
|
+
assertCacheTtl(def.cache);
|
|
150
234
|
return build(def, '');
|
|
151
235
|
}
|
|
152
236
|
|
|
237
|
+
/**
|
|
238
|
+
* The same rule, for the same reason: a lease every `CacheTier` refuses is wrong for every read of
|
|
239
|
+
* this query, so it fails on the line that wrote it rather than on the first request. Positive and
|
|
240
|
+
* finite is `assertTtl`'s bar in `@ultimat3/cache`, restated as a refusal and never as a second
|
|
241
|
+
* resolution — there is no "never expires" to fall back to.
|
|
242
|
+
*/
|
|
243
|
+
function assertCacheTtl(cache: QueryCache | undefined): void {
|
|
244
|
+
const ttlMs = cache?.ttlMs;
|
|
245
|
+
if (ttlMs === undefined) return;
|
|
246
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new QueryCacheTtlInvalidError(ttlMs);
|
|
247
|
+
}
|
|
248
|
+
|
|
153
249
|
/**
|
|
154
250
|
* Structural, not nominal: an object only counts as a query if `query()` built it,
|
|
155
251
|
* because only then does a declaration exist for `sourceFor` to read. A look-alike
|
|
@@ -203,8 +299,12 @@ export function describeQuery(target: AnyQuery): QueryDescriptor {
|
|
|
203
299
|
name: queryName(target),
|
|
204
300
|
live: target.isLive,
|
|
205
301
|
capability: policyCapability(target.policy),
|
|
302
|
+
// The flattened list, beside the label and never instead of it: one is read, one is matched.
|
|
303
|
+
permissions: policyPermissions(target.policy),
|
|
206
304
|
tags: tagKeys(target.cache?.tags ?? []),
|
|
207
305
|
ttlMs: target.cache?.ttlMs ?? null,
|
|
306
|
+
rateLimit: target.rateLimit ?? null,
|
|
307
|
+
deprecated: target.deprecated ?? null,
|
|
208
308
|
};
|
|
209
309
|
}
|
|
210
310
|
|
package/src/read.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
import type { Ctx } from '@ultimat3/core';
|
|
9
9
|
import {
|
|
10
10
|
anonymousActor,
|
|
11
|
+
assert,
|
|
11
12
|
createContext,
|
|
13
|
+
logger,
|
|
12
14
|
runWithContext,
|
|
13
15
|
tryUseContext,
|
|
14
16
|
useContext,
|
|
@@ -17,7 +19,14 @@ import {
|
|
|
17
19
|
} from '@ultimat3/core';
|
|
18
20
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
19
21
|
import { formatPath, validateAsync } from '@ultimat3/schema';
|
|
20
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
cacheKeyFor,
|
|
24
|
+
DEFAULT_READ_CACHE_TTL_MS,
|
|
25
|
+
readAuthority,
|
|
26
|
+
readFresh,
|
|
27
|
+
readOnce,
|
|
28
|
+
readThrough,
|
|
29
|
+
} from './cache';
|
|
21
30
|
import { QueryForeignError, QueryInputInvalidError, QueryUnregisteredError } from './errors';
|
|
22
31
|
import { actorOf, guard } from './policy-gate';
|
|
23
32
|
import type { AnyQuery, AnyQueryDef, Query, QueryOptions, SourceOptions } from './query';
|
|
@@ -57,8 +66,23 @@ export function queryName(target: AnyQuery): string {
|
|
|
57
66
|
export function runQuery<TInput extends StandardSchemaV1, TRow extends object>(
|
|
58
67
|
target: Query<TInput, TRow>,
|
|
59
68
|
raw: unknown,
|
|
69
|
+
options?: QueryOptions,
|
|
70
|
+
): Promise<readonly TRow[]>;
|
|
71
|
+
/**
|
|
72
|
+
* The same read from a schema-erased handle. The route projection maps `listQueries()`,
|
|
73
|
+
* which knows only `AnyQuery` — an overload rather than a second function, so what is
|
|
74
|
+
* gone is the row TYPE and never the parse, the policy or the memo.
|
|
75
|
+
*/
|
|
76
|
+
export function runQuery(
|
|
77
|
+
target: AnyQuery,
|
|
78
|
+
raw: unknown,
|
|
79
|
+
options?: QueryOptions,
|
|
80
|
+
): Promise<readonly object[]>;
|
|
81
|
+
export function runQuery(
|
|
82
|
+
target: AnyQuery,
|
|
83
|
+
raw: unknown,
|
|
60
84
|
options: QueryOptions = {},
|
|
61
|
-
): Promise<readonly
|
|
85
|
+
): Promise<readonly object[]> {
|
|
62
86
|
return asActor(options, (ctx) => readRows(target, raw, ctx, options));
|
|
63
87
|
}
|
|
64
88
|
|
|
@@ -81,7 +105,14 @@ export function sourceFor(
|
|
|
81
105
|
* core models it as the anonymous actor. Omitting `actor` touches no context at all.
|
|
82
106
|
*/
|
|
83
107
|
function asActor<T>(options: QueryOptions, run: (ctx: Ctx) => Promise<T>): Promise<T> {
|
|
84
|
-
if (options.actor === undefined)
|
|
108
|
+
if (options.actor === undefined) {
|
|
109
|
+
// INSTALLED, never only handed over — the same fix `@ultimat3/action`'s `invoke` carries, for
|
|
110
|
+
// the same reason: `@ultimat3/entity`'s tenant guard derives from `tryUseContext()`, so a read
|
|
111
|
+
// built under an explicit `ctx` evaluated its policy against that actor and its row tenancy
|
|
112
|
+
// against nobody. Absent a `ctx` this reinstalls the ambient one, which changes nothing.
|
|
113
|
+
const ctx = options.ctx ?? useContext();
|
|
114
|
+
return runWithContext(ctx, () => run(ctx));
|
|
115
|
+
}
|
|
85
116
|
const patch = { actor: options.actor ?? anonymousActor() };
|
|
86
117
|
const inChild = (): Promise<T> => run(useContext());
|
|
87
118
|
const base = options.ctx ?? tryUseContext();
|
|
@@ -90,23 +121,72 @@ function asActor<T>(options: QueryOptions, run: (ctx: Ctx) => Promise<T>): Promi
|
|
|
90
121
|
: runWithContext(base, () => withChildContext(patch, inChild));
|
|
91
122
|
}
|
|
92
123
|
|
|
93
|
-
|
|
94
|
-
|
|
124
|
+
/**
|
|
125
|
+
* The span covers the WHOLE read, not just `execute()`. Wrapping the execution alone left the
|
|
126
|
+
* input parse, the policy evaluation and `sql()`'s own construction outside every span, so a read
|
|
127
|
+
* whose cost was in building the source reported milliseconds while its parent HTTP span reported
|
|
128
|
+
* seconds — a gap with no name, which reads as framework overhead and gets hand-instrumented.
|
|
129
|
+
*
|
|
130
|
+
* Attributes are bounded: surface, actor KIND, booleans, and a row count. Never the input, never
|
|
131
|
+
* an actor id — a read is keyed per tenant and per cursor, and either would be unbounded.
|
|
132
|
+
*/
|
|
133
|
+
function readRows(
|
|
134
|
+
target: AnyQuery,
|
|
135
|
+
raw: unknown,
|
|
136
|
+
ctx: Ctx,
|
|
137
|
+
options: QueryOptions,
|
|
138
|
+
): Promise<readonly object[]> {
|
|
139
|
+
const name = queryName(target);
|
|
140
|
+
return withSpan(`query.${name}`, async (span) => {
|
|
141
|
+
span.setAttributes({
|
|
142
|
+
'ultimate.primitive': 'query',
|
|
143
|
+
'ultimate.query': name,
|
|
144
|
+
'ultimate.surface': options.surface ?? 'server',
|
|
145
|
+
'ultimate.actor.kind': ctx.actor.kind,
|
|
146
|
+
'ultimate.live': target.isLive,
|
|
147
|
+
'ultimate.fresh': options.fresh === true,
|
|
148
|
+
// Whether this read goes through the tier at all. Every read is memoized; only a `cache:`
|
|
149
|
+
// read is filled — and which of the two a slow read took is the first thing to ask.
|
|
150
|
+
'ultimate.cached': defOf(target).cache !== undefined,
|
|
151
|
+
});
|
|
152
|
+
const rows = await readRowsIn(target, raw, ctx, options);
|
|
153
|
+
span.setAttribute('ultimate.rows', rows.length);
|
|
154
|
+
return rows;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function readRowsIn(
|
|
159
|
+
target: AnyQuery,
|
|
95
160
|
raw: unknown,
|
|
96
161
|
ctx: Ctx,
|
|
97
162
|
options: QueryOptions,
|
|
98
|
-
): Promise<readonly
|
|
163
|
+
): Promise<readonly object[]> {
|
|
99
164
|
const def = defOf(target);
|
|
100
165
|
const name = queryName(target);
|
|
101
166
|
const source = await buildSource(target, raw, ctx, options);
|
|
102
|
-
const read = (): Promise<readonly object[]> =>
|
|
103
|
-
// The source came from this query's own `sql()`, so its rows are TRow
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
167
|
+
const read = (): Promise<readonly object[]> => source.execute();
|
|
168
|
+
// The source came from this query's own `sql()`, so its rows are TRow throughout —
|
|
169
|
+
// which is what the typed overload above states, and this body never has to assert.
|
|
170
|
+
const tags = def.cache?.tags ?? [];
|
|
171
|
+
// The authority is part of the key on EVERY read, cached or not, because there is one key
|
|
172
|
+
// function and a second one beside it is what this package's own rule forbids. It costs the memo
|
|
173
|
+
// nothing — a memo is already per-ctx — and it is the whole of what the tier was missing: keyed
|
|
174
|
+
// on the name, the input and the tags alone, the process-wide tier handed one org's rows to the
|
|
175
|
+
// next org that asked for them. `actor` when the declaration named no scope, always.
|
|
176
|
+
const key = cacheKeyFor(name, raw, tags, readAuthority(ctx.actor, def.cache?.scope ?? 'actor'));
|
|
177
|
+
// `fresh` is the caller saying no cache may answer this one — the memo included, a memo being
|
|
178
|
+
// a cache whose lifetime is the request. It still *publishes* into the memo: this read is the
|
|
179
|
+
// newest answer the request has, so the next plain read of the key joins it rather than the
|
|
180
|
+
// entry a write earlier in the request already moved past.
|
|
181
|
+
if (options.fresh === true) return await readFresh(ctx, key, read);
|
|
182
|
+
// `cache:` buys the tier, never the memo: a read asked twice in one request is one execution
|
|
183
|
+
// whether or not its author opted into caching.
|
|
184
|
+
// A declared `cache:` with no `ttlMs` gets one anyway. Tags are the primary eviction, but a
|
|
185
|
+
// read whose tags never fire would otherwise hold one entry per distinct input for the life of
|
|
186
|
+
// the process — a paginated feed over 10k tenants is 10k immortal entries.
|
|
187
|
+
return def.cache === undefined
|
|
188
|
+
? await readOnce(ctx, key, read)
|
|
189
|
+
: await readThrough(ctx, key, def.cache.ttlMs ?? DEFAULT_READ_CACHE_TTL_MS, read, tags);
|
|
110
190
|
}
|
|
111
191
|
|
|
112
192
|
async function buildSource(
|
|
@@ -118,14 +198,34 @@ async function buildSource(
|
|
|
118
198
|
const def = defOf(target);
|
|
119
199
|
const name = queryName(target);
|
|
120
200
|
const input = await validate(def.input, raw, name);
|
|
121
|
-
|
|
201
|
+
const unenforced = options.unenforced;
|
|
202
|
+
if (unenforced === undefined) {
|
|
122
203
|
guard(
|
|
123
204
|
def.policy,
|
|
124
205
|
{ actor: actorOf(ctx), input, ctx, query: name },
|
|
125
206
|
options.surface ?? 'server',
|
|
126
207
|
);
|
|
208
|
+
} else {
|
|
209
|
+
// The reason IS the mechanism, exactly as it is for `crossTenant`: a blank one leaves the
|
|
210
|
+
// escape with no argument, and the next reader cannot tell a considered skip from a forgotten
|
|
211
|
+
// policy. Refused before the source is built, so nothing is read on a blank justification.
|
|
212
|
+
assert(
|
|
213
|
+
unenforced.trim() !== '',
|
|
214
|
+
`query "${name}" was built with a blank unenforced reason, so the policy it skips carries no argument`,
|
|
215
|
+
`pass why this read needs no subject: sourceFor(target, input, { unenforced: 'explain returns no rows' })`,
|
|
216
|
+
);
|
|
217
|
+
// The audit half. `debug`, because the two shipped callers are dev tooling and a sync node's
|
|
218
|
+
// once-per-query-id window — never a per-request path — and core's logger costs one level
|
|
219
|
+
// comparison when nothing is listening.
|
|
220
|
+
logger.debug('query.policy.unenforced', { query: name, reason: unenforced });
|
|
127
221
|
}
|
|
128
|
-
|
|
222
|
+
const source = def.sql(input, ctx);
|
|
223
|
+
// A live window is served in the order its patches are placed in. The matcher breaks a tie on
|
|
224
|
+
// the declared keys with `id` (`totalOrder`) and so does the keyset re-read a reconnect resumes
|
|
225
|
+
// with, so an initial window served in the declared keys alone puts tied rows where neither of
|
|
226
|
+
// them would: the client renders one order and the next read answers another. A source that
|
|
227
|
+
// cannot say (`total` absent) already serves one order it can be resumed in.
|
|
228
|
+
return options.surface === 'live' && source.total !== undefined ? source.total() : source;
|
|
129
229
|
}
|
|
130
230
|
|
|
131
231
|
async function validate(schema: StandardSchemaV1, raw: unknown, name: string): Promise<unknown> {
|
package/src/shape.ts
CHANGED
|
@@ -57,6 +57,32 @@ export function seekKeyOf(
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** Appended by `totalOrder`. Ascending whatever the declared keys do — so is the seek predicate. */
|
|
61
|
+
const ID_TIEBREAK: OrderKey = { column: 'id', direction: 'asc' };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The ordering a read is actually served in: the declared keys, then `id` to make it total.
|
|
65
|
+
*
|
|
66
|
+
* Two rows sharing every declared sort value have no order at all without it — the database
|
|
67
|
+
* returns them either way round while `isAfterKey` decides the next page as though `id` had
|
|
68
|
+
* settled it. `Builder.seek()` compiles this list, `paginate()` sorts by it, and the matcher
|
|
69
|
+
* places a row by it: a row inserted at the end of a tie group is a row the next re-read finds
|
|
70
|
+
* somewhere else. An ordering that already names `id` is total, and adding a second `id` term
|
|
71
|
+
* would compare the key to itself.
|
|
72
|
+
*/
|
|
73
|
+
export function totalOrder(orderBy: readonly OrderKey[]): readonly OrderKey[] {
|
|
74
|
+
return orderBy.some((key) => key.column === 'id') ? orderBy : [...orderBy, ID_TIEBREAK];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* SQL NULL, as a row spells it. A column the row simply omits reads `undefined` here and NULL
|
|
79
|
+
* in Postgres, so both are the same absence — otherwise a fixture row without `deletedAt` and
|
|
80
|
+
* the same row round-tripped through a driver answer `where({ deletedAt: null })` differently.
|
|
81
|
+
*/
|
|
82
|
+
export function isNull(value: unknown): boolean {
|
|
83
|
+
return value === null || value === undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
60
86
|
export function matchesFilters(row: object, filters: readonly Filter[]): boolean {
|
|
61
87
|
return filters.every((filter) => matchesFilter(row, filter));
|
|
62
88
|
}
|
|
@@ -71,33 +97,104 @@ export function matchesFilter(row: object, filter: Filter): boolean {
|
|
|
71
97
|
case 'in':
|
|
72
98
|
return Array.isArray(filter.value) && filter.value.some((item) => same(actual, item));
|
|
73
99
|
case '>':
|
|
74
|
-
return compareValues(actual, filter.value) > 0;
|
|
75
100
|
case '>=':
|
|
76
|
-
return compareValues(actual, filter.value) >= 0;
|
|
77
101
|
case '<':
|
|
78
|
-
return compareValues(actual, filter.value) < 0;
|
|
79
102
|
case '<=':
|
|
80
|
-
return
|
|
103
|
+
return ordered(filter.op, actual, filter.value);
|
|
81
104
|
default:
|
|
82
105
|
return false;
|
|
83
106
|
}
|
|
84
107
|
}
|
|
85
108
|
|
|
86
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* `col > NULL` is unknown in SQL and unknown is not a match, so a NULL on either side of an
|
|
111
|
+
* ordering operator matches nothing here either. Only `=`, `!=` and `in` read NULL as a value —
|
|
112
|
+
* and those are exactly the three `Builder.toSQL()` compiles to `is null` / `is distinct from`.
|
|
113
|
+
*/
|
|
114
|
+
function ordered(op: '>' | '>=' | '<' | '<=', actual: unknown, value: unknown): boolean {
|
|
115
|
+
if (isNull(actual) || isNull(value)) return false;
|
|
116
|
+
const result = compareValues(actual, value);
|
|
117
|
+
switch (op) {
|
|
118
|
+
case '>':
|
|
119
|
+
return result > 0;
|
|
120
|
+
case '>=':
|
|
121
|
+
return result >= 0;
|
|
122
|
+
case '<':
|
|
123
|
+
return result < 0;
|
|
124
|
+
case '<=':
|
|
125
|
+
return result <= 0;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Dates compare by instant, everything else by value. No coercion across types.
|
|
131
|
+
*
|
|
132
|
+
* NULL is greater than every value and equal to itself — Postgres' own sort rule, which is what
|
|
133
|
+
* lets `Builder.toSQL()` write it down as `asc nulls last` / `desc nulls first` and mean this
|
|
134
|
+
* function. Sorting only: a comparison *filter* against NULL matches nothing (`ordered`). Before
|
|
135
|
+
* this, `null` sorted as the string `"null"`, so it landed between `"m"` and `"o"` in memory and
|
|
136
|
+
* at the end in the database — the same page read two ways.
|
|
137
|
+
*/
|
|
87
138
|
export function compareValues(a: unknown, b: unknown): number {
|
|
139
|
+
if (isNull(a) || isNull(b)) return isNull(a) ? (isNull(b) ? 0 : 1) : -1;
|
|
88
140
|
const left = normalize(a);
|
|
89
141
|
const right = normalize(b);
|
|
90
|
-
if (
|
|
142
|
+
if (isNumeric(left) && isNumeric(right)) return compareNumeric(left, right);
|
|
91
143
|
const l = String(left);
|
|
92
144
|
const r = String(right);
|
|
93
145
|
return l < r ? -1 : l > r ? 1 : 0;
|
|
94
146
|
}
|
|
95
147
|
|
|
148
|
+
function isNumeric(value: unknown): value is number | bigint {
|
|
149
|
+
return typeof value === 'number' || typeof value === 'bigint';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Numbers and bigints, in one order, because **Postgres orders them in one order**.
|
|
154
|
+
*
|
|
155
|
+
* `bigint` is a first-class `ColumnKind` — the physical type of every `<p>_minor` column — and
|
|
156
|
+
* `@ultimat3/entity`'s `count-by.ts` lists it as groupable, so these values do reach the
|
|
157
|
+
* comparator. The old numeric fast path was `typeof left === 'number' && typeof right ===
|
|
158
|
+
* 'number'` alone, so a bigint fell through to `String(left) < String(right)`:
|
|
159
|
+
* `compareValues(9n, 10n)` answered `1` and a sort came out `["10", "100", "9"]`, which means the
|
|
160
|
+
* in-memory source, the live matcher and the seek fallback all disagreed with the database on any
|
|
161
|
+
* bigint-ordered read — including page two of one.
|
|
162
|
+
*
|
|
163
|
+
* A bigint pair never subtracts: the difference is exact but the return type is a `number`. A
|
|
164
|
+
* mixed pair goes through `BigInt` when the number is whole, so a value past 2^53 keeps its exact
|
|
165
|
+
* place; a fractional number cannot equal a bigint, so comparing it as a float is enough to place
|
|
166
|
+
* it. `Number.isInteger` is false for `NaN` and `±Infinity`, which is what keeps them out of the
|
|
167
|
+
* `BigInt()` call that would throw on them.
|
|
168
|
+
*/
|
|
169
|
+
function compareNumeric(left: number | bigint, right: number | bigint): number {
|
|
170
|
+
if (typeof left === 'number' && typeof right === 'number') return left - right;
|
|
171
|
+
if (typeof left === 'bigint' && typeof right === 'bigint') return sign(left, right);
|
|
172
|
+
// Widened rather than negated: `-sign(a, b)` answers `-0` for a tie, and `-0` is a different
|
|
173
|
+
// value from `0` to `Object.is` and to a caller writing `=== 0`.
|
|
174
|
+
return typeof left === 'bigint'
|
|
175
|
+
? mixed(left, right as number)
|
|
176
|
+
: -mixed(right as bigint, left) || 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A bigint against a number, in that order. Whole numbers go through `BigInt` so a value past
|
|
180
|
+
* 2^53 keeps its exact place; a fractional number can never equal a bigint, so comparing it as a
|
|
181
|
+
* float is enough to place it. `Number.isInteger` is false for `NaN` and `±Infinity`, which is
|
|
182
|
+
* what keeps them out of the `BigInt()` call that would throw on them. */
|
|
183
|
+
function mixed(big: bigint, other: number): number {
|
|
184
|
+
return Number.isInteger(other) ? sign(big, BigInt(other)) : sign(Number(big), other);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sign<T extends number | bigint>(left: T, right: T): number {
|
|
188
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A `Date` compares by instant, so two of them order by time and never by their ISO text. */
|
|
96
192
|
function normalize(value: unknown): unknown {
|
|
97
193
|
return value instanceof Date ? value.getTime() : value;
|
|
98
194
|
}
|
|
99
195
|
|
|
100
196
|
function same(a: unknown, b: unknown): boolean {
|
|
197
|
+
if (isNull(a) || isNull(b)) return isNull(a) && isNull(b);
|
|
101
198
|
return compareValues(a, b) === 0 && typeof normalize(a) === typeof normalize(b);
|
|
102
199
|
}
|
|
103
200
|
|