@ultimat3/db 1.2.0 → 3.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/CLAUDE.md +618 -0
- package/README.md +159 -21
- package/package.json +3 -2
- package/src/attribution.ts +45 -0
- package/src/branch.ts +21 -5
- package/src/client.ts +259 -31
- package/src/destructive.ts +126 -0
- package/src/drift.ts +263 -14
- package/src/errors.ts +256 -13
- package/src/expected-loop.ts +53 -0
- package/src/fake-pglite.ts +32 -0
- package/src/fake-reservable.ts +50 -0
- package/src/fake.ts +16 -2
- package/src/foreign-key.ts +41 -0
- package/src/generate.ts +192 -39
- package/src/index.ts +38 -11
- package/src/introspect.ts +34 -8
- package/src/libpq-options.ts +76 -0
- package/src/migrate.ts +283 -64
- package/src/observe.ts +90 -0
- package/src/pglite-branch.ts +2 -1
- package/src/pglite-turns.ts +13 -10
- package/src/pglite.ts +85 -15
- package/src/readonly-query.ts +20 -8
- package/src/snapshot-json.ts +84 -0
- package/src/snapshot-parse.ts +99 -0
- package/src/sql-noise.ts +40 -0
- package/src/sql-scan.ts +159 -0
- package/src/sqlstate.ts +107 -0
- package/src/statement-shape.ts +58 -0
- package/src/statement-span.ts +40 -0
- package/src/statement-split.ts +51 -0
- package/src/transaction.ts +138 -16
- package/src/type-pins.ts +29 -0
- package/src/readonly.ts +0 -111
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// `@ultimat3/auth`, `@ultimat3/entity`, `@ultimat3/jobs` and the CLI are written against this
|
|
3
3
|
// list, so anything not here is an implementation detail and may change.
|
|
4
4
|
|
|
5
|
+
export { statementAttribution, withStatementAttribution } from './attribution';
|
|
5
6
|
export type { BranchInfo, BranchOptions, DropBranchOptions, ReapOptions } from './branch';
|
|
6
7
|
export {
|
|
7
8
|
assertBranchName,
|
|
@@ -26,17 +27,29 @@ export {
|
|
|
26
27
|
createPostgresClient,
|
|
27
28
|
db,
|
|
28
29
|
isReservable,
|
|
30
|
+
POOL_MAX_ENV,
|
|
29
31
|
POOL_PROFILES,
|
|
30
32
|
poolProfileFor,
|
|
31
33
|
setDbClient,
|
|
32
34
|
} from './client';
|
|
35
|
+
export type { DestructiveKind, DestructiveStatement } from './destructive';
|
|
36
|
+
export {
|
|
37
|
+
DESTRUCTIVE_CAUSE,
|
|
38
|
+
DESTRUCTIVE_MARKER,
|
|
39
|
+
destructiveStatements,
|
|
40
|
+
hasDestructiveMarker,
|
|
41
|
+
isDestructive,
|
|
42
|
+
} from './destructive';
|
|
33
43
|
export type { DriftDifference, DriftKind, DriftOptions, DriftReport } from './drift';
|
|
34
44
|
export {
|
|
45
|
+
appTables,
|
|
35
46
|
assertNoDrift,
|
|
36
47
|
checkDrift,
|
|
48
|
+
declaredSchema,
|
|
37
49
|
diffSchema,
|
|
38
50
|
driftError,
|
|
39
51
|
expectedSchema,
|
|
52
|
+
FRAMEWORK_TABLE_PREFIX,
|
|
40
53
|
} from './drift';
|
|
41
54
|
export type { DbErrorCode, DbErrorInit } from './errors';
|
|
42
55
|
export {
|
|
@@ -48,12 +61,21 @@ export {
|
|
|
48
61
|
dbDrift,
|
|
49
62
|
dbNotImplemented,
|
|
50
63
|
dbUnavailable,
|
|
64
|
+
driverError,
|
|
51
65
|
identifierUnsafe,
|
|
66
|
+
migrateConcurrent,
|
|
52
67
|
migrationConflict,
|
|
68
|
+
migrationDestructive,
|
|
53
69
|
migrationIrreversible,
|
|
54
|
-
|
|
70
|
+
migrationSnapshotMissing,
|
|
71
|
+
multipleStatements,
|
|
72
|
+
poolAcquireTimeout,
|
|
73
|
+
poolMaxInvalid,
|
|
74
|
+
rollbackStepsInvalid,
|
|
75
|
+
serializationExhausted,
|
|
55
76
|
sqlUnsafe,
|
|
56
77
|
} from './errors';
|
|
78
|
+
export { expectedQueryLoop, expectedQueryLoopReason } from './expected-loop';
|
|
57
79
|
export type { RecordedStatement, RecordingClient, StubResponse } from './fake';
|
|
58
80
|
export { createRecordingClient } from './fake';
|
|
59
81
|
export type {
|
|
@@ -61,15 +83,9 @@ export type {
|
|
|
61
83
|
EntityDescriptionLike,
|
|
62
84
|
GeneratedMigration,
|
|
63
85
|
GenerateOptions,
|
|
64
|
-
|
|
65
|
-
} from './generate';
|
|
66
|
-
export {
|
|
67
|
-
generateMigration,
|
|
68
|
-
migrationStamp,
|
|
69
|
-
parseIndexName,
|
|
70
|
-
slugify,
|
|
71
|
-
snapshotOf,
|
|
86
|
+
IndexDescriptionLike,
|
|
72
87
|
} from './generate';
|
|
88
|
+
export { generateMigration, migrationStamp, slugify, snapshotOf } from './generate';
|
|
73
89
|
export type {
|
|
74
90
|
ColumnDescription,
|
|
75
91
|
ForeignKeyDescription,
|
|
@@ -91,8 +107,11 @@ export {
|
|
|
91
107
|
auditLedger,
|
|
92
108
|
checksumOf,
|
|
93
109
|
ensureLedger,
|
|
110
|
+
isLedgerMissing,
|
|
94
111
|
LEDGER_TABLE,
|
|
95
112
|
MIGRATION_LOCK_KEY,
|
|
113
|
+
MIGRATION_LOCK_POLL_MS,
|
|
114
|
+
MIGRATION_LOCK_WAIT_MS,
|
|
96
115
|
migrate,
|
|
97
116
|
migrationChecksum,
|
|
98
117
|
pendingMigrations,
|
|
@@ -100,6 +119,8 @@ export {
|
|
|
100
119
|
rollback,
|
|
101
120
|
runningAppVersion,
|
|
102
121
|
} from './migrate';
|
|
122
|
+
export type { StatementAttribution, StatementEvent, StatementObserver } from './observe';
|
|
123
|
+
export { setStatementObserver, statementObserver } from './observe';
|
|
103
124
|
export type {
|
|
104
125
|
PgliteClient,
|
|
105
126
|
PgliteDriver,
|
|
@@ -117,13 +138,19 @@ export {
|
|
|
117
138
|
} from './pglite';
|
|
118
139
|
export type { PgliteBranchInfo, PgliteBranchOptions } from './pglite-branch';
|
|
119
140
|
export { branchPglite, pgliteBranchDir } from './pglite-branch';
|
|
120
|
-
export type { MutationVerdict, ReadOnlyOptions } from './readonly';
|
|
121
|
-
export { assertReadOnly, inspectStatement, readOnly, stripSqlNoise } from './readonly';
|
|
122
141
|
export type { ReadOnlyQueryOptions, ReadOnlyQueryResult } from './readonly-query';
|
|
123
142
|
export { READONLY_TIMEOUT_MS, readOnlyQuery } from './readonly-query';
|
|
124
143
|
export type { ReadOnlyRoleOptions } from './readonly-role';
|
|
125
144
|
export { ensureReadOnlyRole, grantReadOnlySql, READONLY_ROLE } from './readonly-role';
|
|
145
|
+
export { snapshotJson } from './snapshot-json';
|
|
146
|
+
export { parseSnapshot } from './snapshot-parse';
|
|
126
147
|
export type { SqlFragment } from './sql';
|
|
127
148
|
export { identifier, isSqlFragment, join, literal, raw, sql } from './sql';
|
|
149
|
+
export { stripSqlNoise } from './sql-noise';
|
|
150
|
+
export type { DbSqlStateCode } from './sqlstate';
|
|
151
|
+
export { DB_SQLSTATE_CODES, isRetryableState, SQLSTATE, sqlState, sqlStateCode } from './sqlstate';
|
|
152
|
+
export { statementFingerprint, statementKind, statementVerb } from './statement-shape';
|
|
153
|
+
export { STATEMENT_ATTRIBUTE } from './statement-span';
|
|
154
|
+
export { statementsOf } from './statement-split';
|
|
128
155
|
export type { DbTx, IsolationLevel, TransactionOptions } from './transaction';
|
|
129
156
|
export { beginStatement, currentTx, withTransaction } from './transaction';
|
package/src/introspect.ts
CHANGED
|
@@ -17,9 +17,18 @@ export interface ColumnDescription {
|
|
|
17
17
|
|
|
18
18
|
export interface IndexDescription {
|
|
19
19
|
readonly name: string;
|
|
20
|
+
/** Physical columns in **index key order** — the order the planner sorts by, never `attnum`. */
|
|
20
21
|
readonly columns: readonly string[];
|
|
21
22
|
readonly unique: boolean;
|
|
22
23
|
readonly primary: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Partial index predicate as SQL, `null` when the index covers every row. The catalog returns
|
|
26
|
+
* its own rewriting of the expression (`(deleted_at IS NULL)`), never the author's spelling, so
|
|
27
|
+
* this is readable and comparable to *itself* — never to a snapshot's text. See `drift.ts`.
|
|
28
|
+
*/
|
|
29
|
+
readonly where: string | null;
|
|
30
|
+
/** `desc` only when every key column is descending; `null` is Postgres' own default. */
|
|
31
|
+
readonly order: 'asc' | 'desc' | null;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
export interface ForeignKeyDescription {
|
|
@@ -65,6 +74,8 @@ interface IndexRow {
|
|
|
65
74
|
readonly is_unique: boolean;
|
|
66
75
|
readonly is_primary: boolean;
|
|
67
76
|
readonly columns: readonly string[];
|
|
77
|
+
readonly predicate: string | null;
|
|
78
|
+
readonly descending: boolean;
|
|
68
79
|
}
|
|
69
80
|
|
|
70
81
|
interface ForeignKeyRow {
|
|
@@ -90,37 +101,50 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
|
|
|
90
101
|
order by table_name, ordinal_position
|
|
91
102
|
`);
|
|
92
103
|
|
|
104
|
+
// Ordered by the index's own key position, never by `attnum`: `indkey` IS the order the planner
|
|
105
|
+
// sorts by, and a composite index on `(created_at, org_id)` whose columns were declared the
|
|
106
|
+
// other way round came back reversed — a description that reads correct and compares wrong.
|
|
107
|
+
// `indnkeyatts` drops INCLUDE payload columns, which are stored, not keyed.
|
|
93
108
|
const indexes = await client.query<IndexRow>(sql`
|
|
94
109
|
select
|
|
95
110
|
t.relname as table_name,
|
|
96
111
|
i.relname as index_name,
|
|
97
112
|
ix.indisunique as is_unique,
|
|
98
113
|
ix.indisprimary as is_primary,
|
|
99
|
-
|
|
114
|
+
pg_get_expr(ix.indpred, ix.indrelid) as predicate,
|
|
115
|
+
array_agg(a.attname order by k.ord) as columns,
|
|
116
|
+
bool_and((ix.indoption[k.ord - 1] & 1) = 1) as descending
|
|
100
117
|
from pg_class t
|
|
101
118
|
join pg_namespace n on n.oid = t.relnamespace
|
|
102
119
|
join pg_index ix on ix.indrelid = t.oid
|
|
103
120
|
join pg_class i on i.oid = ix.indexrelid
|
|
104
|
-
join
|
|
105
|
-
|
|
106
|
-
|
|
121
|
+
cross join lateral unnest(ix.indkey::smallint[]) with ordinality as k(attnum, ord)
|
|
122
|
+
join pg_attribute a on a.attrelid = t.oid and a.attnum = k.attnum
|
|
123
|
+
where n.nspname = ${schema} and t.relkind = 'r' and k.ord <= ix.indnkeyatts
|
|
124
|
+
group by t.relname, i.relname, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid
|
|
107
125
|
order by t.relname, i.relname
|
|
108
126
|
`);
|
|
109
127
|
|
|
128
|
+
// `conkey` and `confkey` are unnested TOGETHER, by shared ordinality: they are two halves of one
|
|
129
|
+
// ordered pairing, and matching each independently with `= any(...)` is a cross product — a
|
|
130
|
+
// two-column key came back as four source columns against four referenced ones, duplicated and
|
|
131
|
+
// misaligned. Ordered by `k.ord` (the constraint's own key position), never by `attnum`, for the
|
|
132
|
+
// same reason `indkey` orders the index query: `references t (y, x)` is not `references t (x, y)`.
|
|
110
133
|
const foreignKeys = await client.query<ForeignKeyRow>(sql`
|
|
111
134
|
select
|
|
112
135
|
src.relname as table_name,
|
|
113
136
|
c.conname as constraint_name,
|
|
114
|
-
array_agg(sa.attname order by
|
|
137
|
+
array_agg(sa.attname order by k.ord) as columns,
|
|
115
138
|
tgt.relname as referenced_table,
|
|
116
|
-
array_agg(ta.attname order by
|
|
139
|
+
array_agg(ta.attname order by k.ord) as referenced_columns,
|
|
117
140
|
c.confdeltype as on_delete
|
|
118
141
|
from pg_constraint c
|
|
119
142
|
join pg_class src on src.oid = c.conrelid
|
|
120
143
|
join pg_class tgt on tgt.oid = c.confrelid
|
|
121
144
|
join pg_namespace n on n.oid = src.relnamespace
|
|
122
|
-
join
|
|
123
|
-
join pg_attribute
|
|
145
|
+
cross join lateral unnest(c.conkey, c.confkey) with ordinality as k(src_attnum, tgt_attnum, ord)
|
|
146
|
+
join pg_attribute sa on sa.attrelid = src.oid and sa.attnum = k.src_attnum
|
|
147
|
+
join pg_attribute ta on ta.attrelid = tgt.oid and ta.attnum = k.tgt_attnum
|
|
124
148
|
where c.contype = 'f' and n.nspname = ${schema}
|
|
125
149
|
group by src.relname, c.conname, tgt.relname, c.confdeltype
|
|
126
150
|
order by src.relname, c.conname
|
|
@@ -149,6 +173,8 @@ export function buildSchema(
|
|
|
149
173
|
columns: [...row.columns],
|
|
150
174
|
unique: row.is_unique,
|
|
151
175
|
primary: row.is_primary,
|
|
176
|
+
where: row.predicate,
|
|
177
|
+
order: row.descending ? ('desc' as const) : null,
|
|
152
178
|
}))
|
|
153
179
|
.sort(byName);
|
|
154
180
|
return {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Single responsibility: merging the framework's own libpq `options` into whatever the operator
|
|
2
|
+
// already put in `DATABASE_URL`. A connection string is the operator's file, not the framework's,
|
|
3
|
+
// and `searchParams.set` on a key they may have written is a silent overwrite of their setting.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* libpq hands `options` to the backend as command-line arguments, split on whitespace with a
|
|
7
|
+
* backslash escaping the next character. Escapes are kept intact, so re-joining the tokens
|
|
8
|
+
* reproduces the operator's string byte for byte.
|
|
9
|
+
*/
|
|
10
|
+
export function splitLibpqOptions(options: string): readonly string[] {
|
|
11
|
+
const tokens: string[] = [];
|
|
12
|
+
let current = '';
|
|
13
|
+
let open = false;
|
|
14
|
+
for (let index = 0; index < options.length; index += 1) {
|
|
15
|
+
const char = options[index] ?? '';
|
|
16
|
+
const escaped = options[index + 1];
|
|
17
|
+
if (char === '\\' && escaped !== undefined) {
|
|
18
|
+
current += `\\${escaped}`;
|
|
19
|
+
open = true;
|
|
20
|
+
index += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (char.trim() === '') {
|
|
24
|
+
if (open) tokens.push(current);
|
|
25
|
+
current = '';
|
|
26
|
+
open = false;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
current += char;
|
|
30
|
+
open = true;
|
|
31
|
+
}
|
|
32
|
+
if (open) tokens.push(current);
|
|
33
|
+
return tokens;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The three spellings a backend accepts for one GUC on the command line: `-c name=value` as two
|
|
38
|
+
* arguments, `-cname=value` as one, and `--name=value` (where a hyphen in the name reads as an
|
|
39
|
+
* underscore). A bare `name=value` token is the second half of the first spelling.
|
|
40
|
+
*/
|
|
41
|
+
const ASSIGNS = (name: string): RegExp => new RegExp(`^(?:-c|--)?${name.replaceAll('_', '[_-]')}=`);
|
|
42
|
+
|
|
43
|
+
/** Drops every assignment of `name`, and the `-c` that introduced it. */
|
|
44
|
+
function without(tokens: readonly string[], name: string): readonly string[] {
|
|
45
|
+
const assigns = ASSIGNS(name);
|
|
46
|
+
const kept: string[] = [];
|
|
47
|
+
for (const token of tokens) {
|
|
48
|
+
if (!assigns.test(token)) {
|
|
49
|
+
kept.push(token);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (kept.at(-1) === '-c') kept.pop();
|
|
53
|
+
}
|
|
54
|
+
return kept;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The operator's `options` with the framework's settings merged in.
|
|
59
|
+
*
|
|
60
|
+
* **Precedence: the framework wins on the settings it names, the operator keeps everything else.**
|
|
61
|
+
* A role's `statement_timeout` is a safety bound the role is sized around — `web`'s 10s is what
|
|
62
|
+
* stops a slow endpoint holding all 20 pool slots — so a value in the URL may not raise it; but a
|
|
63
|
+
* `search_path`, an `application_name` or a `-c` an operator added is theirs and must survive.
|
|
64
|
+
* Enforced by removing the framework's own names before appending, never by position: relying on
|
|
65
|
+
* "the last `-c` wins" would make the bound depend on backend argument order nobody here measured.
|
|
66
|
+
*/
|
|
67
|
+
export function mergeLibpqOptions(
|
|
68
|
+
existing: string | null,
|
|
69
|
+
settings: Readonly<Record<string, string>>,
|
|
70
|
+
): string {
|
|
71
|
+
let tokens = splitLibpqOptions(existing ?? '');
|
|
72
|
+
for (const [name, value] of Object.entries(settings)) {
|
|
73
|
+
tokens = [...without(tokens, name), '-c', `${name}=${value}`];
|
|
74
|
+
}
|
|
75
|
+
return tokens.join(' ');
|
|
76
|
+
}
|