@ultimat3/admin 23.0.0 → 24.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.
@@ -14,18 +14,30 @@ const comparable = (value: unknown): string | number => {
14
14
  if (value instanceof Date) return value.getTime();
15
15
  if (typeof value === 'number') return value;
16
16
  if (typeof value === 'boolean') return value ? 1 : 0;
17
- return value === null || value === undefined ? '' : String(value);
17
+ return String(value);
18
18
  };
19
19
 
20
- /** Code-unit order, never a collation: the same set reads the same way on every machine. */
20
+ const absent = (value: unknown): boolean => value === null || value === undefined;
21
+
22
+ /**
23
+ * Code-unit order, never a collation: the same set reads the same way on every machine. NULL is
24
+ * the LARGEST value — last ascending, first descending — as the database orders a nullable sort
25
+ * key and as `repo-entity.ts` seeks one; it read as `''` here, which sorted it as a zero.
26
+ */
21
27
  const order = (a: unknown, b: unknown): number => {
28
+ if (absent(a) || absent(b)) return absent(a) && absent(b) ? 0 : absent(a) ? 1 : -1;
22
29
  const x = comparable(a);
23
30
  const y = comparable(b);
24
31
  return x < y ? -1 : x > y ? 1 : 0;
25
32
  };
26
33
 
