@ultimat3/db 22.15.0 → 24.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 (43) hide show
  1. package/CLAUDE.md +91 -56
  2. package/README.md +121 -6
  3. package/package.json +5 -3
  4. package/src/array-parameter.ts +38 -1
  5. package/src/bound-parameters.ts +22 -3
  6. package/src/bun-sql.ts +12 -0
  7. package/src/catalog-fold.ts +116 -0
  8. package/src/catalog-objects.ts +229 -0
  9. package/src/catalog-relations.ts +184 -0
  10. package/src/catalog.ts +174 -0
  11. package/src/client.ts +57 -9
  12. package/src/commit-tag.ts +21 -0
  13. package/src/dependent-view.ts +6 -4
  14. package/src/drift-errors.ts +3 -3
  15. package/src/drift-findings.ts +213 -60
  16. package/src/drift.ts +19 -13
  17. package/src/dump-drift.ts +142 -0
  18. package/src/errors.ts +8 -7
  19. package/src/foreign-key.ts +0 -34
  20. package/src/generate.ts +5 -0
  21. package/src/index.ts +9 -3
  22. package/src/introspect-catalog.ts +171 -0
  23. package/src/introspect.ts +45 -8
  24. package/src/listen.ts +62 -0
  25. package/src/migrate.ts +3 -3
  26. package/src/object-drift.ts +162 -0
  27. package/src/pglite-branch.ts +6 -6
  28. package/src/pglite-extensions.ts +112 -0
  29. package/src/pglite-package.ts +11 -0
  30. package/src/pglite-snapshot.ts +121 -0
  31. package/src/pglite.ts +146 -11
  32. package/src/pool-gauge.ts +70 -0
  33. package/src/primary-key.ts +180 -0
  34. package/src/schema-dump-entry.ts +39 -0
  35. package/src/schema-dump-table.ts +75 -0
  36. package/src/schema-dump.ts +192 -0
  37. package/src/schema-load.ts +119 -0
  38. package/src/sibling-turn.ts +49 -0
  39. package/src/sqlstate.ts +33 -10
  40. package/src/statement-funnel.ts +16 -5
  41. package/src/transaction-errors.ts +66 -0
  42. package/src/transaction-options.ts +122 -0
  43. package/src/transaction.ts +131 -121
package/src/drift.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  changedColumn,
10
10
  changedForeignKey,
11
11
  changedIndex,
12
+ changedPrimaryKey,
12
13
  missingCheck,
13
14
  missingColumn,
14
15
  missingForeignKey,
@@ -23,6 +24,7 @@ import { foreignKeyTarget, onDeleteRule } from './foreign-key';
23
24
  import { indexMethodOf } from './index-method';
24
25
  import { findTable, introspect, type SchemaDescription, type TableDescription } from './introspect';
25
26
  import { type LedgerRow, type Migration, readLedger } from './migrate';
27
+ import { sameColumns } from './primary-key';
26
28
 
27
29
  // Re-exported explicitly, never `export *`: `src/index.ts` publishes both from `'./drift'`, so the
28
30
  // split is invisible to `@ultimat3/db`'s public surface and no consumer moves with it.
@@ -145,7 +147,7 @@ function compareForeignKeys(live: TableDescription, expected: TableDescription):
145
147
  continue;
146
148
  }
147
149
  if (onDeleteRule(counterpart.onDelete) !== onDeleteRule(key.onDelete)) {
148
- differences.push(changedForeignKey(live.name, key, counterpart));
150
+ differences.push(changedForeignKey(live.schema, live.name, key, counterpart));
149
151
  }
150
152
  }
151
153
  return differences;
@@ -175,26 +177,29 @@ function compareChecks(live: TableDescription, expected: TableDescription): Drif
175
177
  const present = new Set(held);
176
178
  return declared
177
179
  .filter((check) => !present.has(check.name))
178
- .map((check) => missingCheck(live.name, check));
180
+ .map((check) => missingCheck(live.schema, live.name, check));
179
181
  }
180
182
 
