@ultimat3/query 22.11.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/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.11.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.11.0",
40
- "@ultimat3/core": "22.11.0",
41
- "@ultimat3/entity": "22.11.0",
42
- "@ultimat3/http": "22.11.0",
43
- "@ultimat3/policy": "22.11.0",
44
- "@ultimat3/schema": "22.11.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/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
  /**