@ultimat3/entity 0.0.1 → 1.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.
- package/LICENSE +21 -0
- package/README.md +126 -40
- package/package.json +4 -3
- package/src/column.d.ts +28 -0
- package/src/column.d.ts.map +1 -0
- package/src/column.js +75 -0
- package/src/column.js.map +1 -0
- package/src/column.ts +134 -0
- package/src/columns.d.ts +39 -0
- package/src/columns.d.ts.map +1 -0
- package/src/columns.js +136 -0
- package/src/columns.js.map +1 -0
- package/src/columns.ts +164 -217
- package/src/cursor.ts +187 -0
- package/src/database.d.ts +21 -0
- package/src/database.d.ts.map +1 -0
- package/src/database.js +38 -0
- package/src/database.js.map +1 -0
- package/src/database.ts +62 -0
- package/src/describe.d.ts +16 -0
- package/src/describe.d.ts.map +1 -0
- package/src/describe.js +79 -0
- package/src/describe.js.map +1 -0
- package/src/describe.ts +106 -0
- package/src/entity.d.ts +58 -0
- package/src/entity.d.ts.map +1 -0
- package/src/entity.js +160 -0
- package/src/entity.js.map +1 -0
- package/src/entity.ts +246 -99
- package/src/errors.d.ts +18 -0
- package/src/errors.d.ts.map +1 -0
- package/src/errors.js +59 -0
- package/src/errors.js.map +1 -0
- package/src/errors.ts +27 -6
- package/src/expr.d.ts +41 -0
- package/src/expr.d.ts.map +1 -0
- package/src/expr.js +94 -0
- package/src/expr.js.map +1 -0
- package/src/expr.ts +231 -0
- package/src/index.d.ts +23 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +12 -0
- package/src/index.js.map +1 -0
- package/src/index.ts +36 -20
- package/src/invariants.d.ts +36 -0
- package/src/invariants.d.ts.map +1 -0
- package/src/invariants.js +53 -0
- package/src/invariants.js.map +1 -0
- 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.d.ts +30 -0
- package/src/query.d.ts.map +1 -0
- package/src/query.js +74 -0
- package/src/query.js.map +1 -0
- package/src/query.ts +144 -0
- package/src/registry.d.ts +45 -0
- package/src/registry.d.ts.map +1 -0
- package/src/registry.js +26 -0
- package/src/registry.js.map +1 -0
- package/src/registry.ts +8 -5
- package/src/repo.d.ts +54 -0
- package/src/repo.d.ts.map +1 -0
- package/src/repo.js +203 -0
- package/src/repo.js.map +1 -0
- package/src/repo.ts +0 -0
- package/src/seed.d.ts +20 -0
- package/src/seed.d.ts.map +1 -0
- package/src/seed.js +43 -0
- package/src/seed.js.map +1 -0
- package/src/seed.ts +69 -0
- package/src/tenancy.d.ts +41 -0
- package/src/tenancy.d.ts.map +1 -0
- package/src/tenancy.js +57 -0
- package/src/tenancy.js.map +1 -0
- package/src/tenancy.ts +68 -19
- package/src/types.d.ts +99 -0
- package/src/types.d.ts.map +1 -0
- package/src/types.js +8 -0
- package/src/types.js.map +1 -0
- package/src/types.ts +94 -44
- package/src/view.ts +97 -0
package/src/columns.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
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.
|
|
4
|
+
import { uuid as uuidV7 } from '@ultimat3/core';
|
|
5
|
+
import { BARE, column, GENERATED_UUID, makeColumn, makeTimestamp } from './column';
|
|
6
|
+
import { invariantViolated } from './errors';
|
|
7
|
+
const reject = (rule, detail) => {
|
|
8
|
+
throw invariantViolated('column', rule, detail);
|
|
9
|
+
};
|
|
10
|
+
/** uuid v7: time-ordered, so a primary key index stays append-friendly. */
|
|
11
|
+
export const newId = () => uuidV7();
|
|
12
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
13
|
+
const parseUuid = (value) => typeof value === 'string' && UUID.test(value)
|
|
14
|
+
? value
|
|
15
|
+
: reject('format', `expected a uuid, got ${String(value)}`);
|
|
16
|
+
export const uuid = () => ({
|
|
17
|
+
...makeColumn({ ...BARE, kind: 'uuid' }, parseUuid, false),
|
|
18
|
+
// Narrower than the generic chain: a uuid key is generated when omitted, so it is the one
|
|
19
|
+
// primary key an insert may leave out.
|
|
20
|
+
primaryKey: () => makeColumn({ ...BARE, kind: 'uuid', primaryKey: true, default: GENERATED_UUID }, parseUuid, true),
|
|
21
|
+
});
|
|
22
|
+
export const text = (options = {}) => column('text', (value) => typeof value === 'string' ? value : reject('type', `expected a string, got ${typeof value}`), options.max === undefined
|
|
23
|
+
? {}
|
|
24
|
+
: { length: options.max, check: (name) => `char_length(${name}) <= ${options.max}` });
|
|
25
|
+
export const integer = () => column('integer', (value) => typeof value === 'number' && Number.isSafeInteger(value)
|
|
26
|
+
? value
|
|
27
|
+
: reject('type', `expected a safe integer, got ${String(value)}`));
|
|
28
|
+
export const boolean = () => column('boolean', (value) => typeof value === 'boolean' ? value : reject('type', `expected a boolean, got ${typeof value}`));
|
|
29
|
+
const parseInstant = (value) => {
|
|
30
|
+
if (value instanceof Date && !Number.isNaN(value.getTime()))
|
|
31
|
+
return value;
|
|
32
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
33
|
+
const parsed = new Date(value);
|
|
34
|
+
if (!Number.isNaN(parsed.getTime()))
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
return reject('format', `expected a UTC instant, got ${String(value)}`);
|
|
38
|
+
};
|
|
39
|
+
/** Always `timestamptz`. UTC storage is not a per-table decision. */
|
|
40
|
+
export const timestamp = () => makeTimestamp({ ...BARE, kind: 'timestamptz' }, parseInstant, false);
|
|
41
|
+
const quote = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
42
|
+
const oneOf = (values) => (name) => `${name} in (${values.map(quote).join(', ')})`;
|
|
43
|
+
/**
|
|
44
|
+
* A closed set of strings, emitted as a CHECK rather than a Postgres `ENUM` type: adding a
|
|
45
|
+
* variant is then a one-line migration instead of `ALTER TYPE`, which cannot run inside a
|
|
46
|
+
* transaction on older servers.
|
|
47
|
+
*/
|
|
48
|
+
export const enumerated = (values) => {
|
|
49
|
+
const allowed = new Set(values);
|
|
50
|
+
return column('text', (value) => typeof value === 'string' && allowed.has(value)
|
|
51
|
+
? value
|
|
52
|
+
: reject('enum', `expected one of ${values.join(' | ')}, got ${String(value)}`), { values, check: oneOf(values) });
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* An absolute http(s) URL, validated on write rather than on render: a bad URL stored once is
|
|
56
|
+
* served to every reader, and `<img src>` fails silently in the browser.
|
|
57
|
+
*/
|
|
58
|
+
export const url = () => column('text', (value) => {
|
|
59
|
+
if (typeof value === 'string') {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = new URL(value);
|
|
62
|
+
if (parsed.protocol === 'http:' || parsed.protocol === 'https:')
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// fall through to the shared rejection so the error names the rule
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return reject('format', `expected an absolute http(s) URL, got ${String(value)}`);
|
|
70
|
+
}, { check: (name) => `${name} ~ '^https?://'` });
|
|
71
|
+
const isIanaZone = (value) => {
|
|
72
|
+
try {
|
|
73
|
+
new Intl.DateTimeFormat('en', { timeZone: value }).format(0);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* IANA identifiers, checked against `Intl` when the column is declared — a typo or a UTC offset
|
|
82
|
+
* is a startup error rather than a row nobody can format. An offset is wrong twice a year.
|
|
83
|
+
*/
|
|
84
|
+
export const tz = (zones) => {
|
|
85
|
+
for (const zone of zones) {
|
|
86
|
+
if (!isIanaZone(zone))
|
|
87
|
+
reject('iana-tz', `${zone} is not an IANA time zone`);
|
|
88
|
+
}
|
|
89
|
+
const allowed = new Set(zones);
|
|
90
|
+
return column('text', (value) => typeof value === 'string' && allowed.has(value)
|
|
91
|
+
? value
|
|
92
|
+
: reject('iana-tz', `expected one of ${zones.join(' | ')}, got ${String(value)}`), { values: zones, check: oneOf(zones) });
|
|
93
|
+
};
|
|
94
|
+
const BCP47 = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
95
|
+
export const locale = (locales) => {
|
|
96
|
+
for (const tag of locales) {
|
|
97
|
+
if (!BCP47.test(tag))
|
|
98
|
+
reject('bcp-47', `${tag} is not a BCP-47 language tag`);
|
|
99
|
+
}
|
|
100
|
+
const allowed = new Set(locales);
|
|
101
|
+
return column('text', (value) => typeof value === 'string' && allowed.has(value)
|
|
102
|
+
? value
|
|
103
|
+
: reject('bcp-47', `expected one of ${locales.join(' | ')}, got ${String(value)}`), { values: locales, check: oneOf(locales) });
|
|
104
|
+
};
|
|
105
|
+
const parseMinor = (value) => {
|
|
106
|
+
if (typeof value === 'bigint')
|
|
107
|
+
return value;
|
|
108
|
+
if (typeof value === 'number') {
|
|
109
|
+
if (!Number.isInteger(value)) {
|
|
110
|
+
return reject('money-minor-units', `got the float ${value}; money is integer minor units — 12.34 EUR is 1234n, not 12.34`);
|
|
111
|
+
}
|
|
112
|
+
return BigInt(value);
|
|
113
|
+
}
|
|
114
|
+
if (typeof value === 'string' && /^-?\d+$/.test(value))
|
|
115
|
+
return BigInt(value);
|
|
116
|
+
return reject('money-minor-units', `expected integer minor units, got ${String(value)}`);
|
|
117
|
+
};
|
|
118
|
+
const parseCurrency = (value) => typeof value === 'string' && /^[A-Z]{3}$/.test(value)
|
|
119
|
+
? value
|
|
120
|
+
: reject('iso-4217', `expected a 3-letter ISO-4217 code, got ${String(value)}`);
|
|
121
|
+
const parseMoney = (value) => {
|
|
122
|
+
if (typeof value !== 'object' || value === null) {
|
|
123
|
+
return reject('money', `expected { minor, currency }, got ${String(value)}`);
|
|
124
|
+
}
|
|
125
|
+
const input = value;
|
|
126
|
+
return { minor: parseMinor(input.minor), currency: parseCurrency(input.currency) };
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* One property, two physical columns: `<name>_minor bigint` and `<name>_currency char(3)`.
|
|
130
|
+
* A single implied currency is a migration nobody wants to write later, and a float is a
|
|
131
|
+
* rounding bug nobody wants to debug.
|
|
132
|
+
*/
|
|
133
|
+
export const money = () => column('money', parseMoney);
|
|
134
|
+
/** The CHECK that stops a psql session writing a currency the app would refuse. */
|
|
135
|
+
export const currencyCheck = (currencyColumn) => `${currencyColumn} ~ '^[A-Z]{3}$'`;
|
|
136
|
+
//# sourceMappingURL=columns.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"columns.js","sourceRoot":"","sources":["columns.ts"],"names":[],"mappings":"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"}
|
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}$'`;
|