181
183
  /**
182
- * A primary key column is `NOT NULL` in the catalog whether or not anything declared it — Postgres
183
- * adds the constraint with the key. Both sides are therefore read through the union of the two
184
- * primary keys, or a table whose snapshot spells its key column nullable reports a difference
185
- * against a database that is exactly right and cannot be anything else. The union, not one side:
186
- * a key present on only one of them is a difference the *key* comparison owns, and reporting it
187
- * again as a nullability change would be one fault with two findings.
184
+ * The two key lists, compared in ORDER. The comment that used to stand here said a key on one side
185
+ * only "is a difference the key comparison owns" — and no key comparison existed, so a table
186
+ * re-keyed by hand, or one whose key no migration ever produced, read `ok: true`.
188
187
  */
189
- function keyColumnsOf(live: TableDescription, expected: TableDescription): ReadonlySet<string> {
190
- return new Set([...live.primaryKey, ...expected.primaryKey]);
188
+ function comparePrimaryKey(live: TableDescription, expected: TableDescription): DriftDifference[] {
189
+ if (sameColumns(live.primaryKey, expected.primaryKey)) return [];
190
+ const held = live.indexes.find((index) => index.primary)?.name;
191
+ return [changedPrimaryKey(live.schema, live.name, live.primaryKey, held, expected.primaryKey)];
191
192
  }
192
193
 
193
194
  function compareTable(live: TableDescription, expected: TableDescription): DriftDifference[] {
194
195
  const differences: DriftDifference[] = [];
195
196
  const expectedColumns = new Map(expected.columns.map((column) => [column.name, column]));
196
197
  const liveColumns = new Map(live.columns.map((column) => [column.name, column]));
197
- const keyColumns = keyColumnsOf(live, expected);
198
+ // A primary key column is `NOT NULL` in the catalog whether or not anything declared it —
199
+ // Postgres adds the constraint with the key — so a snapshot spelling its key column nullable is
200
+ // not drift. The DECLARED key only: a column the database alone keys stays NOT NULL after the
201
+ // stray constraint is dropped, which is a second fault and reported as one.
202
+ const keyColumns = new Set(expected.primaryKey);
198
203
  for (const column of live.columns) {
199
204
  if (expectedColumns.has(column.name)) continue;
200
205
  differences.push(unexpectedColumn(live.name, column.name));
@@ -210,9 +215,10 @@ function compareTable(live: TableDescription, expected: TableDescription): Drift
210
215
  // `retypeColumn` already owns that question where both sides are generated.
211
216
  if (keyColumns.has(column.name)) continue;
212
217
  if (column.nullable !== counterpart.nullable) {
213
- differences.push(changedColumn(live.name, column.name, counterpart.nullable));
218
+ differences.push(changedColumn(live.schema, live.name, column.name, counterpart.nullable));
214
219
  }
215
220
  }
221
+ differences.push(...comparePrimaryKey(live, expected));
216
222
  differences.push(...compareIndexes(live, expected));
217
223
  differences.push(...compareChecks(live, expected));
218
224
  differences.push(...compareForeignKeys(live, expected));
@@ -224,7 +230,7 @@ export function diffSchema(live: SchemaDescription, expected: SchemaDescription)
224
230
  const differences: DriftDifference[] = [];
225
231
  for (const table of live.tables) {
226
232
  const counterpart = findTable(expected, table.name);
227
- if (counterpart === undefined) differences.push(unexpectedTable(table.name));
233
+ if (counterpart === undefined) differences.push(unexpectedTable(table.schema, table.name));
228
234
  else differences.push(...compareTable(table, counterpart));
229
235
  }
230
236
  for (const table of expected.tables) {
@@ -0,0 +1,142 @@
1
+ // Single responsibility: `X_SCHEMA_DUMP_DRIFT` — what it means for a committed schema dump to
2
+ // disagree with the one the migrations produce, and how each disagreement reads. Pure: files in,
3
+ // differences out. Which directory the dump lives in is the caller's fact; paths here are relative.
4
+
5
+ import { DbError } from './errors';
6
+ import type { SchemaDumpFile } from './schema-dump';
7
+
8
+ export type SchemaDumpDifferenceKind =
9
+ | 'missing-file'
10
+ | 'changed-file'
11
+ | 'unexpected-file'
12
+ | 'unloadable'
13
+ | 'reload-differs';
14
+
15
+ export interface SchemaDumpDifference {
16
+ readonly kind: SchemaDumpDifferenceKind;
17
+ /** Relative to the dump directory. */
18
+ readonly path: string;
19
+ readonly cause: string;
20
+ readonly fix: string;
21
+ }
22
+
23
+ /** Regenerating is the whole repair for a dump that is merely behind. */
24
+ export const SCHEMA_DUMP_FIX = 'x db gen';
25
+
26
+ /**
27
+ * For a dump that regenerating cannot repair. It still starts with the command, because the first
28
+ * thing to rule out is a dump that is merely stale — and it says what the second failure means,
29
+ * since nothing the app's author can run fixes a statement the dump renders wrong.
30
+ */
31
+ const roundTripFix = (path: string): string =>
32
+ `x db gen # and when the regenerated dump is refused the same way, ${path} holds an object the dump cannot round-trip: report it with that file and the migration that creates it`;
33
+
34
+ const byPath = (a: SchemaDumpDifference, b: SchemaDumpDifference): number =>
35
+ a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
36
+
37
+ /**
38
+ * The committed files against the rendered ones, byte for byte, in both directions: a file the
39
+ * migrations produce and nobody committed, one whose bytes differ, and one on disk that nothing
40
+ * renders. The last matters as much as the first — a stray `04_tables/old.sql` is a table an
41
+ * agent reading the directory believes exists.
42
+ */
43
+ export function compareSchemaDump(
44
+ committed: readonly SchemaDumpFile[],
45
+ rendered: readonly SchemaDumpFile[],
46
+ ): readonly SchemaDumpDifference[] {
47
+ const onDisk = new Map(committed.map((file) => [file.path, file.content]));
48
+ const produced = new Set(rendered.map((file) => file.path));
49
+ const differences: SchemaDumpDifference[] = [];
50
+ for (const file of rendered) {
51
+ const held = onDisk.get(file.path);
52
+ if (held === file.content) continue;
53
+ differences.push(
54
+ held === undefined
55
+ ? {
56
+ kind: 'missing-file',
57
+ path: file.path,
58
+ cause: `schema dump file ${file.path} is what the migrations produce and is not committed`,
59
+ fix: SCHEMA_DUMP_FIX,
60
+ }
61
+ : {
62
+ kind: 'changed-file',
63
+ path: file.path,
64
+ cause: `schema dump file ${file.path} differs from what the migrations produce`,
65
+ fix: SCHEMA_DUMP_FIX,
66
+ },
67
+ );
68
+ }
69
+ for (const file of committed) {
70
+ if (produced.has(file.path)) continue;
71
+ differences.push({
72
+ kind: 'unexpected-file',
73
+ path: file.path,
74
+ cause: `schema dump file ${file.path} describes nothing the migrations produce`,
75
+ fix: SCHEMA_DUMP_FIX,
76
+ });
77
+ }
78
+ return differences.sort(byPath);
79
+ }
80
+
81
+ /**
82
+ * Load equals replay, as a comparison: the dump of the migrated database against the dump of a
83
+ * database built by loading that dump. Any difference means the files are not a cache of the
84
+ * migrations but a second, lossy source. One finding, naming the first file that came back
85
+ * different — the rest follow from it.
86
+ *
87
+ * Asked only of a dump that claims to be whole. One that names objects in `unrendered.sql` has
88
+ * already said it is not, and its caller does not ask.
89
+ */
90
+ export function reloadDifferences(
91
+ replayed: readonly SchemaDumpFile[],
92
+ reloaded: readonly SchemaDumpFile[],
93
+ ): readonly SchemaDumpDifference[] {
94
+ const first = compareSchemaDump(reloaded, replayed)[0];
95
+ if (first === undefined) return [];
96
+ return [
97
+ {
98
+ kind: 'reload-differs',
99
+ path: first.path,
100
+ cause: `a database loaded from the schema dump is not the one the migrations build: ${first.path} comes back different`,
101
+ fix: roundTripFix(first.path),
102
+ },
103
+ ];
104
+ }
105
+
106
+ /** A dump file the database refused, with what the database said about it. */
107
+ export const unloadableDump = (path: string, detail: string): SchemaDumpDifference => ({
108
+ kind: 'unloadable',
109
+ path,
110
+ cause: `schema dump file ${path} does not load: ${detail}`,
111
+ fix: roundTripFix(path),
112
+ });
113
+
114
+ export const schemaDumpDrift = (difference: SchemaDumpDifference): DbError =>
115
+ new DbError({
116
+ code: 'X_SCHEMA_DUMP_DRIFT',
117
+ cause: difference.cause,
118
+ fix: difference.fix,
119
+ meta: { kind: difference.kind, path: difference.path },
120
+ });
121
+
122
+ const DIFFERENCE_KINDS: readonly string[] = [
123
+ 'missing-file',
124
+ 'changed-file',
125
+ 'unexpected-file',
126
+ 'unloadable',
127
+ 'reload-differs',
128
+ ] satisfies readonly SchemaDumpDifferenceKind[];
129
+
130
+ /**
131
+ * The difference a thrown `X_SCHEMA_DUMP_DRIFT` was built from, or `undefined` for anything else.
132
+ * `loadSchemaDump` throws; a caller that reports findings wants the value back, and reading it
133
+ * off the error here keeps `meta`'s shape a fact of this file alone.
134
+ */
135
+ export function schemaDumpDifferenceOf(error: unknown): SchemaDumpDifference | undefined {
136
+ if (!(error instanceof DbError) || error.code !== 'X_SCHEMA_DUMP_DRIFT') return undefined;
137
+ const kind = error.meta?.['kind'];
138
+ const path = error.meta?.['path'];
139
+ if (typeof kind !== 'string' || typeof path !== 'string') return undefined;
140
+ if (!DIFFERENCE_KINDS.includes(kind)) return undefined;
141
+ return { kind: kind as SchemaDumpDifferenceKind, path, cause: error.cause, fix: error.fix };
142
+ }
package/src/errors.ts CHANGED
@@ -37,6 +37,10 @@ export const DB_OWNED_ERROR_CODES = [
37
37
  'X_MIGRATE_CONCURRENT',
38
38
  'X_SQL_UNSAFE',
39
39
  'X_BRANCH_EXISTS',
40
+ 'X_SCHEMA_DUMP_DRIFT',
41
+ 'X_DB_TRANSACTION_ABORTED',
42
+ 'X_DB_COMMIT_UNKNOWN',
43
+ 'X_DB_SIBLING_SCOPE_TIMEOUT',
40
44
  ] as const;
41
45
 
42
46
  /**
@@ -81,6 +85,10 @@ export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
81
85
  X_MIGRATION_VIEW_DEPENDS: 'a view is compiled against a column this migration retypes',
82
86
  X_SQL_UNSAFE: 'SQL was built by string interpolation',
83
87
  X_BRANCH_EXISTS: 'that branch database already exists',
88
+ X_SCHEMA_DUMP_DRIFT: 'the committed schema dump is not what the migrations produce',
89
+ X_DB_TRANSACTION_ABORTED: 'the server rolled the transaction back',
90
+ X_DB_COMMIT_UNKNOWN: 'the connection was lost while COMMIT was in flight',
91
+ X_DB_SIBLING_SCOPE_TIMEOUT: 'a nested transaction scope waited too long for its sibling',
84
92
  };
85
93
 
86
94
  // Registered unconditionally, in one call, so a second package claiming one of db's codes fails
@@ -414,10 +422,3 @@ export const isolationLevelInvalid = (received: unknown): DbError =>
414
422
  cause: `an isolation level must be one of 'read committed', 'repeatable read' or 'serializable'; got ${describeValue(received)}`,
415
423
  fix: "withTransaction(fn, { isolation: 'serializable' }) # or 'repeatable read', or 'read committed'",
416
424
  });
417
-
418
- export const dbNotImplemented = (feature: string, fix: string): DbError =>
419
- new DbError({
420
- code: 'X_NOT_IMPLEMENTED',
421
- cause: `${feature} is not implemented by this driver`,
422
- fix,
423
- });
@@ -130,37 +130,3 @@ export function unrestorableNote(table: string, constraint: string, gone: string
130
130
  `cannot be restored; ${identifier(gone).text} is gone`
131
131
  );
132
132
  }
133
-
134
- /**
135
- * The drop/add pair that moves a key's `on delete` rule — a rebuild, because Postgres has no
136
- * `alter constraint` for it — for a `fix:` line an author pastes into a new migration.
137
- *
138
- * It lives here, beside the two writers, because it is the one caller reading values neither of
139
- * them may assume: `held` is the **live catalog's** and `declared` is a `.snapshot.json`'s. Both
140
- * writers refuse rather than guess — `identifier()` on a name holding a quote, a space or a
141
- * backslash (all three legal inside a quoted Postgres name), and `addForeignKey` on an `on delete`
142
- * rule Postgres does not have. That is exactly right for DDL this package SENDS and wrong for a
143
- * `fix:` line: `diffSchema` is documented pure and total, so a pair it cannot write is a sentence,
144
- * never a throw — a drift check that raises in place of its report hands the caller an exception
145
- * where a verdict was asked for. The constraint is still named, because it is the only thing
146
- * identifying which one, quoted by `JSON.stringify`, which escapes rather than refuses; nothing
147
- * runs this string either way.
148
- */
149
- export function rebuildForeignKey(
150
- table: string,
151
- declared: ForeignKeyDescription,
152
- held: ForeignKeyDescription,
153
- ): string {
154
- // The writers are ASKED whether they can write the pair — never a second copy of their rules
155
- // beside them, which is the copy that drifts. A refusal is the answer, and nothing here reads
156
- // the thrown value.
157
- try {
158
- return `${dropForeignKey(table, held.name)} ${addForeignKey(table, declared)}`;
159
- } catch {
160
- return (
161
- `drop constraint ${JSON.stringify(held.name)} on table ${JSON.stringify(table)} and add ` +
162
- 'it back with the on delete rule the migrations declare — by hand: x db gen cannot ' +
163
- 'write this pair'
164
- );
165
- }
166
- }
package/src/generate.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  } from './introspect';
24
24
  import { declaredIndexes } from './invariant-ddl';
25
25
  import { migrationIrreversible } from './migration-errors';
26
+ import { addChangedKey, dropChangedKey } from './primary-key';
26
27
  import type { ReplicaIdentityInput } from './replica-identity';
27
28
  import { replicaIdentityFullAfter, replicaIdentityPlan } from './replica-identity';
28
29
  import type { MovedAside } from './retype-dependents';
@@ -324,6 +325,9 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
324
325
  created.add(entity.table);
325
326
  continue;
326
327
  }
328
+ // The key first and last, around every column statement of the table (`primary-key.ts`): the
329
+ // snapshot below records `entity.primaryKey`, and until this arm existed nothing produced it.
330
+ dropChangedKey(entity, live, current, plan, options.name);
327
331
  diffTable(entity, live, plan, retypedIn(retyped, entity.table));
328
332
  const kept = new Set(entity.columns.map((column) => column.column));
329
333
  for (const column of live.columns) {
@@ -343,6 +347,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
343
347
  ' -- data is not restored',
344
348
  );
345
349
  }
