@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/expr.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// The invariant expression language. One declaration compiles to two enforcement points: a
|
|
2
|
+
// predicate the app runs on every write, and the SQL predicate the migration emits.
|
|
3
|
+
//
|
|
4
|
+
// Expressions are written against property keys (`c.likeCount`) and resolved to physical names
|
|
5
|
+
// (`like_count`) by `entity()`, because the author never writes a physical name.
|
|
6
|
+
//
|
|
7
|
+
// A JS predicate (`matches(isValidSlug)`, `satisfies(fn, [...])`) cannot be translated to SQL.
|
|
8
|
+
// It still runs in the app, and reports `sql: null` so `x verify` can warn that the database
|
|
9
|
+
// does not know this rule — silently pretending it reached Postgres would be worse.
|
|
10
|
+
|
|
11
|
+
import { invariantViolated } from './errors';
|
|
12
|
+
|
|
13
|
+
export type Row = Readonly<Record<string, unknown>>;
|
|
14
|
+
|
|
15
|
+
/** Property path -> physical column name. */
|
|
16
|
+
export type Resolve = (path: readonly string[]) => string;
|
|
17
|
+
|
|
18
|
+
export interface Expr {
|
|
19
|
+
readonly kind: 'check' | 'unique';
|
|
20
|
+
readonly paths: readonly (readonly string[])[];
|
|
21
|
+
readonly message: string;
|
|
22
|
+
/** `null` when the rule is a JS predicate the database cannot be told about. */
|
|
23
|
+
toSql(resolve: Resolve): string | null;
|
|
24
|
+
holds(row: Row): boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Term {
|
|
28
|
+
readonly path: readonly string[];
|
|
29
|
+
readonly label: string;
|
|
30
|
+
sql(resolve: Resolve): string;
|
|
31
|
+
read(row: Row): unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ColumnExpr {
|
|
35
|
+
/** `btrim(...)` in SQL, `.trim()` in the app — the same rule, both sides. */
|
|
36
|
+
trimmed(): ColumnExpr;
|
|
37
|
+
minLength(length: number): Expr;
|
|
38
|
+
contains(value: string): Expr;
|
|
39
|
+
/** A `RegExp` reaches the database; a function is app-only. */
|
|
40
|
+
matches(pattern: RegExp | ((value: string) => boolean)): Expr;
|
|
41
|
+
atLeast(value: number | bigint): Expr;
|
|
42
|
+
eq(value: string | number | boolean | bigint | ColumnExpr): Expr;
|
|
43
|
+
isTrue(): Expr;
|
|
44
|
+
/** Money is two physical columns; these are how a rule names one of them. */
|
|
45
|
+
readonly minor: ColumnExpr;
|
|
46
|
+
readonly currency: ColumnExpr;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type RowPredicate = (...values: never[]) => boolean;
|
|
50
|
+
|
|
51
|
+
export type InvariantColumns = {
|
|
52
|
+
readonly [column: string]: ColumnExpr;
|
|
53
|
+
} & {
|
|
54
|
+
/** Decided by the database — a single row cannot see a duplicate. */
|
|
55
|
+
unique(columns: readonly string[]): Expr;
|
|
56
|
+
/** Lifts a domain predicate over several columns. App-only by construction. */
|
|
57
|
+
satisfies(predicate: RowPredicate, columns: readonly string[]): Expr;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const terms = new WeakMap<ColumnExpr, Term>();
|
|
61
|
+
|
|
62
|
+
const walk = (row: Row, path: readonly string[]): unknown =>
|
|
63
|
+
path.reduce<unknown>(
|
|
64
|
+
(value, key) => (typeof value === 'object' && value !== null ? (value as Row)[key] : undefined),
|
|
65
|
+
row,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const literal = (value: unknown): string =>
|
|
69
|
+
typeof value === 'string' ? `'${value.replaceAll("'", "''")}'` : String(value);
|
|
70
|
+
|
|
71
|
+
const check = (
|
|
72
|
+
paths: readonly (readonly string[])[],
|
|
73
|
+
message: string,
|
|
74
|
+
sql: (resolve: Resolve) => string | null,
|
|
75
|
+
holds: (row: Row) => boolean,
|
|
76
|
+
): Expr => ({ kind: 'check', paths, message, toSql: sql, holds });
|
|
77
|
+
|
|
78
|
+
const isColumnExpr = (value: unknown): value is ColumnExpr =>
|
|
79
|
+
typeof value === 'object' && value !== null && terms.has(value as ColumnExpr);
|
|
80
|
+
|
|
81
|
+
const expr = (term: Term): ColumnExpr => {
|
|
82
|
+
const one = (
|
|
83
|
+
message: string,
|
|
84
|
+
sql: (resolve: Resolve) => string | null,
|
|
85
|
+
holds: (value: unknown) => boolean,
|
|
86
|
+
): Expr => check([term.path], message, sql, (row) => holds(term.read(row)));
|
|
87
|
+
|
|
88
|
+
const built: ColumnExpr = {
|
|
89
|
+
trimmed: () =>
|
|
90
|
+
expr({
|
|
91
|
+
path: term.path,
|
|
92
|
+
label: `trimmed ${term.label}`,
|
|
93
|
+
sql: (resolve) => `btrim(${term.sql(resolve)})`,
|
|
94
|
+
read: (row) => {
|
|
95
|
+
const value = term.read(row);
|
|
96
|
+
return typeof value === 'string' ? value.trim() : value;
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
|
|
100
|
+
minLength: (length) =>
|
|
101
|
+
one(
|
|
102
|
+
`${term.label} must be at least ${length} character${length === 1 ? '' : 's'}`,
|
|
103
|
+
(resolve) => `char_length(${term.sql(resolve)}) >= ${length}`,
|
|
104
|
+
(value) => typeof value === 'string' && value.length >= length,
|
|
105
|
+
),
|
|
106
|
+
|
|
107
|
+
contains: (value) =>
|
|
108
|
+
one(
|
|
109
|
+
`${term.label} must contain ${literal(value)}`,
|
|
110
|
+
(resolve) => `position(${literal(value)} in ${term.sql(resolve)}) > 0`,
|
|
111
|
+
(actual) => typeof actual === 'string' && actual.includes(value),
|
|
112
|
+
),
|
|
113
|
+
|
|
114
|
+
matches: (pattern) =>
|
|
115
|
+
one(
|
|
116
|
+
`${term.label} must match ${pattern instanceof RegExp ? pattern.source : pattern.name || 'the rule'}`,
|
|
117
|
+
(resolve) =>
|
|
118
|
+
pattern instanceof RegExp ? `${term.sql(resolve)} ~ ${literal(pattern.source)}` : null,
|
|
119
|
+
(value) =>
|
|
120
|
+
typeof value === 'string' &&
|
|
121
|
+
(pattern instanceof RegExp ? pattern.test(value) : pattern(value)),
|
|
122
|
+
),
|
|
123
|
+
|
|
124
|
+
atLeast: (bound) =>
|
|
125
|
+
one(
|
|
126
|
+
`${term.label} must be at least ${bound}`,
|
|
127
|
+
(resolve) => `${term.sql(resolve)} >= ${bound}`,
|
|
128
|
+
(value) => (typeof value === 'number' || typeof value === 'bigint') && value >= bound,
|
|
129
|
+
),
|
|
130
|
+
|
|
131
|
+
eq: (other) =>
|
|
132
|
+
isColumnExpr(other)
|
|
133
|
+
? sameAs(term, other)
|
|
134
|
+
: one(
|
|
135
|
+
`${term.label} must equal ${literal(other)}`,
|
|
136
|
+
(resolve) => `${term.sql(resolve)} = ${literal(other)}`,
|
|
137
|
+
(value) => value === other,
|
|
138
|
+
),
|
|
139
|
+
|
|
140
|
+
isTrue: () =>
|
|
141
|
+
one(
|
|
142
|
+
`${term.label} must be true`,
|
|
143
|
+
(resolve) => term.sql(resolve),
|
|
144
|
+
(value) => value === true,
|
|
145
|
+
),
|
|
146
|
+
|
|
147
|
+
get minor() {
|
|
148
|
+
return part(term, 'minor');
|
|
149
|
+
},
|
|
150
|
+
get currency() {
|
|
151
|
+
return part(term, 'currency');
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
terms.set(built, term);
|
|
156
|
+
return built;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const part = (term: Term, key: string): ColumnExpr =>
|
|
160
|
+
expr({
|
|
161
|
+
path: [...term.path, key],
|
|
162
|
+
label: `${term.label}.${key}`,
|
|
163
|
+
sql: (resolve) => resolve([...term.path, key]),
|
|
164
|
+
read: (row) => walk(row, [...term.path, key]),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const sameAs = (left: Term, other: ColumnExpr): Expr => {
|
|
168
|
+
const right = terms.get(other);
|
|
169
|
+
if (right === undefined) throw invariantViolated('invariant', 'eq', 'not a column expression');
|
|
170
|
+
return check(
|
|
171
|
+
[left.path, right.path],
|
|
172
|
+
`${left.label} must equal ${right.label}`,
|
|
173
|
+
(resolve) => `${left.sql(resolve)} = ${right.sql(resolve)}`,
|
|
174
|
+
(row) => left.read(row) === right.read(row),
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const columnTerm = (property: string): Term => ({
|
|
179
|
+
path: [property],
|
|
180
|
+
label: property,
|
|
181
|
+
sql: (resolve) => resolve([property]),
|
|
182
|
+
read: (row) => row[property],
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const unique = (columns: readonly string[]): Expr => ({
|
|
186
|
+
kind: 'unique',
|
|
187
|
+
paths: columns.map((column) => [column]),
|
|
188
|
+
message: `${columns.join(', ')} must be unique`,
|
|
189
|
+
toSql: (resolve) => columns.map((column) => resolve([column])).join(', '),
|
|
190
|
+
// A single row cannot see a duplicate: the unique index is the authority.
|
|
191
|
+
holds: () => true,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const satisfies = (predicate: RowPredicate, columns: readonly string[]): Expr =>
|
|
195
|
+
check(
|
|
196
|
+
columns.map((column) => [column]),
|
|
197
|
+
`${columns.join(', ')} must satisfy ${predicate.name || 'the rule'}`,
|
|
198
|
+
() => null,
|
|
199
|
+
(row) =>
|
|
200
|
+
Reflect.apply(
|
|
201
|
+
predicate,
|
|
202
|
+
undefined,
|
|
203
|
+
columns.map((column) => row[column]),
|
|
204
|
+
) === true,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The `c` an invariant is written against. A Proxy so a typo in a column name is a
|
|
209
|
+
* declaration-time error naming the columns that do exist, not `undefined is not a function`.
|
|
210
|
+
*/
|
|
211
|
+
export const invariantColumns = (
|
|
212
|
+
entity: string,
|
|
213
|
+
properties: readonly string[],
|
|
214
|
+
): InvariantColumns => {
|
|
215
|
+
const known = new Set(properties);
|
|
216
|
+
const helpers = { unique, satisfies };
|
|
217
|
+
return new Proxy(helpers, {
|
|
218
|
+
get(target, property) {
|
|
219
|
+
if (property === 'unique' || property === 'satisfies') return target[property];
|
|
220
|
+
if (typeof property !== 'string') return undefined;
|
|
221
|
+
if (!known.has(property)) {
|
|
222
|
+
throw invariantViolated(
|
|
223
|
+
entity,
|
|
224
|
+
'invariant',
|
|
225
|
+
`no column "${property}"; declared columns are ${properties.join(', ')}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return expr(columnTerm(property));
|
|
229
|
+
},
|
|
230
|
+
}) as InvariantColumns;
|
|
231
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
// The public surface of @ultimat3/entity. Explicit, never `export *`.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/** Re-exported so an `entity` file needs one import, not two. Same object as schema's. */
|
|
4
|
+
export type { Infer } from '@ultimat3/schema';
|
|
5
|
+
export { t } from '@ultimat3/schema';
|
|
6
|
+
export type { TextOptions } from './columns';
|
|
4
7
|
export {
|
|
5
8
|
boolean,
|
|
6
|
-
|
|
9
|
+
enumerated,
|
|
7
10
|
integer,
|
|
8
|
-
jsonb,
|
|
9
11
|
locale,
|
|
10
12
|
money,
|
|
11
13
|
newId,
|
|
12
|
-
nullable,
|
|
13
|
-
orgId,
|
|
14
|
-
references,
|
|
15
|
-
slug,
|
|
16
|
-
softDelete,
|
|
17
|
-
table,
|
|
18
14
|
text,
|
|
19
|
-
|
|
15
|
+
timestamp,
|
|
20
16
|
tz,
|
|
17
|
+
url,
|
|
18
|
+
uuid,
|
|
21
19
|
} from './columns';
|
|
22
|
-
export type {
|
|
23
|
-
export {
|
|
20
|
+
export type { Database, DatabaseOptions, Driver, EntitySet } from './database';
|
|
21
|
+
export { database, memoryDriver } from './database';
|
|
22
|
+
export type { Entity, EntityCore, EntityInit, IndexInit } from './entity';
|
|
23
|
+
export { entity, SOFT_DELETE_COLUMN } from './entity';
|
|
24
24
|
export type { EntityErrorCode } from './errors';
|
|
25
25
|
export {
|
|
26
26
|
dbDrift,
|
|
@@ -32,15 +32,19 @@ export {
|
|
|
32
32
|
notFound,
|
|
33
33
|
tenancyUnscoped,
|
|
34
34
|
} from './errors';
|
|
35
|
-
export type {
|
|
35
|
+
export type { ColumnExpr, Expr, InvariantColumns, Resolve } from './expr';
|
|
36
|
+
export type { Invariant, InvariantDef, InvariantKind } from './invariants';
|
|
36
37
|
export {
|
|
37
38
|
assertInvariants,
|
|
38
39
|
constraintName,
|
|
39
40
|
invariant,
|
|
40
41
|
invariantsToSql,
|
|
41
42
|
toSql,
|
|
42
|
-
unique,
|
|
43
43
|
} from './invariants';
|
|
44
|
+
export type { PostgresDriverOptions } from './pg-driver';
|
|
45
|
+
export { postgresDriver, postgresRepo, postgresTransactor } from './pg-driver';
|
|
46
|
+
export type { ReadBuilder, Table } from './query';
|
|
47
|
+
export { tableFor } from './query';
|
|
44
48
|
export type {
|
|
45
49
|
ColumnDescription,
|
|
46
50
|
EntityDescription,
|
|
@@ -55,8 +59,10 @@ export {
|
|
|
55
59
|
registerEntity,
|
|
56
60
|
} from './registry';
|
|
57
61
|
export type { FindManyArgs, Page, Repo, RepoOptions, Transactor, Tx } from './repo';
|
|
58
|
-
export {
|
|
59
|
-
export type {
|
|
62
|
+
export { memoryRepo, memoryTransactor } from './repo';
|
|
63
|
+
export type { Seed, SeedContext, SeedOptions } from './seed';
|
|
64
|
+
export { defineSeed, seedId } from './seed';
|
|
65
|
+
export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
|
|
60
66
|
export {
|
|
61
67
|
assertScoped,
|
|
62
68
|
describePlan,
|
|
@@ -65,15 +71,25 @@ export {
|
|
|
65
71
|
isOrgScoped,
|
|
66
72
|
ORG_COLUMN,
|
|
67
73
|
orgScoped,
|
|
74
|
+
tenantColumnOf,
|
|
68
75
|
} from './tenancy';
|
|
69
76
|
export type {
|
|
70
|
-
|
|
77
|
+
AnyColumn,
|
|
78
|
+
Column,
|
|
71
79
|
ColumnDefault,
|
|
72
80
|
ColumnKind,
|
|
73
81
|
ColumnMap,
|
|
82
|
+
ColumnMeta,
|
|
74
83
|
IndexDef,
|
|
75
|
-
|
|
84
|
+
Insertable,
|
|
85
|
+
MoneyInput,
|
|
86
|
+
MoneyValue,
|
|
87
|
+
OnDelete,
|
|
88
|
+
ReferenceOptions,
|
|
76
89
|
RowOf,
|
|
77
|
-
|
|
90
|
+
TimestampColumn,
|
|
91
|
+
TypeOf,
|
|
92
|
+
UuidColumn,
|
|
78
93
|
} from './types';
|
|
79
|
-
|
|
94
|
+
// `viewFor` stays internal: a view is reached through the entity, as `posts.$view([...])`.
|
|
95
|
+
export type { EntityView } from './view';
|
package/src/invariants.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
// A domain invariant is written once and enforced twice: in the app on every write,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// A domain invariant is written once and enforced twice: in the app on every write, and in
|
|
2
|
+
// Postgres as a CHECK or a unique index emitted into the migration. The database can therefore
|
|
3
|
+
// never disagree with the code — a bulk import, a psql session or a second service all hit the
|
|
4
|
+
// same rule.
|
|
5
|
+
//
|
|
6
|
+
// `invariant()` is unbound on purpose: it is written next to the columns, and `entity()` binds
|
|
7
|
+
// it to their physical names. That is what keeps a physical name from being typed twice.
|
|
8
|
+
|
|
5
9
|
import { invariantViolated } from './errors';
|
|
10
|
+
import type { Expr, InvariantColumns, Resolve, Row } from './expr';
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
/** `assert` is a rule only the app can run — a JS predicate with no SQL translation. */
|
|
13
|
+
export type InvariantKind = 'check' | 'unique' | 'assert';
|
|
8
14
|
|
|
9
15
|
export interface Invariant<T> {
|
|
10
16
|
/** Becomes the constraint name: `<table>_<name>_check`. Keep it snake_case. */
|
|
@@ -12,64 +18,60 @@ export interface Invariant<T> {
|
|
|
12
18
|
readonly kind: InvariantKind;
|
|
13
19
|
/** Safe to log and useful to an agent: says what was expected, not what leaked. */
|
|
14
20
|
readonly message: string;
|
|
15
|
-
/** SQL predicate for `check`,
|
|
16
|
-
readonly sql: string;
|
|
21
|
+
/** SQL predicate for `check`, the column list for `unique`, `null` for `assert`. */
|
|
22
|
+
readonly sql: string | null;
|
|
23
|
+
/** Physical column names the rule reads. */
|
|
17
24
|
readonly columns: readonly string[];
|
|
18
25
|
/** Partial-constraint predicate, e.g. `deleted_at is null`. */
|
|
19
26
|
readonly where?: string;
|
|
20
27
|
readonly holds: (row: T) => boolean;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
readonly
|
|
27
|
-
readonly holds: (row: T) => boolean;
|
|
28
|
-
readonly columns?: readonly string[];
|
|
29
|
-
readonly where?: string;
|
|
30
|
+
/** What `invariant()` returns: a rule that does not yet know its physical column names. */
|
|
31
|
+
export interface InvariantDef {
|
|
32
|
+
readonly name: string;
|
|
33
|
+
readonly build: (columns: InvariantColumns) => Expr;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
/** `invariant('
|
|
33
|
-
export const invariant =
|
|
34
|
-
name,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
sql: init.sql,
|
|
38
|
-
columns: init.columns ?? [],
|
|
39
|
-
...(init.where === undefined ? {} : { where: init.where }),
|
|
40
|
-
holds: init.holds,
|
|
41
|
-
});
|
|
36
|
+
/** `invariant('post_like_count_non_negative', (c) => c.likeCount.atLeast(0))` */
|
|
37
|
+
export const invariant = (
|
|
38
|
+
name: string,
|
|
39
|
+
build: (columns: InvariantColumns) => Expr,
|
|
40
|
+
): InvariantDef => ({ name, build });
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
readonly columns: readonly string[];
|
|
46
|
-
/** In-app duplicate detection needs the store, so the app check defaults to true. */
|
|
47
|
-
readonly holds?: (row: T) => boolean;
|
|
48
|
-
readonly where?: string;
|
|
49
|
-
}
|
|
42
|
+
const asRow = (value: unknown): Row =>
|
|
43
|
+
typeof value === 'object' && value !== null ? (value as Row) : {};
|
|
50
44
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
45
|
+
/** Called by `entity()`: resolves property paths to physical names and freezes the rule. */
|
|
46
|
+
export const bindInvariant = <T>(
|
|
47
|
+
def: InvariantDef,
|
|
48
|
+
columns: InvariantColumns,
|
|
49
|
+
resolve: Resolve,
|
|
50
|
+
partialWhere: string | undefined,
|
|
51
|
+
): Invariant<T> => {
|
|
52
|
+
const expr = def.build(columns);
|
|
53
|
+
const sql = expr.toSql(resolve);
|
|
54
|
+
const kind: InvariantKind = expr.kind === 'unique' ? 'unique' : sql === null ? 'assert' : 'check';
|
|
55
|
+
return {
|
|
56
|
+
name: def.name,
|
|
57
|
+
kind,
|
|
58
|
+
message: expr.message,
|
|
59
|
+
sql,
|
|
60
|
+
columns: expr.paths.map(resolve),
|
|
61
|
+
// A soft-deleted row must not keep a slug reserved, so uniqueness is partial there.
|
|
62
|
+
...(kind === 'unique' && partialWhere !== undefined ? { where: partialWhere } : {}),
|
|
63
|
+
holds: (row) => expr.holds(asRow(row)),
|
|
64
|
+
};
|
|
65
|
+
};
|
|
65
66
|
|
|
66
67
|
export const constraintName = (
|
|
67
68
|
table: string,
|
|
68
69
|
inv: { readonly name: string; readonly kind: InvariantKind },
|
|
69
|
-
): string => `${table}_${inv.name}_${inv.kind === '
|
|
70
|
+
): string => `${table}_${inv.name}_${inv.kind === 'unique' ? 'key' : 'check'}`;
|
|
70
71
|
|
|
71
72
|
/** The DDL the migration emits. One statement, terminated, ready to diff. */
|
|
72
|
-
export const toSql = <T>(table: string, inv: Invariant<T>): string => {
|
|
73
|
+
export const toSql = <T>(table: string, inv: Invariant<T>): string | null => {
|
|
74
|
+
if (inv.sql === null) return null;
|
|
73
75
|
const name = constraintName(table, inv);
|
|
74
76
|
if (inv.kind === 'check') {
|
|
75
77
|
return `ALTER TABLE "${table}" ADD CONSTRAINT "${name}" CHECK (${inv.sql});`;
|
|
@@ -80,7 +82,10 @@ export const toSql = <T>(table: string, inv: Invariant<T>): string => {
|
|
|
80
82
|
};
|
|
81
83
|
|
|
82
84
|
export const invariantsToSql = <T>(table: string, invariants: readonly Invariant<T>[]): string =>
|
|
83
|
-
invariants
|
|
85
|
+
invariants
|
|
86
|
+
.map((inv) => toSql(table, inv))
|
|
87
|
+
.filter((statement): statement is string => statement !== null)
|
|
88
|
+
.join('\n');
|
|
84
89
|
|
|
85
90
|
/** Runs on every write. Reports every violation at once so one round trip fixes all. */
|
|
86
91
|
export const assertInvariants = <T>(
|
|
@@ -89,7 +94,6 @@ export const assertInvariants = <T>(
|
|
|
89
94
|
row: T,
|
|
90
95
|
): void => {
|
|
91
96
|
const failed = invariants.filter((inv) => !inv.holds(row));
|
|
92
|
-
if (failed.length === 0) return;
|
|
93
97
|
const first = failed[0];
|
|
94
98
|
if (first === undefined) return;
|
|
95
99
|
throw invariantViolated(entityName, first.name, failed.map((inv) => inv.message).join('; '));
|
package/src/pg-driver.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The production driver. Same `Repo` contract as `memoryRepo`, same plans, same cursor — the
|
|
2
|
+
// only difference is where the rows are, which is the point: a test that passes against memory
|
|
3
|
+
// means something about Postgres.
|
|
4
|
+
//
|
|
5
|
+
// It never takes a connection as an argument. `db()` from `@ultimat3/db` returns the open
|
|
6
|
+
// transaction when there is one, so a repository call inside `withTransaction` joins it without
|
|
7
|
+
// being told — which is how `ctx.jobs.enqueue()` lands its outbox row atomically with the write
|
|
8
|
+
// that caused it. `RepoOptions.tx` is the in-memory driver's undo hook and is ignored here.
|
|
9
|
+
|
|
10
|
+
import { type DbClient, db, type TransactionOptions, withTransaction } from '@ultimat3/db';
|
|
11
|
+
import { snake } from './column';
|
|
12
|
+
import { cursorFor, seekFrom, valueAt } from './cursor';
|
|
13
|
+
import type { Driver } from './database';
|
|
14
|
+
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
15
|
+
import { notFound } from './errors';
|
|
16
|
+
import { bindValues, decodeRow, type PhysicalRow } from './pg-row';
|
|
17
|
+
import {
|
|
18
|
+
countStatement,
|
|
19
|
+
deleteStatement,
|
|
20
|
+
insertStatement,
|
|
21
|
+
type ReadShape,
|
|
22
|
+
selectStatement,
|
|
23
|
+
updateStatement,
|
|
24
|
+
} from './pg-sql';
|
|
25
|
+
import { idPlan, readPlan } from './plan';
|
|
26
|
+
import type { FindManyArgs, Repo, Transactor } from './repo';
|
|
27
|
+
import type { QueryPlan } from './tenancy';
|
|
28
|
+
|
|
29
|
+
export interface PostgresDriverOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Pin a client. Left out, every call resolves `db()` — the ambient pool, or the open
|
|
32
|
+
* transaction when one is in scope. Tests pass `createRecordingClient()` here or install one
|
|
33
|
+
* globally with `setDbClient()`.
|
|
34
|
+
*/
|
|
35
|
+
readonly client?: DbClient | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const shapeOf = (args: FindManyArgs, seek?: readonly unknown[]): ReadShape => ({
|
|
39
|
+
includeDeleted: args.includeDeleted === true,
|
|
40
|
+
...(seek === undefined ? {} : { seek }),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const postgresRepo = <Row>(
|
|
44
|
+
entity: EntityCore<Row>,
|
|
45
|
+
config: PostgresDriverOptions = {},
|
|
46
|
+
): Repo<Row> => {
|
|
47
|
+
const client = (): DbClient => config.client ?? db();
|
|
48
|
+
const idOf = (row: Row): string =>
|
|
49
|
+
entity.$primaryKey.map((property) => String(valueAt(row, property))).join('');
|
|
50
|
+
|
|
51
|
+
const one = async (plan: QueryPlan, args: FindManyArgs): Promise<Row | null> => {
|
|
52
|
+
const [found] = await client().query<PhysicalRow>(
|
|
53
|
+
selectStatement(entity, plan, shapeOf(args), 1),
|
|
54
|
+
);
|
|
55
|
+
return found === undefined ? null : decodeRow(entity, found);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
async findById(id, options) {
|
|
60
|
+
const plan = idPlan(entity, id, options, 'findById');
|
|
61
|
+
return one(plan, options ?? {});
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async findMany(args = {}) {
|
|
65
|
+
const plan = readPlan(entity, args, 'findMany');
|
|
66
|
+
// One row past the page: the presence of that row is what says there is a next cursor,
|
|
67
|
+
// and it costs one row instead of a second `count(*)` over the same predicate.
|
|
68
|
+
const found = await client().query<PhysicalRow>(
|
|
69
|
+
selectStatement(entity, plan, shapeOf(args, seekFrom(entity, plan)), plan.limit + 1),
|
|
70
|
+
);
|
|
71
|
+
const rows = found.slice(0, plan.limit).map((row) => decodeRow(entity, row));
|
|
72
|
+
const last = rows.at(-1);
|
|
73
|
+
return {
|
|
74
|
+
rows,
|
|
75
|
+
nextCursor:
|
|
76
|
+
found.length > plan.limit && last !== undefined
|
|
77
|
+
? cursorFor(entity, plan, last, idOf(last))
|
|
78
|
+
: null,
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async insert(values) {
|
|
83
|
+
entity.$assert(values);
|
|
84
|
+
const written = await client().one<PhysicalRow>(
|
|
85
|
+
insertStatement(entity, bindValues(entity, values)),
|
|
86
|
+
);
|
|
87
|
+
// `returning *` is the row Postgres actually stored, defaults included.
|
|
88
|
+
return written === null ? values : decodeRow(entity, written);
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
async update(id, patch, options) {
|
|
92
|
+
const plan = idPlan(entity, id, options, 'update');
|
|
93
|
+
const values = bindValues(entity, patch);
|
|
94
|
+
if (values.size === 0) {
|
|
95
|
+
const current = await one(plan, options ?? {});
|
|
96
|
+
if (current === null) throw notFound(entity.$name, id);
|
|
97
|
+
return current;
|
|
98
|
+
}
|
|
99
|
+
const written = await client().one<PhysicalRow>(
|
|
100
|
+
updateStatement(entity, plan, values, shapeOf(options ?? {})),
|
|
101
|
+
);
|
|
102
|
+
if (written === null) throw notFound(entity.$name, id);
|
|
103
|
+
const after = decodeRow(entity, written);
|
|
104
|
+
// SQL-expressible invariants are CHECK constraints, so Postgres already rejected the
|
|
105
|
+
// statement. A JS-only one (`kind: 'assert'`, `sql: null`) can only be judged on the
|
|
106
|
+
// result — inside `withTransaction` the throw takes the row with it.
|
|
107
|
+
entity.$assert(after);
|
|
108
|
+
return after;
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async delete(id, options) {
|
|
112
|
+
const plan = idPlan(entity, id, options, 'delete');
|
|
113
|
+
// Soft delete hides the row without losing it; the column's presence is the switch.
|
|
114
|
+
const statement = entity.$softDelete
|
|
115
|
+
? updateStatement(
|
|
116
|
+
entity,
|
|
117
|
+
plan,
|
|
118
|
+
new Map([[snake(SOFT_DELETE_COLUMN), new Date()]]),
|
|
119
|
+
shapeOf({}),
|
|
120
|
+
)
|
|
121
|
+
: deleteStatement(entity, plan);
|
|
122
|
+
if ((await client().execute(statement)) === 0) throw notFound(entity.$name, id);
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async count(args = {}) {
|
|
126
|
+
const plan = readPlan(entity, args, 'count');
|
|
127
|
+
const row = await client().one<{ count: unknown }>(
|
|
128
|
+
countStatement(entity, plan, shapeOf(args)),
|
|
129
|
+
);
|
|
130
|
+
return Number(row?.count ?? 0);
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `database(entities, { driver: postgresDriver() })` — the one line that moves an app off the
|
|
137
|
+
* in-memory default. Repos are stateless, so there is nothing to memoise.
|
|
138
|
+
*/
|
|
139
|
+
export const postgresDriver = (config: PostgresDriverOptions = {}): Driver => ({
|
|
140
|
+
repo: <Row>(entity: EntityCore<Row>) => postgresRepo(entity, config),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A real Postgres transaction behind the same `Transactor` the in-memory one implements. The
|
|
145
|
+
* `Tx` handed to the callback is a token: repositories find the transaction through `db()`, so
|
|
146
|
+
* nothing has to thread a connection through the call stack.
|
|
147
|
+
*/
|
|
148
|
+
export const postgresTransactor = (options: TransactionOptions = {}): Transactor => ({
|
|
149
|
+
run: (work) =>
|
|
150
|
+
withTransaction(
|
|
151
|
+
(tx) => work({ id: tx.id, onRollback: (undo: () => void) => tx.onRollback(undo) }),
|
|
152
|
+
options,
|
|
153
|
+
),
|
|
154
|
+
});
|