@ultimat3/query 22.10.0 → 22.12.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 CHANGED
@@ -38,7 +38,8 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
38
38
 
39
39
  ## Invariants — the read path
40
40
 
41
- - Every surface goes through `sourceFor`: parse input, evaluate policy, build the source. A second
41
+ - Every surface goes through `sourceFor`: the policy's actor half (`guardBeforeInput`), parse input,
42
+ evaluate policy, build the source. A second
42
43
  read path is the one unforgivable change here.
43
44
  - **An explicit `ctx` is INSTALLED, never merely passed** — the ambient context (which
44
45
  `@ultimat3/entity`'s tenant guard reads) must be the identity `guard()` decided about.
package/README.md CHANGED
@@ -336,16 +336,19 @@ not exported, because a page is the read's own answer rather than an imported he
336
336
  ### The same page over HTTP — `?_first=` and `?_after=`
337
337
 
338
338
  The route reads two controls off the search string BEFORE the schema sees it, and with either
339
- present answers the `Page` envelope `.page()` answers a server caller with — `{ rows, endCursor,
340
- hasNextPage }`, the same names, so a cursor read off the wire and one read off a direct call are
341
- one string in one field. Without a control the answer is the bare array it has always been, so a
339
+ present answers the `Page` envelope `.page()` answers a server caller with — `{ rows, nextCursor,
340
+ hasMore, endCursor, hasNextPage }`, the same names, so a cursor read off the wire and one read off a
341
+ direct call are one string in one field. **`nextCursor` and `hasMore` are the preferred names**
342
+ (`As of 22.12`); `endCursor` and `hasNextPage` carry the same two values, always, as aliases — not
343
+ deprecated, so a client reading them keeps working. `openapi.json` lists all five as required, the
344
+ aliases described as such. Without a control the answer is the bare array it has always been, so a
342
345
  client written before the controls existed keeps reading rows. `As of 2026-09`; until then the
343
346
  route had no envelope, and a query over a paged external source carried its page marker on a row
344
347
  (an `olderCursor` column on the oldest message), which is a cursor on the wrong side of the shape.
345
348
 
346
349
  ```
347
- GET /_x/query/live-feed?orgId=…&_first=20 → { rows, endCursor, hasNextPage }
348
- GET /_x/query/live-feed?orgId=…&_first=20&_after=<c> → the next page
350
+ GET /_x/query/live-feed?orgId=…&_first=20 → { rows, nextCursor, hasMore, endCursor, hasNextPage }
351
+ GET /_x/query/live-feed?orgId=…&_first=20&_after=<c> → the next page (<c> = nextCursor)
349
352
  GET /_x/query/live-feed?orgId=… → [ …rows ] (unchanged)
350
353
  ```
351
354
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/query",
3
- "version": "22.10.0",
3
+ "version": "22.12.0",
4
4
  "description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,11 +36,11 @@
36
36
  "test": "bun test"
37
37
  },
38
38
  "dependencies": {
39
- "@ultimat3/cache": "22.10.0",
40
- "@ultimat3/core": "22.10.0",
41
- "@ultimat3/entity": "22.10.0",
42
- "@ultimat3/http": "22.10.0",
43
- "@ultimat3/policy": "22.10.0",
44
- "@ultimat3/schema": "22.10.0"
39
+ "@ultimat3/cache": "22.12.0",
40
+ "@ultimat3/core": "22.12.0",
41
+ "@ultimat3/entity": "22.12.0",
42
+ "@ultimat3/http": "22.12.0",
43
+ "@ultimat3/policy": "22.12.0",
44
+ "@ultimat3/schema": "22.12.0"
45
45
  }
46
46
  }
package/src/http.ts CHANGED
@@ -80,7 +80,8 @@ export function toQueryRoute(target: AnyQuery): Route {
80
80
  return answerRow(row);
81
81
  }
82
82
  // With a page control the answer is the `Page` envelope `query.page()` answers a server
83
- // caller with — `{ rows, endCursor, hasNextPage }`, the same names, so a cursor read off the
83
+ // caller with — `{ rows, nextCursor, hasMore, endCursor, hasNextPage }`, the same names (the
84
+ // last two aliases of the first two), so a cursor read off the
84
85
  // wire and one read off a direct call are the same string in the same field. Without one the
85
86
  // answer is the bare array it has always been: every client written before the controls
