@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
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
import { invariantViolated } from './errors';
|
|
9
|
+
/** `invariant('post_like_count_non_negative', (c) => c.likeCount.atLeast(0))` */
|
|
10
|
+
export const invariant = (name, build) => ({ name, build });
|
|
11
|
+
const asRow = (value) => typeof value === 'object' && value !== null ? value : {};
|
|
12
|
+
/** Called by `entity()`: resolves property paths to physical names and freezes the rule. */
|
|
13
|
+
export const bindInvariant = (def, columns, resolve, partialWhere) => {
|
|
14
|
+
const expr = def.build(columns);
|
|
15
|
+
const sql = expr.toSql(resolve);
|
|
16
|
+
const kind = expr.kind === 'unique' ? 'unique' : sql === null ? 'assert' : 'check';
|
|
17
|
+
return {
|
|
18
|
+
name: def.name,
|
|
19
|
+
kind,
|
|
20
|
+
message: expr.message,
|
|
21
|
+
sql,
|
|
22
|
+
columns: expr.paths.map(resolve),
|
|
23
|
+
// A soft-deleted row must not keep a slug reserved, so uniqueness is partial there.
|
|
24
|
+
...(kind === 'unique' && partialWhere !== undefined ? { where: partialWhere } : {}),
|
|
25
|
+
holds: (row) => expr.holds(asRow(row)),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
export const constraintName = (table, inv) => `${table}_${inv.name}_${inv.kind === 'unique' ? 'key' : 'check'}`;
|
|
29
|
+
/** The DDL the migration emits. One statement, terminated, ready to diff. */
|
|
30
|
+
export const toSql = (table, inv) => {
|
|
31
|
+
if (inv.sql === null)
|
|
32
|
+
return null;
|
|
33
|
+
const name = constraintName(table, inv);
|
|
34
|
+
if (inv.kind === 'check') {
|
|
35
|
+
return `ALTER TABLE "${table}" ADD CONSTRAINT "${name}" CHECK (${inv.sql});`;
|
|
36
|
+
}
|
|
37
|
+
const where = inv.where === undefined ? '' : ` WHERE ${inv.where}`;
|
|
38
|
+
const columns = inv.columns.map((column) => `"${column}"`).join(', ');
|
|
39
|
+
return `CREATE UNIQUE INDEX "${name}" ON "${table}" (${columns})${where};`;
|
|
40
|
+
};
|
|
41
|
+
export const invariantsToSql = (table, invariants) => invariants
|
|
42
|
+
.map((inv) => toSql(table, inv))
|
|
43
|
+
.filter((statement) => statement !== null)
|
|
44
|
+
.join('\n');
|
|
45
|
+
/** Runs on every write. Reports every violation at once so one round trip fixes all. */
|
|
46
|
+
export const assertInvariants = (entityName, invariants, row) => {
|
|
47
|
+
const failed = invariants.filter((inv) => !inv.holds(row));
|
|
48
|
+
const first = failed[0];
|
|
49
|
+
if (first === undefined)
|
|
50
|
+
return;
|
|
51
|
+
throw invariantViolated(entityName, first.name, failed.map((inv) => inv.message).join('; '));
|
|
52
|
+
};
|
|
53
|
+
//# sourceMappingURL=invariants.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invariants.js","sourceRoot":"","sources":["invariants.ts"],"names":[],"mappings":"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"}
|
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
|
+
});
|
package/src/pg-row.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Single responsibility: the two-way map between a physical Postgres row and an entity row.
|
|
2
|
+
// Three things are not one-to-one and all three live here: the property key is camelCase while
|
|
3
|
+
// the column is snake_case, money is one property over two columns, and a value that came back
|
|
4
|
+
// from the driver is re-parsed by the column that declared it rather than trusted — int8 arrives
|
|
5
|
+
// as a string, timestamptz may arrive as one, and a silent `NaN` is worse than a loud throw.
|
|
6
|
+
|
|
7
|
+
import { snake } from './column';
|
|
8
|
+
import type { EntityCore } from './entity';
|
|
9
|
+
import { invariantViolated } from './errors';
|
|
10
|
+
import type { AnyColumn, MoneyValue } from './types';
|
|
11
|
+
|
|
12
|
+
export type PhysicalRow = Readonly<Record<string, unknown>>;
|
|
13
|
+
|
|
14
|
+
const MONEY_PARTS = new Set(['minor', 'currency']);
|
|
15
|
+
|
|
16
|
+
/** `price` -> `price_minor`, `price_currency`. Everything else is one snake_case column. */
|
|
17
|
+
export const columnsOf = (property: string, column: AnyColumn): readonly string[] =>
|
|
18
|
+
column.$meta.kind === 'money'
|
|
19
|
+
? [`${snake(property)}_minor`, `${snake(property)}_currency`]
|
|
20
|
+
: [snake(property)];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A predicate or sort key names a property, never a physical column — so `orgId` becomes
|
|
24
|
+
* `org_id` in exactly one place, and a name the entity never declared cannot reach the SQL.
|
|
25
|
+
*/
|
|
26
|
+
export const physicalName = <Row>(entity: EntityCore<Row>, path: string): string => {
|
|
27
|
+
const [property = path, part] = path.split('.');
|
|
28
|
+
const column = entity.$columns[property];
|
|
29
|
+
if (column === undefined) {
|
|
30
|
+
throw invariantViolated(
|
|
31
|
+
entity.$name,
|
|
32
|
+
'column',
|
|
33
|
+
`no column "${path}" — pick from: ${Object.keys(entity.$columns).join(', ')}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const isMoney = column.$meta.kind === 'money';
|
|
37
|
+
if (part === undefined) {
|
|
38
|
+
if (!isMoney) return snake(property);
|
|
39
|
+
throw invariantViolated(
|
|
40
|
+
entity.$name,
|
|
41
|
+
property,
|
|
42
|
+
`${property} is money: name ${property}.minor or ${property}.currency`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (!isMoney || !MONEY_PARTS.has(part)) {
|
|
46
|
+
throw invariantViolated(entity.$name, property, `${property} has no part "${part}"`);
|
|
47
|
+
}
|
|
48
|
+
return `${snake(property)}_${part}`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Every physical column of the entity, in declaration order. */
|
|
52
|
+
export const allColumns = <Row>(entity: EntityCore<Row>): readonly string[] =>
|
|
53
|
+
Object.entries(entity.$columns).flatMap(([property, column]) => columnsOf(property, column));
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Row (or patch) -> the columns to write. Absent properties are skipped rather than nulled,
|
|
57
|
+
* which is what makes the same function serve `insert` and a partial `update`.
|
|
58
|
+
*/
|
|
59
|
+
export const bindValues = <Row>(
|
|
60
|
+
entity: EntityCore<Row>,
|
|
61
|
+
values: Partial<Row>,
|
|
62
|
+
): ReadonlyMap<string, unknown> => {
|
|
63
|
+
const bound = new Map<string, unknown>();
|
|
64
|
+
const record = values as Readonly<Record<string, unknown>>;
|
|
65
|
+
for (const [property, column] of Object.entries(entity.$columns)) {
|
|
66
|
+
if (!Object.hasOwn(record, property)) continue;
|
|
67
|
+
const value = record[property];
|
|
68
|
+
if (column.$meta.kind !== 'money') {
|
|
69
|
+
bound.set(snake(property), value ?? null);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const money = value as MoneyValue | null | undefined;
|
|
73
|
+
bound.set(`${snake(property)}_minor`, money?.minor ?? null);
|
|
74
|
+
bound.set(`${snake(property)}_currency`, money?.currency ?? null);
|
|
75
|
+
}
|
|
76
|
+
return bound;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const moneyOf = (source: PhysicalRow, minor: string, currency: string): unknown => {
|
|
80
|
+
const amount = source[minor];
|
|
81
|
+
if (amount === null || amount === undefined) return null;
|
|
82
|
+
return { minor: amount, currency: String(source[currency] ?? '').trim() };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Physical row -> entity row. A column the projection left out is left out here too, so a
|
|
87
|
+
* `select` narrows the object as well as the statement.
|
|
88
|
+
*/
|
|
89
|
+
export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Row => {
|
|
90
|
+
const row: Record<string, unknown> = {};
|
|
91
|
+
for (const [property, column] of Object.entries(entity.$columns)) {
|
|
92
|
+
const [head, tail] = columnsOf(property, column);
|
|
93
|
+
if (head === undefined || !(head in source)) continue;
|
|
94
|
+
const value = tail === undefined ? source[head] : moneyOf(source, head, tail);
|
|
95
|
+
if (value !== null && value !== undefined) {
|
|
96
|
+
row[property] = column.$parse(value);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (column.$meta.notNull) {
|
|
100
|
+
throw invariantViolated(
|
|
101
|
+
entity.$name,
|
|
102
|
+
property,
|
|
103
|
+
'the database returned null for a not-null column — the table no longer matches the entity',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
row[property] = null;
|
|
107
|
+
}
|
|
108
|
+
// Every property present was validated by the column that declared it, so this is the row.
|
|
109
|
+
return row as Row;
|
|
110
|
+
};
|
package/src/pg-sql.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Single responsibility: compile a `QueryPlan` into parameterised SQL. Nothing here builds a
|
|
2
|
+
// string from a value — `sql` binds every scalar to `$n` and refuses anything else — and every
|
|
3
|
+
// identifier is resolved through the entity, so a column name can only ever be one the entity
|
|
4
|
+
// declared. That is the whole reason this file exists instead of a template literal per method.
|
|
5
|
+
|
|
6
|
+
import { identifier, join, raw, type SqlFragment, sql } from '@ultimat3/db';
|
|
7
|
+
import { snake } from './column';
|
|
8
|
+
import type { EntityCore } from './entity';
|
|
9
|
+
import { SOFT_DELETE_COLUMN } from './entity';
|
|
10
|
+
import { allColumns, columnsOf, physicalName } from './pg-row';
|
|
11
|
+
import type { Predicate, QueryPlan, SortKey } from './tenancy';
|
|
12
|
+
|
|
13
|
+
/** Nothing matches. `in ()` is a syntax error in Postgres, so an empty set needs a constant. */
|
|
14
|
+
const NEVER = sql`1 = 0`;
|
|
15
|
+
|
|
16
|
+
export interface ReadShape {
|
|
17
|
+
/** Soft-deleted rows are hidden unless the caller asked for them. */
|
|
18
|
+
readonly includeDeleted: boolean;
|
|
19
|
+
/** The keyset position, already revived to typed values. */
|
|
20
|
+
readonly seek?: readonly unknown[] | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const columnRef = <Row>(entity: EntityCore<Row>, path: string): SqlFragment =>
|
|
24
|
+
identifier(physicalName(entity, path));
|
|
25
|
+
|
|
26
|
+
const predicateSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFragment => {
|
|
27
|
+
const column = columnRef(entity, predicate.column);
|
|
28
|
+
const value = predicate.value;
|
|
29
|
+
switch (predicate.op) {
|
|
30
|
+
case 'eq':
|
|
31
|
+
return value === null ? sql`${column} is null` : sql`${column} = ${value}`;
|
|
32
|
+
case 'neq':
|
|
33
|
+
// `is distinct from` so a null on either side compares as a value, not as unknown.
|
|
34
|
+
return sql`${column} is distinct from ${value}`;
|
|
35
|
+
case 'in': {
|
|
36
|
+
const values = Array.isArray(value) ? value : [value];
|
|
37
|
+
return values.length === 0
|
|
38
|
+
? NEVER
|
|
39
|
+
: sql`${column} in (${join(values.map((each) => sql`${each}`))})`;
|
|
40
|
+
}
|
|
41
|
+
case 'gt':
|
|
42
|
+
return sql`${column} > ${value}`;
|
|
43
|
+
case 'gte':
|
|
44
|
+
return sql`${column} >= ${value}`;
|
|
45
|
+
case 'lt':
|
|
46
|
+
return sql`${column} < ${value}`;
|
|
47
|
+
case 'lte':
|
|
48
|
+
return sql`${column} <= ${value}`;
|
|
49
|
+
case 'like':
|
|
50
|
+
return sql`${column} like ${value}`;
|
|
51
|
+
case 'is-null':
|
|
52
|
+
return sql`${column} is null`;
|
|
53
|
+
case 'is-not-null':
|
|
54
|
+
return sql`${column} is not null`;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The keyset seek, spelled out rather than as a row comparison: `(a, b) > (x, y)` requires every
|
|
60
|
+
* key to sort the same way, and a listing that is `published_at desc, id asc` does not.
|
|
61
|
+
*/
|
|
62
|
+
const seekSql = <Row>(
|
|
63
|
+
entity: EntityCore<Row>,
|
|
64
|
+
orderBy: readonly SortKey[],
|
|
65
|
+
seek: readonly unknown[],
|
|
66
|
+
): SqlFragment => {
|
|
67
|
+
const terms = orderBy.map((entry, index) => {
|
|
68
|
+
const equal = orderBy
|
|
69
|
+
.slice(0, index)
|
|
70
|
+
.map((earlier, position) => sql`${columnRef(entity, earlier.column)} = ${seek[position]}`);
|
|
71
|
+
const after = raw(entry.direction === 'desc' ? '<' : '>');
|
|
72
|
+
return sql`(${join(
|
|
73
|
+
[...equal, sql`${columnRef(entity, entry.column)} ${after} ${seek[index]}`],
|
|
74
|
+
' and ',
|
|
75
|
+
)})`;
|
|
76
|
+
});
|
|
77
|
+
return sql`(${join(terms, ' or ')})`;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const conditions = <Row>(
|
|
81
|
+
entity: EntityCore<Row>,
|
|
82
|
+
plan: QueryPlan,
|
|
83
|
+
shape: ReadShape,
|
|
84
|
+
): SqlFragment => {
|
|
85
|
+
const parts = plan.where.map((predicate) => predicateSql(entity, predicate));
|
|
86
|
+
if (entity.$softDelete && !shape.includeDeleted) {
|
|
87
|
+
parts.push(sql`${identifier(snake(SOFT_DELETE_COLUMN))} is null`);
|
|
88
|
+
}
|
|
89
|
+
if (shape.seek !== undefined) parts.push(seekSql(entity, plan.orderBy, shape.seek));
|
|
90
|
+
return parts.length === 0 ? sql`true` : join(parts, ' and ');
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const orderSql = <Row>(entity: EntityCore<Row>, orderBy: readonly SortKey[]): SqlFragment =>
|
|
94
|
+
join(
|
|
95
|
+
orderBy.map(
|
|
96
|
+
(entry) =>
|
|
97
|
+
sql`${columnRef(entity, entry.column)} ${raw(entry.direction === 'desc' ? 'desc' : 'asc')}`,
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A projection always carries the primary key and the sort keys even when the caller did not
|
|
103
|
+
* ask for them: without those values the page cannot produce the cursor that continues it.
|
|
104
|
+
*/
|
|
105
|
+
const projection = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment => {
|
|
106
|
+
if (plan.select === undefined) return join(allColumns(entity).map(identifier));
|
|
107
|
+
const wanted = new Set([
|
|
108
|
+
...plan.select,
|
|
109
|
+
...entity.$primaryKey,
|
|
110
|
+
...plan.orderBy.map((entry) => entry.column.split('.')[0] ?? entry.column),
|
|
111
|
+
]);
|
|
112
|
+
const names = [...wanted].flatMap((property) => {
|
|
113
|
+
const column = entity.$columns[property];
|
|
114
|
+
return column === undefined ? [] : columnsOf(property, column);
|
|
115
|
+
});
|
|
116
|
+
return join(names.map(identifier));
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const selectStatement = <Row>(
|
|
120
|
+
entity: EntityCore<Row>,
|
|
121
|
+
plan: QueryPlan,
|
|
122
|
+
shape: ReadShape,
|
|
123
|
+
limit: number,
|
|
124
|
+
): SqlFragment =>
|
|
125
|
+
sql`select ${projection(entity, plan)} from ${identifier(entity.$table)} where ${conditions(
|
|
126
|
+
entity,
|
|
127
|
+
plan,
|
|
128
|
+
shape,
|
|
129
|
+
)} order by ${orderSql(entity, plan.orderBy)} limit ${limit}`;
|
|
130
|
+
|
|
131
|
+
export const countStatement = <Row>(
|
|
132
|
+
entity: EntityCore<Row>,
|
|
133
|
+
plan: QueryPlan,
|
|
134
|
+
shape: ReadShape,
|
|
135
|
+
): SqlFragment =>
|
|
136
|
+
sql`select count(*) as count from ${identifier(entity.$table)} where ${conditions(entity, plan, shape)}`;
|
|
137
|
+
|
|
138
|
+
export const insertStatement = <Row>(
|
|
139
|
+
entity: EntityCore<Row>,
|
|
140
|
+
values: ReadonlyMap<string, unknown>,
|
|
141
|
+
): SqlFragment => {
|
|
142
|
+
const entries = [...values];
|
|
143
|
+
return sql`insert into ${identifier(entity.$table)} (${join(
|
|
144
|
+
entries.map(([column]) => identifier(column)),
|
|
145
|
+
)}) values (${join(entries.map(([, value]) => sql`${value}`))}) returning *`;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const updateStatement = <Row>(
|
|
149
|
+
entity: EntityCore<Row>,
|
|
150
|
+
plan: QueryPlan,
|
|
151
|
+
values: ReadonlyMap<string, unknown>,
|
|
152
|
+
shape: ReadShape,
|
|
153
|
+
): SqlFragment =>
|
|
154
|
+
sql`update ${identifier(entity.$table)} set ${join(
|
|
155
|
+
[...values].map(([column, value]) => sql`${identifier(column)} = ${value}`),
|
|
156
|
+
)} where ${conditions(entity, plan, shape)} returning *`;
|
|
157
|
+
|
|
158
|
+
/** Only reached when the entity has no soft-delete column, so there is no filter to apply. */
|
|
159
|
+
export const deleteStatement = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment =>
|
|
160
|
+
sql`delete from ${identifier(entity.$table)} where ${conditions(entity, plan, {
|
|
161
|
+
includeDeleted: true,
|
|
162
|
+
})}`;
|