350
+ addChangedKey(entity, live, plan, options.name);
346
351
  }
347
352
 
348
353
  const order = dropOrder(current.tables.filter((table) => !wanted.has(table.name)));
package/src/index.ts CHANGED
@@ -72,7 +72,6 @@ export {
72
72
  DB_ERROR_RETRY,
73
73
  DB_ERROR_TITLES,
74
74
  DbError,
75
- dbNotImplemented,
76
75
  dbUnavailable,
77
76
  driverError,
78
77
  identifierUnsafe,
@@ -112,6 +111,8 @@ export {
112
111
  uniqueColumns,
113
112
  } from './invariant-ddl';
114
113
  export { constraintExpressionUnsafe, constraintNameUnsafe } from './invariant-errors';
114
+ export type { DbSubscription, ListeningClient } from './listen';
115
+ export { canListen } from './listen';
115
116
  export type {
116
117
  AppliedMigration,
117
118
  LedgerRow,
@@ -166,6 +167,8 @@ export {
166
167
  } from './pglite';
167
168
  export type { PgliteBranchInfo, PgliteBranchOptions } from './pglite-branch';
168
169
  export { branchPglite, pgliteBranchDir } from './pglite-branch';
170
+ export type { LinkedExtensions, PgliteExtensionLoader } from './pglite-extensions';
171
+ export { linkPgliteExtensions } from './pglite-extensions';
169
172
  export type { PoolProfile } from './pool-profile';
170
173
  export { POOL_MAX_ENV, POOL_PROFILES, poolProfileFor } from './pool-profile';
171
174
  export type { ReadOnlyQueryOptions, ReadOnlyQueryResult } from './readonly-query';
@@ -181,6 +184,7 @@ export { BREAKER_COOLDOWN_MS, BREAKER_FAILURES, replicatedClient } from './repli
181
184
  export { type DbNode, isPlainRead } from './replica-route';
182
185
  export type { ReplicaScope } from './replica-scope';
183
186
  export { markScopeWrote, replicaScope, withReplicaReads } from './replica-scope';
187
+ export { SIBLING_SCOPE_WAIT_MS } from './sibling-turn';
184
188
  export { snapshotJson } from './snapshot-json';
185
189
  export { parseSnapshot } from './snapshot-parse';
186
190
  export type { SqlFragment } from './sql';
@@ -199,8 +203,10 @@ export { DB_SQLSTATE_CODES, isRetryableState, SQLSTATE, sqlState, sqlStateCode }
199
203
  export { statementFingerprint, statementKind, statementVerb } from './statement-shape';
200
204
  export { STATEMENT_ATTRIBUTE } from './statement-span';
201
205
  export { statementsOf } from './statement-split';
202
- export type { DbTx, IsolationLevel, TransactionOptions } from './transaction';
203
- export { beginStatement, currentTx, withTransaction } from './transaction';
206
+ export { currentTx, liveTxConnection, withTransaction } from './transaction';
207
+ export { commitUnknown, siblingScopeTimeout, transactionAborted } from './transaction-errors';
208
+ export type { DbTx, IsolationLevel, TransactionOptions } from './transaction-options';
209
+ export { beginStatement } from './transaction-options';
204
210
  export type { GeneratableForm } from './ungeneratable';
205
211
  export { GENERATABLE_FORMS, ungeneratableStatements } from './ungeneratable';
206
212
  export type { UnrenderedDeclaration } from './unrendered';
@@ -0,0 +1,171 @@
1
+ // Single responsibility: read the WHOLE schema out of `pg_catalog` — tables and everything that is
2
+ // not a table — into one `CatalogDescription`, sorted in JS. `introspect()` beside it stays the
3
+ // entity-vocabulary reading drift compares to a snapshot; this is the catalog's own spelling, the
4
+ // input of the schema dump, and comparable only to another reading of itself.
5
+
6
+ import type { CatalogDescription, CatalogType } from './catalog';
7
+ import { by, sequenceOf, tableOf } from './catalog-fold';
8
+ import {
9
+ domainCheckRows,
10
+ domainRows,
11
+ enumRows,
12
+ extensionRows,
13
+ functionRows,
14
+ triggerRows,
15
+ unrenderedRows,
16
+ viewRows,
17
+ } from './catalog-objects';
18
+ import {
19
+ columnRows,
20
+ constraintRows,
21
+ indexRows,
22
+ sequenceRows,
23
+ tableRows,
24
+ } from './catalog-relations';
25
+ import { type DbClient, db } from './client';
26
+
27
+ export interface IntrospectCatalogOptions {
28
+ readonly client?: DbClient | undefined;
29
+ readonly schema?: string | undefined;
30
+ }
31
+
32
+ async function typesOf(client: DbClient, schema: string): Promise<readonly CatalogType[]> {
33
+ const labels = await enumRows(client, schema);
34
+ const domains = await domainRows(client, schema);
35
+ const checks = await domainCheckRows(client, schema);
36
+ const enums = [...new Set(labels.map((row) => row.name))].map(
37
+ (name): CatalogType => ({
38
+ kind: 'enum',
39
+ name,
40
+ labels: labels
41
+ .filter((row) => row.name === name)
42
+ .sort((a, b) => a.position - b.position)
43
+ .map((row) => row.label),
44
+ }),
45
+ );
46
+ const described = domains.map(
47
+ (row): CatalogType => ({
48
+ kind: 'domain',
49
+ name: row.name,
50
+ baseType: row.base_type,
51
+ notNull: row.not_null,
52
+ default: row.expression,
53
+ checks: checks
54
+ .filter((check) => check.domain_name === row.name)
55
+ .sort(by((check) => check.name))
56
+ .map((check) => ({ name: check.name, definition: check.definition })),
57
+ }),
58
+ );
59
+ return [...enums, ...described].sort(by((type) => type.name));
60
+ }
61
+
62
+ /**
63
+ * Eleven round trips, sequential: a pinned transaction connection answers one statement at a
64
+ * time, and this runs once per `x db gen`, never per request.
65
+ */
66
+ export async function introspectCatalog(
67
+ options: IntrospectCatalogOptions = {},
68
+ ): Promise<CatalogDescription> {
69
+ const client = options.client ?? db();
70
+ const schema = options.schema ?? 'public';
71
+ const tables = await tableRows(client, schema);
72
+ const columns = await columnRows(client, schema);
73
+ const constraints = await constraintRows(client, schema);
74
+ const sequences = await sequenceRows(client, schema);
75
+ const indexes = await indexRows(client, schema);
76
+ const views = await viewRows(client, schema);
77
+ const functions = await functionRows(client, schema);
78
+ const triggers = await triggerRows(client, schema);
79
+ const unrendered = await unrenderedRows(client, schema);
80
+ const known = new Set(tables.map((table) => table.name));
81
+ const materialized = new Set(views.filter((view) => view.kind === 'm').map((view) => view.name));
82
+ // A trigger loads only onto a relation the dump creates: a plain table, or a view (`instead
83
+ // of`). One on a partitioned table or a partition was filed under `09_triggers/` for a table no
84
+ // file creates, and the load refused the whole dump with `X_SCHEMA_DUMP_DRIFT`.
85
+ const viewNames = new Set(views.map((view) => view.name));
86
+ const loadable = (trigger: { readonly table_name: string }): boolean =>
87
+ known.has(trigger.table_name) || viewNames.has(trigger.table_name);
88
+
89
+ return {
90
+ schema,
91
+ extensions: (await extensionRows(client))
92
+ .map((row) => ({ name: row.name }))
93
+ .sort(by((extension) => extension.name)),
94
+ types: await typesOf(client, schema),
95
+ sequences: sequences
96
+ .filter((sequence) => sequence.ownership === null)
97
+ .map(sequenceOf)
98
+ .sort(by((sequence) => sequence.name)),
99
+ tables: tables
100
+ .map((table) => tableOf(table, columns, constraints, sequences))
101
+ .sort(by((table) => table.name)),
102
+ // An index on a relation this reading does not render (a partition) has nowhere to load.
103
+ indexes: indexes
104
+ .filter((index) => known.has(index.table_name) || materialized.has(index.table_name))
105
+ .map((row) => ({ table: row.table_name, name: row.name, definition: row.definition }))
106
+ .sort(
107
+ by(
108
+ (index) => index.table,
109
+ (index) => index.name,
110
+ ),
111
+ ),
112
+ foreignKeys: constraints
113
+ .filter((constraint) => constraint.type === 'f' && known.has(constraint.table_name))
114
+ .map((row) => ({ table: row.table_name, name: row.name, definition: row.definition }))
115
+ .sort(
116
+ by(
117
+ (key) => key.table,
118
+ (key) => key.name,
119
+ ),
120
+ ),
121
+ views: views
122
+ .map((row) => ({
123
+ name: row.name,
124
+ materialized: row.kind === 'm',
125
+ options: row.options,
126
+ definition: row.definition,
127
+ }))
128
+ .sort(by((view) => view.name)),
129
+ functions: functions
130
+ .map((row) => ({ name: row.name, arguments: row.arguments, definition: row.definition }))
131
+ .sort(
132
+ by(
133
+ (fn) => fn.name,
134
+ (fn) => fn.arguments,
135
+ ),
136
+ ),
137
+ triggers: triggers
138
+ .filter(loadable)
139
+ .map((row) => ({
140
+ table: row.table_name,
141
+ name: row.name,
142
+ definition: row.definition,
143
+ enabled: row.enabled,
144
+ }))
145
+ .sort(
146
+ by(
147
+ (trigger) => trigger.table,
148
+ (trigger) => trigger.name,
149
+ ),
150
+ ),
151
+ unrendered: [
152
+ ...unrendered,
153
+ // Named rather than dropped, the rule `CatalogUnrendered` states.
154
+ ...triggers
155
+ .filter((trigger) => !loadable(trigger))
156
+ .map((trigger) => ({
157
+ kind: 'trigger',
158
+ name: trigger.name,
159
+ table_name: trigger.table_name,
160
+ })),
161
+ ]
162
+ .map((row) => ({ kind: row.kind, name: row.name, table: row.table_name }))
163
+ .sort(
164
+ by(
165
+ (object) => object.kind,
166
+ (object) => object.table ?? '',
167
+ (object) => object.name,
168
+ ),
169
+ ),
170
+ };
171
+ }
package/src/introspect.ts CHANGED
@@ -27,7 +27,11 @@ export interface ColumnDescription {
27
27
 
28
28
  export interface IndexDescription {
29
29
  readonly name: string;
30
- /** Physical columns in **index key order** — the order the planner sorts by, never `attnum`. */
30
+ /**
31
+ * Key columns in **index key order** — the order the planner sorts by, never `attnum`. An
32
+ * EXPRESSION key read from the catalog is its definition in parentheses (`(lower(title))`), in
33
+ * its own position: marked, so it can never be taken for a column, and never dropped.
34
+ */
31
35
  readonly columns: readonly string[];
32
36
  readonly unique: boolean;
33
37
  readonly primary: boolean;
@@ -188,17 +192,45 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
188
192
  ...(await nonAppRelations(client, schema)),
189
193
  ];
190
194
 
195
+ // The type is `format_type`, as `catalog-relations.ts` reads it: `information_schema.data_type`
196
+ // answers `numeric` for `numeric(12,2)`, `ARRAY` for `text[]` and `USER-DEFINED` for an enum, and
197
+ // `ColumnDescription.dataType` has always been documented as the first of each pair. Still FROM
198
+ // the view, which decides which columns this role may see.
191
199
  const columns = await client.query<ColumnRow>(sql`
192
- select table_name, column_name, data_type, is_nullable, column_default, ordinal_position
193
- from information_schema.columns
194
- where table_schema = ${schema}
195
- order by table_name, ordinal_position
200
+ select
201
+ c.table_name,
202
+ c.column_name,
203
+ coalesce(
204
+ (
205
+ select format_type(a.atttypid, a.atttypmod)
206
+ from pg_attribute a
207
+ join pg_class r on r.oid = a.attrelid
208
+ join pg_namespace n on n.oid = r.relnamespace
209
+ where n.nspname = c.table_schema
210
+ and r.relname = c.table_name
211
+ and a.attname = c.column_name
212
+ and a.attnum > 0
213
+ and not a.attisdropped
214
+ ),
215
+ c.data_type
216
+ ) as data_type,
217
+ c.is_nullable,
218
+ c.column_default,
219
+ c.ordinal_position
220
+ from information_schema.columns c
221
+ where c.table_schema = ${schema}
222
+ order by c.table_name, c.ordinal_position
196
223
  `);
197
224
 
198
225
  // Ordered by the index's own key position, never by `attnum`: `indkey` IS the order the planner
199
226
  // sorts by, and a composite index on `(created_at, org_id)` whose columns were declared the
200
227
  // other way round came back reversed — a description that reads correct and compares wrong.
201
228
  // `indnkeyatts` drops INCLUDE payload columns, which are stored, not keyed.
229
+ //
230
+ // A LEFT join on `pg_attribute`: an expression key has `attnum = 0` and no attribute row, so an
231
+ // inner join dropped it and `(id, lower(title))` read back as `(id)` — an index rebuilt by hand
232
+ // with an extra expression key compared equal to the declared one. It is kept in its position
233
+ // and MARKED by its parentheses, which no declared column name carries.
202
234
  const indexes = await client.query<IndexRow>(sql`
203
235
  select
204
236
  t.relname as table_name,
@@ -207,7 +239,10 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
207
239
  ix.indisprimary as is_primary,
208
240
  pg_get_expr(ix.indpred, ix.indrelid) as predicate,
209
241
  am.amname as method,
210
- array_agg(a.attname order by k.ord) as columns,
242
+ array_agg(
243
+ coalesce(a.attname::text, '(' || pg_get_indexdef(ix.indexrelid, k.ord::int, false) || ')')
244
+ order by k.ord
245
+ ) as columns,
211
246
  bool_and((ix.indoption[k.ord - 1] & 1) = 1) as descending
212
247
  from pg_class t
213
248
  join pg_namespace n on n.oid = t.relnamespace
@@ -215,9 +250,11 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
215
250
  join pg_class i on i.oid = ix.indexrelid
216
251
  join pg_am am on am.oid = i.relam
217
252
  cross join lateral unnest(ix.indkey::smallint[]) with ordinality as k(attnum, ord)
218
- join pg_attribute a on a.attrelid = t.oid and a.attnum = k.attnum
253
+ left join pg_attribute a on a.attrelid = t.oid and a.attnum = k.attnum and k.attnum > 0
219
254
  where n.nspname = ${schema} and t.relkind = 'r' and k.ord <= ix.indnkeyatts
220
- group by t.relname, i.relname, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid, am.amname
255
+ group by
256
+ t.relname, i.relname, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid,
257
+ ix.indexrelid, am.amname
221
258
  order by t.relname, i.relname
222
259
  `);
223
260