86
87
  // existed keeps reading rows, and `hasNextPage` never rides on a row where a page marker
package/src/index.ts CHANGED
@@ -129,6 +129,7 @@ export {
129
129
  actorOf,
130
130
  admitsAnonymous,
131
131
  guard,
132
+ guardBeforeInput,
132
133
  policyCapability,
133
134
  policyPermissions,
134
135
  } from './policy-gate';
package/src/openapi.ts CHANGED
@@ -48,11 +48,18 @@ const ANSWERS: readonly Record<string, unknown>[] = [
48
48
  { type: 'array', items: {} },
49
49
  {
50
50
  type: 'object',
51
- required: ['rows', 'endCursor', 'hasNextPage'],
51
+ // `nextCursor`/`hasMore` are the preferred names; the other two are the same values, kept as
52
+ // aliases — documented as such, not deprecated — so every client written against them reads on.
53
+ required: ['rows', 'nextCursor', 'hasMore', 'endCursor', 'hasNextPage'],
52
54
  properties: {
53
55
  rows: { type: 'array', items: {} },
54
- endCursor: { type: ['string', 'null'] },
55
- hasNextPage: { type: 'boolean' },
56
+ nextCursor: {
57
+ type: ['string', 'null'],
58
+ description: `the signed cursor that continues this listing — send it as ${PAGE_AFTER_KEY}; null on an empty page`,
59
+ },
60
+ hasMore: { type: 'boolean', description: 'whether another page follows' },
61
+ endCursor: { type: ['string', 'null'], description: 'alias of nextCursor — the same value' },
62
+ hasNextPage: { type: 'boolean', description: 'alias of hasMore — the same value' },
56
63
  },
57
64
  },
58
65
  ];
@@ -170,7 +177,7 @@ const PAGE_PARAMETERS: readonly Record<string, unknown>[] = [
170
177
  name: PAGE_AFTER_KEY,
171
178
  in: 'query',
172
179
  required: false,
173
- description: `the endCursor a previous page answered; needs ${PAGE_FIRST_KEY}`,
180
+ description: `the nextCursor (alias endCursor) a previous page answered; needs ${PAGE_FIRST_KEY}`,
174
181
  schema: { type: 'string' },
175
182
  },
176
183
  ];
@@ -90,7 +90,7 @@ export function pageControlsOf(name: string, values: SearchValues): SplitSearch
90
90
  if (cursor.length === 0) {
91
91
  throw new QueryInputInvalidError(
92
92
  name,
93
- `${PAGE_AFTER_KEY} is empty — omit it for the first page, or send the endCursor the previous page answered`,
93
+ `${PAGE_AFTER_KEY} is empty — omit it for the first page, or send the nextCursor the previous page answered`,
94
94
  );
95
95
  }
96
96
  return { input, page: { first: Number(size), after: cursor } };
package/src/pagination.ts CHANGED
@@ -20,12 +20,32 @@ import { compareRows, seekKeyOf, totalOrder } from './shape';
20
20
  import type { SqlSource } from './source';
21
21
  import { isAfterKey } from './source';
22
22
 
23
+ /**
24
+ * One page. `nextCursor` / `hasMore` are the preferred names; `endCursor` / `hasNextPage` are the
25
+ * same two values under the names every client before 22.12 reads — aliases, both always present,
26
+ * never able to disagree because `pageOf` is the one place that writes them.
27
+ */
23
28
  export interface Page<TRow> {
24
29
  readonly rows: readonly TRow[];
30
+ /** The signed cursor that continues this listing; `null` on an empty page. */
31
+ readonly nextCursor: string | null;
32
+ /** Whether another page follows. */
33
+ readonly hasMore: boolean;
34
+ /** Alias of `nextCursor`. */
25
35
  readonly endCursor: string | null;
36
+ /** Alias of `hasMore`. */
26
37
  readonly hasNextPage: boolean;
27
38
  }
28
39
 
