@ultimat3/query 1.1.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/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 and
3
- * its MCP tool name. Pure string math, so the browser client derives the same URL
4
- * without importing a byte of server code. Ported rather than imported from
5
- * @ultimat3/action: that package is the same tier, and tiers never go sideways.
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
- const after: SeekKey | null = decoded === null ? null : { key: decoded.key, id: decoded.id };
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: seek === null ? null : encodeCursor({ scope: hash, key: seek.key, id: seek.id }),
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
  }
@@ -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
- /** Skips the cache tiers for this call. Live fanout always reads fresh. */
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
- * `false` for developer tooling (`explain`, admin-gated) and for the shared, subject-less
69
- * window a sync node builds once per `(query, input)` see `ToLiveOptions.enforce`. Both are
70
- * reads with no subscriber to decide about; every other caller leaves it alone.
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 enforce?: boolean;
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
- 'input' | 'policy' | 'cache' | 'mcp' | 'as' | 'page' | 'live' | 'tool' | 'client'
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 { cacheKeyFor, readThrough } from './cache';
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 TRow[]> {
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) return run(options.ctx ?? useContext());
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
- async function readRows<TInput extends StandardSchemaV1, TRow extends object>(
94
- target: Query<TInput, TRow>,
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 TRow[]> {
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[]> => withSpan(`query.${name}`, () => source.execute());
103
- // The source came from this query's own `sql()`, so its rows are TRow.
104
- if (options.fresh === true || def.cache === undefined) {
105
- return (await read()) as readonly TRow[];
106
- }
107
- const key = cacheKeyFor(name, raw, def.cache.tags);
108
- const rows = await readThrough(ctx, key, def.cache.ttlMs ?? null, read);
109
- return rows as readonly TRow[];
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
- if (options.enforce !== false) {
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
- return def.sql(input, ctx);
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 compareValues(actual, filter.value) <= 0;
103
+ return ordered(filter.op, actual, filter.value);
81
104
  default:
82
105
  return false;
83
106
  }
84
107
  }
85
108
 
86
- /** Dates compare by instant, everything else by value. No coercion across types. */
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 (typeof left === 'number' && typeof right === 'number') return left - right;
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