27
- /** A cursor carries its value as text (`pagination.ts`); read back as the column's own type. */
34
+ /**
35
+ * A cursor carries its value as text (`pagination.ts`), or `null` for a row that held none; read
36
+ * back as the type of the row it is compared with. A `null` bound stays `null` — `new Date(null)`
37
+ * is the epoch and `new Date('')` is NaN, and either made the page after a NULL row the wrong one.
38
+ */
28
39
  const boundValue = (row: AdminRow, bound: KeysetBound, field: string): unknown => {
40
+ if (bound.value === null) return null;
29
41
  const held = row[field];
30
42
  if (held instanceof Date) return new Date(bound.value);
31
43
  if (typeof held === 'number') return Number(bound.value);
@@ -1,9 +1,15 @@
1
- // The job rows as an `AdminRepo`: a page is ONE `JobIntrospection.list` call, newest first and
2
- // keyset by `(createdAt, id)` — the store's own order, and the only one it answers — and a detail
3
- // is the store's own trace, its input already redacted. Read-only: every change is an action.
1
+ // The job rows as an `AdminRepo`: a page is the store's own `JobIntrospection.list`, newest first
2
+ // and keyset by `(createdAt, id)` — the only order it answers — and a detail is the store's own
3
+ // trace, its input already redacted. Read-only: every change is an action.
4
4
 
5
5
  import { assert } from '@ultimat3/core';
6
- import { inspectJob, type JobRecord, jobCursor, MAX_JOB_PAGE } from '@ultimat3/jobs';
6
+ import {
7
+ inspectJob,
8
+ type JobFilter,
9
+ type JobRecord,
10
+ jobCursor,
11
+ MAX_JOB_PAGE,
12
+ } from '@ultimat3/jobs';
7
13
  import { AdminFilterInvalidError } from '../errors';
8
14
  import type { AdminListQuery, AdminRepo, AdminRow, KeysetBound } from '../registry';
9
15
  import { JOBS_RESOURCE, jobWhere } from './job-where';
@@ -37,7 +43,12 @@ export function jobRow(record: JobRecord): AdminRow {
37
43
 
38
44
  /** A keyset bound as the store's cursor: the bound row's instant in ms, and its id. */
39
45
  const cursorOf = (bound: KeysetBound): string =>
40
- jobCursor({ createdAt: Date.parse(bound.value), id: bound.id });
46
+ // `createdAt` is never NULL on a job row, so a bound without a value names no position: `NaN`
47
+ // here, which the store refuses as an unreadable cursor rather than seeking from the epoch.
48
+ jobCursor({
49
+ createdAt: bound.value === null ? Number.NaN : Date.parse(bound.value),
50
+ id: bound.id,
51
+ });
41
52
 
42
53
  function assertOrder(query: AdminListQuery): void {
43
54
  const { field, direction } = query.sort;
@@ -50,23 +61,46 @@ function assertOrder(query: AdminListQuery): void {
50
61
  });
51
62
  }
52
63
 
64
+ /**
65
+ * The rows one admin query asks for. The admin asks ONE row past its page to learn whether a next
66
+ * one exists, and the store answers `MAX_JOB_PAGE` at most — so a 200-row page (the admin's own
67
+ * ceiling, and what the MCP list tool accepts) was answered 200 rows, read as "no more", and every
68
+ * row after it was on no page. Past the store's ceiling, the overflow is a second read from where
69
+ * the first one stopped: after its last row walking forward, before its first walking back.
70
+ */
71
+ async function records(filter: JobFilter, query: AdminListQuery): Promise<readonly JobRecord[]> {
72
+ const { introspect } = jobsOperator();
73
+ const backward = query.before !== undefined;
74
+ const bound =
75
+ query.after !== undefined
76
+ ? { after: cursorOf(query.after) }
77
+ : query.before !== undefined
78
+ ? { before: cursorOf(query.before) }
79
+ : {};
80
+ const first = await introspect.list({
81
+ ...filter,
82
+ limit: Math.min(query.limit, MAX_JOB_PAGE),
83
+ ...bound,
84
+ });
85
+ const over = query.limit - MAX_JOB_PAGE;
86
+ if (over <= 0 || first.length < MAX_JOB_PAGE) return first;
87
+ const edge = backward ? first[0] : first[first.length - 1];
88
+ if (edge === undefined) return first;
89
+ const more = await introspect.list({
90
+ ...filter,
91
+ limit: Math.min(over, MAX_JOB_PAGE),
92
+ ...(backward ? { before: jobCursor(edge) } : { after: jobCursor(edge) }),
93
+ });
94
+ return backward ? [...more, ...first] : [...first, ...more];
95
+ }
96
+
53
97
  async function list(query: AdminListQuery): Promise<readonly AdminRow[]> {
54
98
  assertOrder(query);
55
99
  const where = jobWhere(query.where ?? []);
56
100
  if (where.empty) return [];
57
- const { introspect } = jobsOperator();
58
- const records = await introspect.list({
59
- ...where.filter,
60
- // The admin asks one row past its page to learn whether there is a next one; the store's page
61
- // is bounded, and a full store page simply reads as "maybe more".
62
- limit: Math.min(query.limit, MAX_JOB_PAGE),
63
- ...(query.after === undefined ? {} : { after: cursorOf(query.after) }),
64
- ...(query.before === undefined ? {} : { before: cursorOf(query.before) }),
65
- });
101
+ const found = await records(where.filter, query);
66
102
  const exact = where.exactId;
67
- return (exact === undefined ? records : records.filter((record) => record.id === exact)).map(
68
- jobRow,
69
- );
103
+ return (exact === undefined ? found : found.filter((record) => record.id === exact)).map(jobRow);
70
104
  }
71
105
 
72
106
  /** The detail: the row, plus what `inspectJob` says of it — redacted input, steps, the stack. */
@@ -2,11 +2,11 @@
2
2
  // boot installs it after `defineAdmin()` has run, and `x dev` swaps it — and its operator surface.
3
3
  // A process with no driver, or one that ships no operator surface, is refused with the fix.
4
4
 
5
+ import { NotImplementedError } from '@ultimat3/core';
5
6
  import {
6
7
  DriverUnavailableError,
7
8
  type JobDriver,
8
9
  type JobIntrospection,
9
- JobsNotImplementedError,
10
10
  jobDriver,
11
11
  } from '@ultimat3/jobs';
12
12
 
@@ -25,8 +25,8 @@ export function jobsOperator(): JobsOperator {
25
25
  });
26
26
  }
