@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/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
- readonlyViolation,
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
- ParsedIndex,
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
- array_agg(a.attname order by a.attnum) as columns
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 pg_attribute a on a.attrelid = t.oid and a.attnum = any(ix.indkey)
105
- where n.nspname = ${schema} and t.relkind = 'r'
106
- group by t.relname, i.relname, ix.indisunique, ix.indisprimary
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 sa.attnum) as columns,
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 ta.attnum) as referenced_columns,
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 pg_attribute sa on sa.attrelid = src.oid and sa.attnum = any(c.conkey)
123
- join pg_attribute ta on ta.attrelid = tgt.oid and ta.attnum = any(c.confkey)
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
+ }