@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/LICENSE +21 -0
- package/README.md +126 -40
- package/package.json +4 -3
- package/src/column.ts +134 -0
- package/src/columns.ts +164 -217
- package/src/cursor.ts +187 -0
- package/src/database.ts +62 -0
- package/src/describe.ts +106 -0
- package/src/entity.ts +246 -99
- package/src/errors.ts +27 -6
- package/src/expr.ts +231 -0
- package/src/index.ts +36 -20
- package/src/invariants.ts +53 -49
- package/src/pg-driver.ts +154 -0
- package/src/pg-row.ts +110 -0
- package/src/pg-sql.ts +162 -0
- package/src/plan.ts +82 -0
- package/src/query.ts +144 -0
- package/src/registry.ts +8 -5
- package/src/repo.ts +0 -0
- package/src/seed.ts +69 -0
- package/src/tenancy.ts +68 -19
- package/src/types.ts +94 -44
- package/src/view.ts +97 -0
package/src/columns.ts
CHANGED
|
@@ -1,255 +1,202 @@
|
|
|
1
|
-
// The blessed column
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
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 = (
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
84
|
-
(
|
|
85
|
-
|
|
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(
|
|
55
|
+
: reject('type', `expected a safe integer, got ${String(value)}`),
|
|
56
|
+
);
|
|
89
57
|
|
|
90
|
-
const
|
|
91
|
-
(
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
|
150
|
-
(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
170
|
-
return value;
|
|
110
|
+
const parsed = new URL(value);
|
|
111
|
+
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return value;
|
|
171
112
|
} catch {
|
|
172
|
-
|
|
113
|
+
// fall through to the shared rejection so the error names the rule
|
|
173
114
|
}
|
|
174
115
|
}
|
|
175
|
-
return reject(
|
|
116
|
+
return reject('format', `expected an absolute http(s) URL, got ${String(value)}`);
|
|
176
117
|
},
|
|
177
|
-
{
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
*
|
|
233
|
-
*
|
|
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
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
+
};
|