27
27
  if (driver.introspect === undefined) {
28
- throw new JobsNotImplementedError({
29
- feature: `the operator surface of the "${driver.name}" jobs driver`,
28
+ throw new NotImplementedError({
29
+ cause: `the operator surface of the "${driver.name}" jobs driver is not implemented: the driver has no introspect`,
30
30
  fix: 'call setJobDriver(createPgDriver()) at boot — the pg and memory drivers implement introspect',
31
31
  });
32
32
  }
@@ -0,0 +1,189 @@
1
+ // The keyset seek on the PAIR `(sort, id)`, for a typed handle whose chain is a conjunction: the
2
+ // row-value comparison `(sort, id) > (v, id₀)` is read as disjoint segments in walk order — the
3
+ // rest of the boundary's class by id, then everything past the class, then the NULLs — so a sort
4
+ // value shared by any number of rows, and a NULL one, is a position like any other.
5
+
6
+ import type { AdminListQuery, AdminRow, AdminTableRead, KeysetBound } from './registry';
7
+
8
+ type TableOperator = Parameters<AdminTableRead['andWhere']>[1];
9
+ type Direction = 'asc' | 'desc';
10
+
11
+ /** One `andWhere` term. `value` is absent for the two null tests. */
12
+ export interface SeekTerm {
13
+ readonly field: string;
14
+ readonly op: TableOperator;
15
+ readonly value?: unknown;
16
+ }
17
+
18
+ export interface SeekColumn {
19
+ readonly field: string;
20
+ readonly idField: string;
21
+ readonly nullable: boolean;
22
+ /**
23
+ * A `timestamptz`. The row hands it back as a `Date` — milliseconds — while Postgres keeps and
24
+ * orders microseconds, so a cursor can only name the boundary's MILLISECOND. Its class is that
25
+ * millisecond `[v, v + 1ms)`, ordered by id inside: the order both drivers can agree on.
26
+ */
27
+ readonly instant: boolean;
28
+ /** The bound's text back to what the handle compares: a `Date` for an instant. */
29
+ readonly typed: (value: string) => unknown;
30
+ }
31
+
32
+ const flip = (direction: Direction): Direction => (direction === 'asc' ? 'desc' : 'asc');
33
+
34
+ const millisOf = (row: AdminRow, field: string): number | null => {
35
+ const value = row[field];
36
+ return value instanceof Date ? value.getTime() : null;
37
+ };
38
+
39
+ /** Where one walk segment reads. */
40
+ interface Segments {
41
+ readonly read: (
42
+ where: readonly SeekTerm[],
43
+ order: 'id' | 'pair',
44
+ limit: number,
45
+ ) => Promise<AdminRow[]>;
46
+ readonly column: SeekColumn;
47
+ readonly walk: Direction;
48
+ }
49
+
50
+ /** The terms that hold the boundary's class: equal, the same millisecond, or NULL. */
51
+ const classOf = (column: SeekColumn, value: unknown): readonly SeekTerm[] => {
52
+ if (value === null) return [{ field: column.field, op: 'is-null' }];
53
+ if (column.instant && value instanceof Date) {
54
+ return [
55
+ { field: column.field, op: 'gte', value },
56
+ { field: column.field, op: 'lt', value: new Date(value.getTime() + 1) },
57
+ ];
58
+ }
59
+ return [{ field: column.field, op: 'eq', value }];
60
+ };
61
+
62
+ /** Strictly past the class of a present `value`, in the walk's direction. NULLs never match. */
63
+ const pastClass = (column: SeekColumn, value: unknown, walk: Direction): readonly SeekTerm[] => {
64
+ if (walk === 'desc') return [{ field: column.field, op: 'lt', value }];
65
+ if (column.instant && value instanceof Date) {
66
+ return [{ field: column.field, op: 'gte', value: new Date(value.getTime() + 1) }];
67
+ }
68
+ return [{ field: column.field, op: 'gt', value }];
69
+ };
70
+
71
+ /** One class, by id, after `afterId` when given — exact whatever the class's internal order. */
72
+ const classRows = (
73
+ at: Segments,
74
+ where: readonly SeekTerm[],
75
+ afterId: string | undefined,
76
+ limit: number,
77
+ ): Promise<AdminRow[]> =>
78
+ limit <= 0
79
+ ? Promise.resolve([])
80
+ : at.read(
81
+ afterId === undefined
82
+ ? where
83
+ : [
84
+ ...where,
85
+ { field: at.column.idField, op: at.walk === 'asc' ? 'gt' : 'lt', value: afterId },
86
+ ],
87
+ 'id',
88
+ limit,
89
+ );
90
+
91
+ /**
92
+ * Present values past `where`, `limit` of them in `(sort, id)` order. For an instant the LAST
93
+ * millisecond read may be cut part-way through and its rows are in microsecond order, so it is
94
+ * dropped and re-read whole by id — the order the next page's seek resumes in. Every earlier
95
+ * millisecond on the page is complete, and is put in that same order.
96
+ */
97
+ async function valueRows(
98
+ at: Segments,
99
+ where: readonly SeekTerm[],
100
+ limit: number,
101
+ ): Promise<AdminRow[]> {
102
+ if (limit <= 0) return [];
103
+ const { column } = at;
104
+ const present: readonly SeekTerm[] =
105
+ where.length === 0 && column.nullable ? [{ field: column.field, op: 'is-not-null' }] : where;
106
+ if (!column.instant) return at.read(present, 'pair', limit);
107
+ const rows = await at.read(present, 'pair', limit + 1);
108
+ if (rows.length <= limit) return byMillisecondThenId(rows, column, at.walk);
109
+ const tail = millisOf(rows[limit - 1] ?? {}, column.field);
110
+ const extra = millisOf(rows[limit] ?? {}, column.field);
111
+ const kept = rows.slice(0, limit);
112
+ if (tail === null || tail !== extra) return byMillisecondThenId(kept, column, at.walk);
113
+ const whole = kept.filter((row) => millisOf(row, column.field) !== tail);
114
+ const rest = await classRows(
115
+ at,
116
+ classOf(column, new Date(tail)),
117
+ undefined,
118
+ limit - whole.length,
119
+ );
120
+ return [...byMillisecondThenId(whole, column, at.walk), ...rest];
121
+ }
122
+
123
+ /**
124
+ * Rows of one millisecond in id order. Code-unit order: it agrees with the database for a `uuid`
125
+ * key, which is the framework's; a text key under a non-C collation can disagree inside one
126
+ * millisecond, and only there.
127
+ */
128
+ const byMillisecondThenId = (
129
+ rows: readonly AdminRow[],
130
+ column: SeekColumn,
131
+ walk: Direction,
132
+ ): AdminRow[] => {
133
+ const sign = walk === 'asc' ? 1 : -1;
134
+ return [...rows].sort((a, b) => {
135
+ const at = (millisOf(a, column.field) ?? 0) - (millisOf(b, column.field) ?? 0);
136
+ if (at !== 0) return sign * at;
137
+ const left = String(a[column.idField]);
138
+ const right = String(b[column.idField]);
139
+ return sign * (left < right ? -1 : left > right ? 1 : 0);
140
+ });
141
+ };
142
+
143
+ /**
144
+ * The page a query asks for, read in WALK order — forward from an `after` bound (or from the top),
145
+ * backward from a `before` one — and answered in the query's own sort order either way.
146
+ *
147
+ * NULL is the largest value, as both drivers order it: last ascending, first descending.
148
+ */
149
+ export async function seekPage(
150
+ query: AdminListQuery,
151
+ column: SeekColumn,
152
+ read: (
153
+ where: readonly SeekTerm[],
154
+ order: { readonly by: 'id' | 'pair'; readonly direction: Direction },
155
+ limit: number,
156
+ ) => Promise<AdminRow[]>,
157
+ ): Promise<readonly AdminRow[]> {
158
+ const backward = query.before !== undefined;
159
+ const walk = backward ? flip(query.sort.direction) : query.sort.direction;
160
+ const at: Segments = {
161
+ column,
162
+ walk,
163
+ read: (where, by, limit) => read(where, { by, direction: walk }, limit),
164
+ };
165
+ const out: AdminRow[] = [];
166
+ const room = (): number => query.limit - out.length;
167
+ const bound: KeysetBound | undefined = query.after ?? query.before;
168
+ const nulls = (afterId?: string): Promise<AdminRow[]> =>
169
+ column.nullable ? classRows(at, classOf(column, null), afterId, room()) : Promise.resolve([]);
170
+
171
+ if (bound === undefined) {
172
+ if (walk === 'desc') out.push(...(await nulls()));
173
+ out.push(...(await valueRows(at, [], room())));
174
+ if (walk === 'asc') out.push(...(await nulls()));
175
+ } else if (bound.value === null) {
176
+ out.push(...(await nulls(bound.id)));
177
+ if (walk === 'desc') out.push(...(await valueRows(at, [], room())));
178
+ } else {
179
+ const value = column.typed(bound.value);
180
+ // The id IS the sort: its class is one row, so only the strict half is read.
181
+ if (column.field !== column.idField) {
182
+ out.push(...(await classRows(at, classOf(column, value), bound.id, room())));
183
+ }
184
+ out.push(...(await valueRows(at, pastClass(column, value, walk), room())));
185
+ if (walk === 'asc') out.push(...(await nulls()));
186
+ }
187
+ const page = out.slice(0, query.limit);
188
+ return backward ? page.reverse() : page;
189
+ }
@@ -61,12 +61,32 @@ const LOCAL_MINUTE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
61
61
  /** An instant names its offset. A bare local time has no zone, and the admin never invents one. */
62
62
  const ZONED = /(?:Z|[+-]\d{2}:\d{2})$/;
63
63
 
64
+ /**
65
+ * A number as an operator types one: decimal digits, a sign, a point. `Number()` alone read a
66
+ * blank as `0`, `0x10` as 16 and `1e3` as 1000 — a filter nobody asked for, answered as a list.
67
+ */
68
+ const DECIMAL = /^-?\d+(?:\.\d+)?$/;
69
+
70
+ /**
71
+ * The `YYYY-MM-DD` a value opens with names a day the calendar HAS. `Date` rolls `2026-02-30`
72
+ * over to March 2nd without a word, so the shape test passed and the driver was handed a day
73
+ * nobody typed.
74
+ */
75
+ const onCalendar = (raw: string): boolean => {
76
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(raw);
77
+ if (match === null) return false;
78
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
79
+ if (month < 1 || month > 12 || day < 1) return false;
80
+ return day <= new Date(Date.UTC(year, month, 0)).getUTCDate();
81
+ };
82
+
64
83
  /**
65
84
  * A typed instant → the ISO string a driver compares. `datetime-local` posts `YYYY-MM-DDTHH:mm`
66
85
  * and the control says UTC beside the box — the same contract the edit form states — and a bare
67
86
  * date is that day's first instant in UTC.
68
87
  */
69
88
  const instantOf = (raw: string): string | undefined => {
89
+ if (!onCalendar(raw)) return undefined;
70
90
  const text = DATE.test(raw)
71
91
  ? `${raw}T00:00:00.000Z`
72
92
  : LOCAL_MINUTE.test(raw)
@@ -92,14 +112,19 @@ function scalarOf(field: AdminField, raw: unknown): Scalar | Refusal {
92
112
  }
93
113
  case 'range': {
94
114
  if (field.type === 'number') {
95
- const parsed = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN;
96
- return raw !== '' && Number.isFinite(parsed)
97
- ? parsed
98
- : { cause: `takes a number, got ${shown(raw)}` };
115
+ const parsed =
116
+ typeof raw === 'number'
117
+ ? raw
118
+ : typeof raw === 'string' && DECIMAL.test(raw)
119
+ ? Number(raw)
120
+ : NaN;
121
+ return Number.isFinite(parsed) ? parsed : { cause: `takes a number, got ${shown(raw)}` };
99
122
  }
100
123
  if (typeof raw !== 'string') return { cause: `takes a date as text, got ${shown(raw)}` };
101
124
  if (field.type === 'date') {
102
- return DATE.test(raw) ? raw : { cause: `takes a YYYY-MM-DD date, got ${shown(raw)}` };
125
+ return DATE.test(raw) && onCalendar(raw)
126
+ ? raw
127
+ : { cause: `takes a YYYY-MM-DD date, got ${shown(raw)}` };
103
128
  }
104
129
  return (
105
130
  instantOf(raw) ?? {
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // ?cursor=<signed> keyset position (pagination.ts)
8
8
  // ?sort=<field>:<asc|desc> a sortable field; the direction defaults to desc
9
- // ?scope=<name> a declared scope; absent is the resource's default
9
+ // ?scope=<name> a declared scope; absent is the resource's default, `*` is none
10
10
  // ?f.<field>=<value> a filter, by the field's default operator
11
11
  // ?f.<field>.<op>=<value> a filter, by a named operator; repeat the parameter for `in`
12
12
  //
@@ -18,6 +18,7 @@ import type { AdminListRequest } from './list-scope';
18
18
  import { scopeNamed } from './list-scope';
19
19
  import type { AdminFilter, AdminSort } from './registry';
20
20
  import type { AdminResource } from './resource';
21
+ import { NO_SCOPE } from './resource-list';
21
22
 
22
23
  export const CURSOR_PARAM = 'cursor';
23
24
  export const SORT_PARAM = 'sort';
@@ -61,7 +62,7 @@ export function pageRequestOf(resource: AdminResource, url: URL): AdminListReque
61
62
  const filters: AdminFilter[] = [];
62
63
  let cursor: string | undefined;
63
64
  let sort: AdminSort | undefined;
64
- let scope: string | undefined;
65
+ let scope: string | null | undefined;
65
66
 
66
67
  for (const key of new Set(url.searchParams.keys())) {
67
68
  const values = url.searchParams.getAll(key).filter((value) => value !== '');
@@ -70,7 +71,8 @@ export function pageRequestOf(resource: AdminResource, url: URL): AdminListReque
70
71
  if (key === CURSOR_PARAM) cursor = last;
71
72
  else if (key === SORT_PARAM) sort = sortOf(resource, last);
72
73
  // Resolved here for its refusal alone: the name is what travels, the actor decides its rows.
73
- else if (key === SCOPE_PARAM) scope = scopeNamed(resource, last)?.name;
74
+ else if (key === SCOPE_PARAM)
75
+ scope = last === NO_SCOPE ? null : scopeNamed(resource, last)?.name;
74
76
  else if (key.startsWith(FILTER_PREFIX)) {
75
77
  filters.push(checkedFilter(resource, { ...filterKey(key), value: values }));
76
78
  } else {
@@ -108,9 +110,9 @@ export function listHref(
108
110
  location: ListLocation = {},
109
111
  ): string {
110
112
  const query = new URLSearchParams();
111
- if (location.scope !== undefined && location.scope !== null) {
112
- query.set(SCOPE_PARAM, location.scope);
113
- }
113
+ // `null` is "no scope, not even the default" — the request's own meaning of it — so it is
114
+ // written, as `*`; only an absent scope is left to the resource's default.
115
+ if (location.scope !== undefined) query.set(SCOPE_PARAM, location.scope ?? NO_SCOPE);
114
116
  for (const filter of location.filters ?? []) {
115
117
  const field = resource.filters.find((known) => known.name === filter.field);
116
118
  if (field === undefined) continue;
package/src/mcp.ts CHANGED
@@ -234,6 +234,10 @@ function crudResult(result: CrudResult<AdminRow>): AdminToolResult {
234
234
  if (result.kind === 'missing') {
235
235
  return { ok: false, error: 'X_ADMIN_INVALID', reason: 'no row has that id' };
236
236
  }
237
+ // Reached only by a caller that sent a version; the update tool sends none.
238
+ if (result.kind === 'stale') {
239
+ return { ok: false, error: 'X_ADMIN_INVALID', reason: 'the row changed since it was read' };
240
+ }
237
241
  return invalidIssues(result.issues);
238
242
  }
239
243
 
package/src/pagination.ts CHANGED
@@ -10,7 +10,8 @@ import { type AdminResource, repoOf } from './resource';
10
10
 
11
11
  export interface AdminCursor {
12
12
  readonly field: string;
13
- readonly value: string;
13
+ /** The boundary row's sort value as text, or `null` when it holds none — a position too. */
14
+ readonly value: string | null;
14
15
  readonly id: string;
15
16
  readonly direction: 'after' | 'before';
16
17
  }
@@ -50,7 +51,7 @@ export function decodeAdminCursor(
50
51
  if (payload.key.length !== 3) return null;
51
52
  const [direction, field, value] = payload.key;
52
53
  if (direction !== 'after' && direction !== 'before') return null;
53
- if (typeof field !== 'string' || typeof value !== 'string') return null;
54
+ if (typeof field !== 'string' || (typeof value !== 'string' && value !== null)) return null;
54
55
  return { direction, field, value, id: payload.id };
55
56
  } catch (error) {
56
57
  if (isUltimateError(error) && error.code === 'X_CURSOR_INVALID') return null;
@@ -98,10 +99,15 @@ export function listQuery<Row extends AdminRow>(
98
99
  };
99
100
  }
100
101
 
101
- const cursorValue = (row: AdminRow, field: string): string => {
102
+ /**
103
+ * The boundary's sort value as the cursor carries it. A NULL stays `null` — JSON holds one — and
104
+ * is never `''`: an empty string is a text value of its own, and the seek past a NULL boundary
105
+ * reads the NULL rows, which a seek past `''` never reaches.
106
+ */
107
+ const cursorValue = (row: AdminRow, field: string): string | null => {
102
108
  const value = row[field];
103
109
  if (value instanceof Date) return value.toISOString();
104
- return value === null || value === undefined ? '' : String(value);
110
+ return value === null || value === undefined ? null : String(value);
105
111
  };
106
112
 
107
113
  /**
@@ -23,9 +23,6 @@ import {
23
23
  } from './authz';
24
24
  import { ADMIN_PERMISSIONS } from './permissions';
25
25
 
26
- /** The admin's own permission set, registered with the policy layer at import time. */
27
- export const adminPermissions = definePermissions(ADMIN_PERMISSIONS);
28
-
29
26
  /**
30
27
  * The admin carries its own actor shape; @ultimat3/policy evaluates core's `Actor`. The
31
28
  * mapping lives here because this file is the only one allowed to speak to the policy layer.
@@ -62,13 +59,18 @@ function readDecision(permission: string, result: unknown): AdminDecision {
62
59
  const isPermission = (value: string): value is Permission => /^[^:]+:[^:]+$/.test(value);
63
60
 
64
61
  /**
65
- * Declare the permissions an admin DERIVES — `<entity>:read|write|delete`, a page's, an action's —
66
- * so `can()` knows them. Granting them stays the app's: a role map that names none of these
67
- * refuses every screen. Idempotent, and this file's because it is the one that speaks to
68
- * `@ultimat3/policy`.
62
+ * Declare the admin's OWN permissions (`ADMIN_PERMISSIONS`) and the ones a mount DERIVES —
63
+ * `<entity>:read|write|delete`, a page's, an action's — so `can()` knows them. Granting them stays
64
+ * the app's: a role map that names none of these refuses every screen. Idempotent, and this file's
65
+ * because it is the one that speaks to `@ultimat3/policy`.
66
+ *
67
+ * Called by `defineAdmin()`, never at module scope. The permission registry is permissive while
68
+ * EMPTY and closed once it holds one name, so registering `admin:*` on import closed the set for
69
+ * every module that merely shared a process with this package — an app that imported the admin
70
+ * for a type, and every test file that ran after an admin one.
69
71
  */
70
72
  export function declareAdminPermissions(permissions: readonly string[]): void {
71
- definePermissions(permissions.filter(isPermission));
73
+ definePermissions([...ADMIN_PERMISSIONS, ...permissions.filter(isPermission)]);
72
74
  }
73
75
 
74
76
  const evaluated = (
package/src/registry.ts CHANGED
@@ -166,7 +166,8 @@ export interface AdminSort {
166
166
 
167
167
  export interface KeysetBound {
168
168
  readonly field: string;
169
- readonly value: string;
169
+ /** The boundary row's sort value as text; `null` when it holds none, which sorts LAST. */
170
+ readonly value: string | null;
170
171
  readonly id: string;
171
172
  }
172
173
 
package/src/relations.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // page, never one per row and never one per cell.
5
5
 
6
6
  import { finiteCount } from '@ultimat3/core';
7
+ import { expectedQueryLoop } from '@ultimat3/db';
7
8
  import type { AuditEntry } from './audit';
8
9
  import type { AdminDecision } from './authz';
9
10
  import { type CrudCtx, decideOperation } from './crud';
@@ -26,7 +27,11 @@ export interface AdminOption {
26
27
  */
27
28
  export const LOOKUP_SELECT_MAX = 50;
28
29
 
29
- /** The most ids one label read names — the list's own page-size ceiling. */
30
+ /**
31
+ * The most ids one label read names — the list's own page-size ceiling. A page naming more is read
32
+ * in chunks of this many, never cut at it: a 200-row list with two columns on one target names up
33
+ * to 400 ids, and the ids past the cut were drawn as raw ids with no error anywhere.
34
+ */
30
35
  const MAX_LABEL_IDS = 200;
31
36
 
32
37
  /** The row's label-field value when it is text, else its id — never a blank cell. */
@@ -140,7 +145,7 @@ async function relationOf(
140
145
  const visible = rowWhere(resource, ctx.actor);
141
146
  const labels = new Map<string, string>();
142
147
  let options: readonly AdminOption[] | null = null;
143
- let wanted = [...need.ids].slice(0, MAX_LABEL_IDS);
148
+ let wanted = [...need.ids];
144
149
 
145
150
  if (need.pick) {
146
151
  // One over the ceiling: the extra row is how "small enough for a select" is learned without
@@ -162,14 +167,25 @@ async function relationOf(
162
167
  }
163
168
  }
164
169
 
165
- if (wanted.length > 0) {
166
- const rows = await repo.list({
167
- where: [...visible, { field: resource.idField, op: 'in', value: wanted }],
168
- sort: resource.defaultSort,
169
- limit: wanted.length,
170
- });
171
- for (const row of rows) labels.set(rowId(row, resource.idField), labelOf(row, resource));
170
+ const chunks: string[][] = [];
171
+ for (let at = 0; at < wanted.length; at += MAX_LABEL_IDS) {
172
+ chunks.push(wanted.slice(at, at + MAX_LABEL_IDS));
172
173
  }
174
+ // One statement per chunk, and only past the ceiling: a page's ids are known up front, so this
175
+ // is a bounded batch, not a read per row — and declared as one.
176
+ await expectedQueryLoop(
177
+ 'admin labels read a page’s referenced ids in chunks under the `in` ceiling',
178
+ async () => {
179
+ for (const chunk of chunks) {
180
+ const rows = await repo.list({
181
+ where: [...visible, { field: resource.idField, op: 'in', value: chunk }],
182
+ sort: resource.defaultSort,
183
+ limit: chunk.length,
184
+ });
185
+ for (const row of rows) labels.set(rowId(row, resource.idField), labelOf(row, resource));
186
+ }
187
+ },
188
+ );
173
189
 
174
190
  await readEntry(resource, ctx, decision);
175
191
  return { labels, options };