40
+ /** Builds a `Page` from its two facts — the only constructor, so the aliases cannot drift. */
41
+ export function pageOf<TRow>(
42
+ rows: readonly TRow[],
43
+ nextCursor: string | null,
44
+ hasMore: boolean,
45
+ ): Page<TRow> {
46
+ return { rows, nextCursor, hasMore, endCursor: nextCursor, hasNextPage: hasMore };
47
+ }
48
+
29
49
  export interface PaginateArgs extends SourceOptions {
30
50
  readonly first: number;
31
51
  readonly after?: string;
@@ -70,20 +90,17 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
70
90
  const last = rows[rows.length - 1];
71
91
  const seek = last === undefined ? null : seekKeyOf(last, shape);
72
92
 
73
- return {
74
- rows,
75
- endCursor:
76
- seek === null
77
- ? null
78
- : encodeCursor({
79
- scope: hash,
80
- // The id rides TYPED at the tail of the key — core's `id` slot is a string — so a
81
- // numeric or bigint tiebreak compares as a number on the next page, not lexically.
82
- key: [...seek.key, seek.id].map(serializeSortValue),
83
- id: String(seek.id),
84
- }),
85
- hasNextPage: scoped.length > args.first,
86
- };
93
+ const nextCursor =
94
+ seek === null
95
+ ? null
96
+ : encodeCursor({
97
+ scope: hash,
98
+ // The id rides TYPED at the tail of the key — core's `id` slot is a string — so a
99
+ // numeric or bigint tiebreak compares as a number on the next page, not lexically.
100
+ key: [...seek.key, seek.id].map(serializeSortValue),
101
+ id: String(seek.id),
102
+ });
103
+ return pageOf(rows, nextCursor, scoped.length > args.first);
87
104
  }
88
105
 
89
106
  /**
@@ -9,6 +9,7 @@ import { assertNever, isAnonymous } from '@ultimat3/core';
9
9
  import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
10
10
  import {
11
11
  enforce,
12
+ enforceBeforeInput,
12
13
  policyPermissions as flattenedPermissions,
13
14
  admitsAnonymous as policyAdmitsAnonymous,
14
15
  } from '@ultimat3/policy';
@@ -42,6 +43,23 @@ export function guard(policy: QueryPolicy, subject: QuerySubject, surface: Query
42
43
  if (denial !== undefined) throw new QueryDeniedError(subject.query, denial);
43
44
  }
44
45
 
46
+ /**
47
+ * The actor-only half of `guard`, run BEFORE the input is parsed — the twin of
48
+ * `@ultimat3/action`'s. A reader the policy refuses whatever they send is answered 403 (or 401),
49
+ * never `X_INPUT_INVALID` with the read's input schema in it. Undecided passes to `guard`.
50
+ */
51
+ export function guardBeforeInput(
52
+ policy: QueryPolicy,
53
+ subject: Omit<QuerySubject, 'input' | 'row'>,
54
+ surface: QuerySurface,
55
+ ): void {
56
+ const denial = enforceBeforeInput(policySurface(surface), policy, {
57
+ actor: subject.actor,
58
+ ctx: subject.ctx,
59
+ });
60
+ if (denial !== undefined) throw new QueryDeniedError(subject.query, denial);
61
+ }
62
+
45
63
  /** A direct server read is the job surface: no request, no socket to close. */
46
64
  function policySurface(surface: QuerySurface): PolicySurface {
47
65
  switch (surface) {
package/src/read.ts CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  readThrough,
30
30
  } from './cache';
31
31
  import { QueryForeignError, QueryInputInvalidError, QueryUnregisteredError } from './errors';
32
- import { actorOf, guard } from './policy-gate';
32
+ import { actorOf, guard, guardBeforeInput } from './policy-gate';
33
33
  import type { AnyQuery, AnyQueryDef, Query, QueryOptions, SourceOptions } from './query';
34
34
  import type { SqlSource } from './source';
35
35
 
@@ -204,8 +204,17 @@ async function buildSource(
204
204
  ): Promise<SqlSource<object>> {
205
205
  const def = defOf(target);
206
206
  const name = queryName(target);
207
- const input = await validate(def.input, raw, name);
208
207
  const unenforced = options.unenforced;
208
+ // The actor half of the policy first, exactly as `invoke` does it: a reader refused whatever
209
+ // they send learns nothing about this read's input schema from an `X_INPUT_INVALID`.
210
+ if (unenforced === undefined) {
211
+ guardBeforeInput(
212
+ def.policy,
213
+ { actor: actorOf(ctx), ctx, query: name },
214
+ options.surface ?? 'server',
215
+ );
216
+ }
217
+ const input = await validate(def.input, raw, name);
209
218
  if (unenforced === undefined) {
210
219
  guard(
211
220
  def.policy,