@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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -40
  3. package/package.json +4 -3
  4. package/src/column.d.ts +28 -0
  5. package/src/column.d.ts.map +1 -0
  6. package/src/column.js +75 -0
  7. package/src/column.js.map +1 -0
  8. package/src/column.ts +134 -0
  9. package/src/columns.d.ts +39 -0
  10. package/src/columns.d.ts.map +1 -0
  11. package/src/columns.js +136 -0
  12. package/src/columns.js.map +1 -0
  13. package/src/columns.ts +164 -217
  14. package/src/cursor.ts +187 -0
  15. package/src/database.d.ts +21 -0
  16. package/src/database.d.ts.map +1 -0
  17. package/src/database.js +38 -0
  18. package/src/database.js.map +1 -0
  19. package/src/database.ts +62 -0
  20. package/src/describe.d.ts +16 -0
  21. package/src/describe.d.ts.map +1 -0
  22. package/src/describe.js +79 -0
  23. package/src/describe.js.map +1 -0
  24. package/src/describe.ts +106 -0
  25. package/src/entity.d.ts +58 -0
  26. package/src/entity.d.ts.map +1 -0
  27. package/src/entity.js +160 -0
  28. package/src/entity.js.map +1 -0
  29. package/src/entity.ts +246 -99
  30. package/src/errors.d.ts +18 -0
  31. package/src/errors.d.ts.map +1 -0
  32. package/src/errors.js +59 -0
  33. package/src/errors.js.map +1 -0
  34. package/src/errors.ts +27 -6
  35. package/src/expr.d.ts +41 -0
  36. package/src/expr.d.ts.map +1 -0
  37. package/src/expr.js +94 -0
  38. package/src/expr.js.map +1 -0
  39. package/src/expr.ts +231 -0
  40. package/src/index.d.ts +23 -0
  41. package/src/index.d.ts.map +1 -0
  42. package/src/index.js +12 -0
  43. package/src/index.js.map +1 -0
  44. package/src/index.ts +36 -20
  45. package/src/invariants.d.ts +36 -0
  46. package/src/invariants.d.ts.map +1 -0
  47. package/src/invariants.js +53 -0
  48. package/src/invariants.js.map +1 -0
  49. package/src/invariants.ts +53 -49
  50. package/src/pg-driver.ts +154 -0
  51. package/src/pg-row.ts +110 -0
  52. package/src/pg-sql.ts +162 -0
  53. package/src/plan.ts +82 -0
  54. package/src/query.d.ts +30 -0
  55. package/src/query.d.ts.map +1 -0
  56. package/src/query.js +74 -0
  57. package/src/query.js.map +1 -0
  58. package/src/query.ts +144 -0
  59. package/src/registry.d.ts +45 -0
  60. package/src/registry.d.ts.map +1 -0
  61. package/src/registry.js +26 -0
  62. package/src/registry.js.map +1 -0
  63. package/src/registry.ts +8 -5
  64. package/src/repo.d.ts +54 -0
  65. package/src/repo.d.ts.map +1 -0
  66. package/src/repo.js +203 -0
  67. package/src/repo.js.map +1 -0
  68. package/src/repo.ts +0 -0
  69. package/src/seed.d.ts +20 -0
  70. package/src/seed.d.ts.map +1 -0
  71. package/src/seed.js +43 -0
  72. package/src/seed.js.map +1 -0
  73. package/src/seed.ts +69 -0
  74. package/src/tenancy.d.ts +41 -0
  75. package/src/tenancy.d.ts.map +1 -0
  76. package/src/tenancy.js +57 -0
  77. package/src/tenancy.js.map +1 -0
  78. package/src/tenancy.ts +68 -19
  79. package/src/types.d.ts +99 -0
  80. package/src/types.d.ts.map +1 -0
  81. package/src/types.js +8 -0
  82. package/src/types.js.map +1 -0
  83. package/src/types.ts +94 -44
  84. 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 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}$'`;