@ultimat3/entity 0.0.1 → 1.1.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/columns.ts CHANGED
@@ -1,255 +1,202 @@
1
- // The blessed column helpers. There is exactly one way to store an id, a timestamp,
2
- // money, a locale and a time zone — the alternatives (float money, naive timestamps,
3
- // a single implied currency) are the bugs this file exists to make unreachable.
4
- import { uuid } from '@ultimat3/core';
5
- import { invariantViolated } from './errors';
6
- import type { ColumnDef, ColumnMap, IndexDef, TableDef } from './types';
1
+ // The blessed column builders. There is exactly one way to store an id, an instant, money, a
2
+ // locale and a time zone — the alternatives (float money, naive timestamps, a single implied
3
+ // currency) are the bugs this file exists to make unreachable.
7
4
 
8
- const snake = (value: string): string => value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
5
+ import { uuid as uuidV7 } from '@ultimat3/core';
6
+ import { BARE, column, GENERATED_UUID, makeColumn, makeTimestamp } from './column';
7
+ import { invariantViolated } from './errors';
8
+ import type { Column, MoneyInput, MoneyValue, TimestampColumn, UuidColumn } from './types';
9
9
 
10
- const reject = (column: string, rule: string, detail: string): never => {
11
- throw invariantViolated(column, rule, detail);
10
+ const reject = (rule: string, detail: string): never => {
11
+ throw invariantViolated('column', rule, detail);
12
12
  };
13
13
 
14
- interface ColumnOptions {
15
- readonly name?: string;
16
- readonly comment?: string;
17
- }
18
-
19
- const base = <T>(
20
- kind: ColumnDef<T>['kind'],
21
- parse: (value: unknown) => T,
22
- overrides: Partial<ColumnDef<T>> = {},
23
- ): ColumnDef<T> => ({
24
- // '' means "derive from the property key in table()", so a column is declared once.
25
- name: '',
26
- kind,
27
- notNull: true,
28
- primaryKey: false,
29
- unique: false,
30
- index: false,
31
- parse,
32
- ...overrides,
33
- });
34
-
35
- const asString =
36
- (label: string) =>
37
- (value: unknown): string => {
38
- if (typeof value === 'string') return value;
39
- return reject(label, 'type', `expected a string, got ${typeof value}`);
40
- };
41
-
42
- const asDate =
43
- (label: string) =>
44
- (value: unknown): Date => {
45
- if (value instanceof Date) return value;
46
- if (typeof value === 'string' || typeof value === 'number') {
47
- const parsed = new Date(value);
48
- if (!Number.isNaN(parsed.getTime())) return parsed;
49
- }
50
- return reject(label, 'type', `expected a Date or ISO-8601 string, got ${String(value)}`);
51
- };
14
+ /** uuid v7: time-ordered, so a primary key index stays append-friendly. */
15
+ export const newId = (): string => uuidV7();
52
16
 
53
17
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
54
18
 
55
- const asUuid =
56
- (label: string) =>
57
- (value: unknown): string => {
58
- if (typeof value === 'string' && UUID.test(value)) return value;
59
- return reject(label, 'format', `expected a uuid, got ${String(value)}`);
60
- };
19
+ const parseUuid = (value: unknown): string =>
20
+ typeof value === 'string' && UUID.test(value)
21
+ ? value
22
+ : reject('format', `expected a uuid, got ${String(value)}`);
23
+
24
+ export const uuid = (): UuidColumn => ({
25
+ ...makeColumn<string, false>({ ...BARE, kind: 'uuid' }, parseUuid, false),
26
+ // Narrower than the generic chain: a uuid key is generated when omitted, so it is the one
27
+ // primary key an insert may leave out.
28
+ primaryKey: () =>
29
+ makeColumn<string, true>(
30
+ { ...BARE, kind: 'uuid', primaryKey: true, default: GENERATED_UUID },
31
+ parseUuid,
32
+ true,
33
+ ),
34
+ });
61
35
 
62
- /** uuid v7: time-ordered, so a primary key index stays append-friendly. */
63
- export const newId = (): string => uuid();
64
-
65
- export const id = (options: ColumnOptions = {}): ColumnDef<string> =>
66
- base<string>('uuid', asUuid('id'), {
67
- ...options,
68
- name: options.name ?? 'id',
69
- primaryKey: true,
70
- default: { kind: 'generated', by: 'uuid-v7' },
71
- });
72
-
73
- export const text = (options: ColumnOptions & { readonly check?: string } = {}) =>
74
- base<string>('text', asString(options.name ?? 'text'), options);
75
-
76
- const asBoolean =
77
- (label: string) =>
78
- (value: unknown): boolean =>
79
- typeof value === 'boolean'
80
- ? value
81
- : reject(label, 'type', `expected a boolean, got ${typeof value}`);
36
+ export interface TextOptions {
37
+ /** Emits `char_length(<column>) <= max`, so Postgres refuses an over-long string too. */
38
+ readonly max?: number;
39
+ }
82
40
 
83
- const asInteger =
84
- (label: string) =>
85
- (value: unknown): number =>
41
+ export const text = (options: TextOptions = {}): Column<string> =>
42
+ column<string>(
43
+ 'text',
44
+ (value) =>
45
+ typeof value === 'string' ? value : reject('type', `expected a string, got ${typeof value}`),
46
+ options.max === undefined
47
+ ? {}
48
+ : { length: options.max, check: (name) => `char_length(${name}) <= ${options.max}` },
49
+ );
50
+
51
+ export const integer = (): Column<number> =>
52
+ column<number>('integer', (value) =>
86
53
  typeof value === 'number' && Number.isSafeInteger(value)
87
54
  ? value
88
- : reject(label, 'type', `expected a safe integer, got ${String(value)}`);
55
+ : reject('type', `expected a safe integer, got ${String(value)}`),
56
+ );
89
57
 
90
- const asCurrency =
91
- (label: string) =>
92
- (value: unknown): string =>
93
- typeof value === 'string' && /^[A-Z]{3}$/.test(value)
94
- ? value
95
- : reject(label, 'iso-4217', `expected a 3-letter ISO-4217 code, got ${String(value)}`);
96
-
97
- export const boolean = (options: ColumnOptions = {}): ColumnDef<boolean> =>
98
- base<boolean>('boolean', asBoolean(options.name ?? 'boolean'), options);
99
-
100
- export const integer = (
101
- options: ColumnOptions & { readonly check?: string } = {},
102
- ): ColumnDef<number> => base<number>('integer', asInteger(options.name ?? 'integer'), options);
103
-
104
- /** UTC always. A `timestamp without time zone` column is not expressible here. */
105
- export const timestamps = (): {
106
- readonly createdAt: ColumnDef<Date>;
107
- readonly updatedAt: ColumnDef<Date>;
108
- } => ({
109
- createdAt: base<Date>('timestamptz', asDate('createdAt'), {
110
- name: 'created_at',
111
- default: { kind: 'generated', by: 'now' },
112
- index: true,
113
- }),
114
- updatedAt: base<Date>('timestamptz', asDate('updatedAt'), {
115
- name: 'updated_at',
116
- default: { kind: 'generated', by: 'now' },
117
- }),
118
- });
58
+ export const boolean = (): Column<boolean> =>
59
+ column<boolean>('boolean', (value) =>
60
+ typeof value === 'boolean' ? value : reject('type', `expected a boolean, got ${typeof value}`),
61
+ );
119
62
 
120
- export type MoneyColumns<N extends string> = {
121
- readonly [K in `${N}Minor`]: ColumnDef<bigint>;
122
- } & {
123
- readonly [K in `${N}Currency`]: ColumnDef<string>;
63
+ const parseInstant = (value: unknown): Date => {
64
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value;
65
+ if (typeof value === 'string' || typeof value === 'number') {
66
+ const parsed = new Date(value);
67
+ if (!Number.isNaN(parsed.getTime())) return parsed;
68
+ }
69
+ return reject('format', `expected a UTC instant, got ${String(value)}`);
124
70
  };
125
71
 
126
- const asMinorUnits =
127
- (label: string) =>
128
- (value: unknown): bigint => {
129
- if (typeof value === 'bigint') return value;
130
- if (typeof value === 'number') {
131
- if (!Number.isInteger(value)) {
132
- return reject(
133
- label,
134
- 'money-minor-units',
135
- `got the float ${value}; money is integer minor units — 12.34 EUR is 1234n, not 12.34`,
136
- );
137
- }
138
- return BigInt(value);
139
- }
140
- if (typeof value === 'string' && /^-?\d+$/.test(value)) return BigInt(value);
141
- return reject(label, 'money-minor-units', `expected integer minor units, got ${String(value)}`);
142
- };
72
+ /** Always `timestamptz`. UTC storage is not a per-table decision. */
73
+ export const timestamp = (): TimestampColumn =>
74
+ makeTimestamp<false>({ ...BARE, kind: 'timestamptz' }, parseInstant, false);
75
+
76
+ const quote = (value: string): string => `'${value.replaceAll("'", "''")}'`;
77
+
78
+ const oneOf =
79
+ (values: readonly string[]) =>
80
+ (name: string): string =>
81
+ `${name} in (${values.map(quote).join(', ')})`;
143
82
 
144
83
  /**
145
- * Two columns, always: minor units as bigint and an ISO-4217 code. A single-currency
146
- * assumption is a migration nobody wants to write later, and a float is a rounding
147
- * bug nobody wants to debug.
84
+ * A closed set of strings, emitted as a CHECK rather than a Postgres `ENUM` type: adding a
85
+ * variant is then a one-line migration instead of `ALTER TYPE`, which cannot run inside a
86
+ * transaction on older servers.
148
87
  */
149
- export const money = <N extends string>(name: N): MoneyColumns<N> =>
150
- ({
151
- [`${name}Minor`]: base<bigint>('bigint', asMinorUnits(`${name}Minor`), {
152
- name: `${snake(name)}_minor`,
153
- }),
154
- [`${name}Currency`]: base<string>('char', asCurrency(`${name}Currency`), {
155
- name: `${snake(name)}_currency`,
156
- length: 3,
157
- check: `${snake(name)}_currency ~ '^[A-Z]{3}$'`,
158
- }),
159
- }) as unknown as MoneyColumns<N>;
160
-
161
- /** IANA identifier, validated by `Intl` at write time and by a CHECK in the database. */
162
- export const tz = (options: ColumnOptions = {}): ColumnDef<string> => {
163
- const label = options.name ?? 'tz';
164
- return base<string>(
88
+ export const enumerated = <const V extends readonly string[]>(values: V): Column<V[number]> => {
89
+ const allowed = new Set<string>(values);
90
+ return column<V[number]>(
91
+ 'text',
92
+ (value) =>
93
+ typeof value === 'string' && allowed.has(value)
94
+ ? value
95
+ : reject('enum', `expected one of ${values.join(' | ')}, got ${String(value)}`),
96
+ { values, check: oneOf(values) },
97
+ );
98
+ };
99
+
100
+ /**
101
+ * An absolute http(s) URL, validated on write rather than on render: a bad URL stored once is
102
+ * served to every reader, and `<img src>` fails silently in the browser.
103
+ */
104
+ export const url = (): Column<string> =>
105
+ column<string>(
165
106
  'text',
166
107
  (value) => {
167
108
  if (typeof value === 'string') {
168
109
  try {
169
- new Intl.DateTimeFormat('en', { timeZone: value }).format(0);
170
- return value;
110
+ const parsed = new URL(value);
111
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return value;
171
112
  } catch {
172
- return reject(label, 'iana-tz', `${value} is not an IANA time zone`);
113
+ // fall through to the shared rejection so the error names the rule
173
114
  }
174
115
  }
175
- return reject(label, 'iana-tz', `expected an IANA time zone, got ${typeof value}`);
116
+ return reject('format', `expected an absolute http(s) URL, got ${String(value)}`);
176
117
  },
177
- { ...options, check: `${snake(label)} ~ '^[A-Za-z0-9_+/-]{3,64}$'` },
118
+ { check: (name) => `${name} ~ '^https?://'` },
178
119
  );
120
+
121
+ const isIanaZone = (value: string): boolean => {
122
+ try {
123
+ new Intl.DateTimeFormat('en', { timeZone: value }).format(0);
124
+ return true;
125
+ } catch {
126
+ return false;
127
+ }
179
128
  };
180
129
 
181
- export const locale = (options: ColumnOptions = {}): ColumnDef<string> => {
182
- const label = options.name ?? 'locale';
183
- return base<string>('text', asString(label), {
184
- ...options,
185
- check: `${snake(label)} ~ '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$'`,
186
- });
130
+ /**
131
+ * IANA identifiers, checked against `Intl` when the column is declared — a typo or a UTC offset
132
+ * is a startup error rather than a row nobody can format. An offset is wrong twice a year.
133
+ */
134
+ export const tz = <const Z extends readonly string[]>(zones: Z): Column<Z[number]> => {
135
+ for (const zone of zones) {
136
+ if (!isIanaZone(zone)) reject('iana-tz', `${zone} is not an IANA time zone`);
137
+ }
138
+ const allowed = new Set<string>(zones);
139
+ return column<Z[number]>(
140
+ 'text',
141
+ (value) =>
142
+ typeof value === 'string' && allowed.has(value)
143
+ ? value
144
+ : reject('iana-tz', `expected one of ${zones.join(' | ')}, got ${String(value)}`),
145
+ { values: zones, check: oneOf(zones) },
146
+ );
187
147
  };
188
148
 
189
- export const slug = (options: ColumnOptions = {}): ColumnDef<string> => {
190
- const label = options.name ?? 'slug';
191
- return base<string>('text', asString(label), {
192
- ...options,
193
- unique: true,
194
- check: `${snake(label)} ~ '^[a-z0-9]+(-[a-z0-9]+)*$'`,
195
- });
149
+ const BCP47 = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
150
+
151
+ export const locale = <const L extends readonly string[]>(locales: L): Column<L[number]> => {
152
+ for (const tag of locales) {
153
+ if (!BCP47.test(tag)) reject('bcp-47', `${tag} is not a BCP-47 language tag`);
154
+ }
155
+ const allowed = new Set<string>(locales);
156
+ return column<L[number]>(
157
+ 'text',
158
+ (value) =>
159
+ typeof value === 'string' && allowed.has(value)
160
+ ? value
161
+ : reject('bcp-47', `expected one of ${locales.join(' | ')}, got ${String(value)}`),
162
+ { values: locales, check: oneOf(locales) },
163
+ );
196
164
  };
197
165
 
198
- /** Takes its own parser: a jsonb column without a schema is an untyped hole. */
199
- export const jsonb = <T>(parse: (value: unknown) => T, options: ColumnOptions = {}): ColumnDef<T> =>
200
- base<T>('jsonb', parse, options);
201
-
202
- /** Presence of this column is what makes an entity soft-deletable — not a flag. */
203
- export const softDelete = (): { readonly deletedAt: ColumnDef<Date | null> } => ({
204
- deletedAt: base<Date | null>(
205
- 'timestamptz',
206
- (value) => (value === null || value === undefined ? null : asDate('deletedAt')(value)),
207
- { name: 'deleted_at', notNull: false, index: true },
208
- ),
209
- });
166
+ const parseMinor = (value: unknown): bigint => {
167
+ if (typeof value === 'bigint') return value;
168
+ if (typeof value === 'number') {
169
+ if (!Number.isInteger(value)) {
170
+ return reject(
171
+ 'money-minor-units',
172
+ `got the float ${value}; money is integer minor units — 12.34 EUR is 1234n, not 12.34`,
173
+ );
174
+ }
175
+ return BigInt(value);
176
+ }
177
+ if (typeof value === 'string' && /^-?\d+$/.test(value)) return BigInt(value);
178
+ return reject('money-minor-units', `expected integer minor units, got ${String(value)}`);
179
+ };
210
180
 
211
- /** Presence of this column is what makes an entity tenant-scoped. See tenancy.ts. */
212
- export const orgId = (options: ColumnOptions & { readonly table?: string } = {}) =>
213
- base<string>('uuid', asUuid('orgId'), {
214
- name: options.name ?? 'org_id',
215
- index: true,
216
- references: { table: options.table ?? 'orgs', column: 'id', onDelete: 'cascade' },
217
- });
218
-
219
- export const nullable = <T>(column: ColumnDef<T>): ColumnDef<T | null> => ({
220
- ...column,
221
- notNull: false,
222
- parse: (value) => (value === null || value === undefined ? null : column.parse(value)),
223
- });
181
+ const parseCurrency = (value: unknown): string =>
182
+ typeof value === 'string' && /^[A-Z]{3}$/.test(value)
183
+ ? value
184
+ : reject('iso-4217', `expected a 3-letter ISO-4217 code, got ${String(value)}`);
224
185
 
225
- export const references = <T>(column: ColumnDef<T>, target: string, targetColumn = 'id') => ({
226
- ...column,
227
- index: true,
228
- references: { table: target, column: targetColumn },
229
- });
186
+ const parseMoney = (value: unknown): MoneyValue => {
187
+ if (typeof value !== 'object' || value === null) {
188
+ return reject('money', `expected { minor, currency }, got ${String(value)}`);
189
+ }
190
+ const input: Partial<MoneyInput> = value;
191
+ return { minor: parseMinor(input.minor), currency: parseCurrency(input.currency) };
192
+ };
230
193
 
231
194
  /**
232
- * Composes columns into a table, filling every column name that was left to the
233
- * property key (`orgId` -> `org_id`) so a physical name is written at most once.
195
+ * One property, two physical columns: `<name>_minor bigint` and `<name>_currency char(3)`.
196
+ * A single implied currency is a migration nobody wants to write later, and a float is a
197
+ * rounding bug nobody wants to debug.
234
198
  */
235
- export const table = <C extends ColumnMap>(name: string, columns: C): TableDef<C> => {
236
- const resolved: Record<string, ColumnDef<unknown>> = {};
237
- const primaryKey: string[] = [];
238
- const indexes: IndexDef[] = [];
239
- for (const [property, column] of Object.entries(columns)) {
240
- const physical = column.name === '' ? snake(property) : column.name;
241
- resolved[property] = { ...column, name: physical };
242
- if (column.primaryKey) primaryKey.push(physical);
243
- if (column.unique) {
244
- indexes.push({ name: `${name}_${physical}_key`, columns: [physical], unique: true });
245
- } else if (column.index) {
246
- indexes.push({ name: `${name}_${physical}_idx`, columns: [physical], unique: false });
247
- }
248
- }
249
- return {
250
- name,
251
- columns: resolved as unknown as C,
252
- primaryKey: primaryKey.length > 0 ? primaryKey : ['id'],
253
- indexes,
254
- };
255
- };
199
+ export const money = (): Column<MoneyValue> => column<MoneyValue>('money', parseMoney);
200
+
201
+ /** The CHECK that stops a psql session writing a currency the app would refuse. */
202
+ export const currencyCheck = (currencyColumn: string): string => `${currencyColumn} ~ '^[A-Z]{3}$'`;
package/src/cursor.ts ADDED
@@ -0,0 +1,187 @@
1
+ // Single responsibility: what an entity cursor *means*. The codec is `@ultimat3/core`'s — this
2
+ // file decides what a page position is bound to (the plan that produced it) and how a sort value
3
+ // survives the round trip (the column's kind).
4
+ //
5
+ // Both drivers call `cursorFor` and `seekFrom` and nothing else, so a rule added to one is added
6
+ // to both. The cursor carries the sort VALUES, not just an id: seeking by id alone needs the row
7
+ // to still exist, and a row deleted between two pages would silently restart pagination.
8
+
9
+ import { CursorInvalidError, decodeCursor, encodeCursor } from '@ultimat3/core';
10
+ import type { EntityCore } from './entity';
11
+ import { invariantViolated } from './errors';
12
+ import type { QueryPlan } from './tenancy';
13
+ import type { AnyColumn, ColumnKind } from './types';
14
+
15
+ const MONEY_PARTS: Readonly<Record<string, ColumnKind>> = { minor: 'bigint', currency: 'char' };
16
+
17
+ /** Resolves `price.minor` as well as `title`; money is the one property with two parts. */
18
+ const partsOf = (path: string): { readonly property: string; readonly part?: string } => {
19
+ const [property = path, part] = path.split('.');
20
+ return part === undefined ? { property } : { property, part };
21
+ };
22
+
23
+ const columnAt = <Row>(entity: EntityCore<Row>, path: string): AnyColumn => {
24
+ const column = entity.$columns[partsOf(path).property];
25
+ if (column === undefined) {
26
+ throw invariantViolated(entity.$name, 'orderBy', `no column "${path}"`);
27
+ }
28
+ return column;
29
+ };
30
+
31
+ /** The physical type a sort key holds — what tells `revive` how to read its string back. */
32
+ const kindAt = <Row>(entity: EntityCore<Row>, path: string): ColumnKind => {
33
+ const { part } = partsOf(path);
34
+ const kind = columnAt(entity, path).$meta.kind;
35
+ if (part === undefined) {
36
+ // Money is two physical columns, so the property alone names no single sort value: the
37
+ // cursor would carry `String({ minor, currency })` and the next page would fail parsing it
38
+ // as a bare `SyntaxError` from `BigInt`, with no code and no fix. `entity()` refuses the
39
+ // same path in `resolve()`; refusing it here keeps one answer for one mistake.
40
+ if (kind !== 'money') return kind;
41
+ throw invariantViolated(
42
+ entity.$name,
43
+ 'orderBy',
44
+ `${path} is money: order by ${path}.minor or ${path}.currency`,
45
+ );
46
+ }
47
+ const money = kind === 'money' ? MONEY_PARTS[part] : undefined;
48
+ if (money === undefined) {
49
+ throw invariantViolated(entity.$name, 'orderBy', `${path} names no column part`);
50
+ }
51
+ return money;
52
+ };
53
+
54
+ export const valueAt = (row: unknown, path: string): unknown => {
55
+ const { property, part } = partsOf(path);
56
+ const record = typeof row === 'object' && row !== null ? (row as Record<string, unknown>) : {};
57
+ const base = record[property];
58
+ if (part === undefined) return base;
59
+ return typeof base === 'object' && base !== null
60
+ ? (base as Record<string, unknown>)[part]
61
+ : undefined;
62
+ };
63
+
64
+ /** Stringified so the cursor is JSON; `revive` restores the type from the column's kind. */
65
+ const serializeSortValue = (value: unknown): string => {
66
+ if (value instanceof Date) return value.toISOString();
67
+ if (typeof value === 'bigint') return value.toString();
68
+ return String(value);
69
+ };
70
+
71
+ // No `money` case: `kindAt` resolves a money sort key to the kind of the part being ordered by
72
+ // (`minor` is bigint, `currency` is char) and refuses the bare property, so the composite kind
73
+ // never reaches here. A case for it could only ever revive "[object Object]".
74
+ const reviveSortValue = (kind: ColumnKind, text: string): unknown => {
75
+ switch (kind) {
76
+ case 'timestamptz':
77
+ return new Date(text);
78
+ case 'bigint':
79
+ return BigInt(text);
80
+ case 'integer':
81
+ return Number(text);
82
+ case 'boolean':
83
+ return text === 'true';
84
+ default:
85
+ return text;
86
+ }
87
+ };
88
+
89
+ /**
90
+ * A keyset seek only has a total order when every sort column is present on every row —
91
+ * `null > 'x'` is unknown in SQL and would drop rows from the middle of a listing.
92
+ *
93
+ * Checked when a cursor is minted as well as when one is decoded: an ordering that cannot carry
94
+ * a position is the author's mistake, and reporting it on the *second* page hides it behind
95
+ * whatever page size the caller happened to use.
96
+ */
97
+ export const assertSeekable = <Row>(
98
+ entity: EntityCore<Row>,
99
+ orderBy: readonly { readonly column: string }[],
100
+ ): void => {
101
+ for (const key of orderBy) {
102
+ // Resolving the kind is the other half: it refuses a column the entity never declared and a
103
+ // money property named without its part — both mint a cursor nothing can decode.
104
+ kindAt(entity, key.column);
105
+ if (columnAt(entity, key.column).$meta.notNull) continue;
106
+ throw invariantViolated(
107
+ entity.$name,
108
+ 'cursor',
109
+ `${key.column} is nullable and cannot carry a cursor — order by a not-null column ` +
110
+ `(add .orderBy('${entity.$primaryKey[0] ?? 'id'}') or make ${key.column} not null)`,
111
+ );
112
+ }
113
+ };
114
+
115
+ /** Deterministic, and total over the value shapes a predicate can hold. */
116
+ const renderValue = (value: unknown): string => {
117
+ if (value === null || value === undefined) return 'null';
118
+ if (value instanceof Date) return value.toISOString();
119
+ if (typeof value === 'bigint') return `${value}n`;
120
+ if (Array.isArray(value)) return `[${value.map(renderValue).join(',')}]`;
121
+ if (typeof value === 'object') {
122
+ const record = value as Readonly<Record<string, unknown>>;
123
+ const keys = Object.keys(record).sort();
124
+ return `{${keys.map((key) => `${key}:${renderValue(record[key])}`).join(',')}}`;
125
+ }
126
+ return JSON.stringify(String(value));
127
+ };
128
+
129
+ /**
130
+ * What a cursor is bound to: this entity, these filters, this sort order. Not the page size — a
131
+ * client may legitimately ask for a bigger next page — and not the projection, which cannot move
132
+ * a row's position. Filters are sorted because `and` is commutative, so two chains that build the
133
+ * same predicate set page each other's cursors.
134
+ *
135
+ * Hashed rather than spelled out: a cursor is base64, not encrypted, and the caller's filter
136
+ * values are not the client's to read.
137
+ */
138
+ export const planScope = (plan: QueryPlan): string => {
139
+ const where = plan.where
140
+ .map((predicate) => `${predicate.column} ${predicate.op} ${renderValue(predicate.value)}`)
141
+ .sort()
142
+ .join('&');
143
+ const order = plan.orderBy.map((key) => `${key.column} ${key.direction}`).join(',');
144
+ return new Bun.CryptoHasher('sha256')
145
+ .update(`${plan.entity}|${where}|${order}`)
146
+ .digest('hex')
147
+ .slice(0, 16);
148
+ };
149
+
150
+ /** The cursor that continues this plan after `row`. Signed by core, scoped by the plan. */
151
+ export const cursorFor = <Row>(
152
+ entity: EntityCore<Row>,
153
+ plan: QueryPlan,
154
+ row: unknown,
155
+ id: string,
156
+ ): string => {
157
+ assertSeekable(entity, plan.orderBy);
158
+ return encodeCursor({
159
+ scope: planScope(plan),
160
+ key: plan.orderBy.map((entry) => serializeSortValue(valueAt(row, entry.column))),
161
+ id,
162
+ });
163
+ };
164
+
165
+ /**
166
+ * The keyset position a plan resumes from, revived to the types its columns hold — `undefined`
167
+ * when the plan has no cursor. A cursor that was tampered with, or taken from another entity,
168
+ * another filter or another sort order, is `X_CURSOR_INVALID` here rather than a silent page one.
169
+ */
170
+ export const seekFrom = <Row>(
171
+ entity: EntityCore<Row>,
172
+ plan: QueryPlan,
173
+ ): readonly unknown[] | undefined => {
174
+ if (plan.cursor === undefined) return undefined;
175
+ const { key } = decodeCursor(plan.cursor, planScope(plan));
176
+ assertSeekable(entity, plan.orderBy);
177
+ // Unreachable through the scope check, which already pins the sort order — kept because the
178
+ // alternative to a bad arity is `?? ''`, and that seeks from an empty string.
179
+ if (key.length !== plan.orderBy.length) {
180
+ throw new CursorInvalidError(
181
+ `it carries ${key.length} sort values, this order needs ${plan.orderBy.length}`,
182
+ );
183
+ }
184
+ return plan.orderBy.map((entry, index) =>
185
+ reviveSortValue(kindAt(entity, entry.column), String(key[index])),
186
+ );
187
+ };