@ultimat3/db 29.3.2 → 30.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 (98) hide show
  1. package/CLAUDE.md +78 -116
  2. package/README.md +199 -16
  3. package/package.json +13 -2
  4. package/src/app-relation.ts +7 -2
  5. package/src/catalog-fold.ts +5 -0
  6. package/src/catalog-objects.ts +20 -24
  7. package/src/catalog-policies.ts +81 -0
  8. package/src/catalog-relations.ts +31 -10
  9. package/src/catalog.ts +40 -1
  10. package/src/check-ddl.ts +34 -7
  11. package/src/client.ts +132 -48
  12. package/src/column-alter.ts +42 -1
  13. package/src/concurrent-index.ts +157 -0
  14. package/src/concurrent-plan.ts +294 -0
  15. package/src/connection-url.ts +16 -4
  16. package/src/db-diagnostics-deadline.ts +43 -0
  17. package/src/db-diagnostics-redact.ts +58 -0
  18. package/src/db-diagnostics-rows.ts +166 -0
  19. package/src/db-diagnostics-sql.ts +92 -0
  20. package/src/db-diagnostics.ts +178 -0
  21. package/src/destructive.ts +21 -35
  22. package/src/diagnostics-entry.ts +15 -0
  23. package/src/drift-errors.ts +9 -4
  24. package/src/drift-findings.ts +47 -62
  25. package/src/drift-missing.ts +203 -0
  26. package/src/drift-row-security.ts +88 -0
  27. package/src/drift-triggers.ts +54 -0
  28. package/src/drift.ts +88 -21
  29. package/src/entity-shape.ts +75 -0
  30. package/src/errors.ts +80 -9
  31. package/src/expected-loop.ts +34 -4
  32. package/src/failure-store-pg.ts +157 -0
  33. package/src/foreign-key-plan.ts +75 -23
  34. package/src/foreign-key.ts +76 -5
  35. package/src/generate-counter.ts +85 -0
  36. package/src/generate-history.ts +142 -0
  37. package/src/generate-row-security.ts +51 -0
  38. package/src/generate-snapshot.ts +82 -0
  39. package/src/generate-steps.ts +99 -0
  40. package/src/generate-triggers.ts +119 -0
  41. package/src/generate-view.ts +97 -0
  42. package/src/generate.ts +128 -89
  43. package/src/index-ddl.ts +23 -35
  44. package/src/index-plan.ts +35 -14
  45. package/src/index.ts +136 -6
  46. package/src/introspect-catalog.ts +26 -6
  47. package/src/introspect-rows.ts +82 -0
  48. package/src/introspect.ts +108 -50
  49. package/src/keyset.ts +182 -0
  50. package/src/lifecycle-errors.ts +15 -0
  51. package/src/local-settings.ts +38 -0
  52. package/src/marker-line.ts +47 -0
  53. package/src/migrate-lock-retry.ts +60 -0
  54. package/src/migrate-rollback.ts +45 -6
  55. package/src/migrate.ts +67 -69
  56. package/src/migration-errors.ts +48 -1
  57. package/src/migration-ledger.ts +38 -4
  58. package/src/migration-step.ts +192 -0
  59. package/src/partition-order.ts +56 -0
  60. package/src/partitioned-tables.ts +24 -0
  61. package/src/pool-gate.ts +120 -0
  62. package/src/pool-listeners.ts +143 -0
  63. package/src/pool-pins.ts +68 -0
  64. package/src/pool-profile.ts +23 -7
  65. package/src/pool-reserve.ts +11 -4
  66. package/src/pool-retire.ts +22 -0
  67. package/src/pool-sweep.ts +115 -0
  68. package/src/pool-timer.ts +10 -0
  69. package/src/pooler-fix.ts +53 -0
  70. package/src/pooler.ts +161 -0
  71. package/src/read-transaction.ts +59 -0
  72. package/src/readonly-query.ts +4 -4
  73. package/src/replica-client.ts +54 -22
  74. package/src/replica-position.ts +89 -0
  75. package/src/replica-scope.ts +25 -4
  76. package/src/retired.ts +131 -0
  77. package/src/retype-dependents.ts +2 -0
  78. package/src/rolling-errors.ts +97 -0
  79. package/src/rolling.ts +212 -0
  80. package/src/row-secure-client.ts +54 -0
  81. package/src/row-security-catalog.ts +26 -0
  82. package/src/row-security-migrator.ts +48 -0
  83. package/src/row-security-plan.ts +58 -0
  84. package/src/row-security-role.ts +60 -0
  85. package/src/row-security-roles.ts +64 -0
  86. package/src/row-security.ts +59 -0
  87. package/src/same-tenant.ts +173 -0
  88. package/src/schema-dump-policies.ts +26 -0
  89. package/src/schema-dump-table.ts +21 -6
  90. package/src/schema-dump.ts +13 -2
  91. package/src/snapshot-parse.ts +42 -2
  92. package/src/sqlstate.ts +4 -0
  93. package/src/statement-bound.ts +82 -0
  94. package/src/transaction-errors.ts +14 -0
  95. package/src/transaction-guard.ts +40 -0
  96. package/src/transaction-settings.ts +24 -0
  97. package/src/transaction.ts +74 -19
  98. package/src/ungeneratable.ts +30 -0
package/src/index.ts CHANGED
@@ -27,9 +27,23 @@ export type {
27
27
  PostgresClientOptions,
28
28
  ReservableClient,
29
29
  } from './client';
30
- export { baseClient, db, isReservable, postgresClient, setDbClient } from './client';
30
+ export {
31
+ baseClient,
32
+ db,
33
+ dbClientInstalled,
34
+ isReservable,
35
+ postgresClient,
36
+ setDbClient,
37
+ } from './client';
31
38
  export type { ColumnDefaultLike } from './column-default';
32
39
  export { defaultExpression } from './column-default';
40
+ export {
41
+ concurrentIndexTarget,
42
+ isConcurrentIndexStatement,
43
+ isNonTransactional,
44
+ isPartitionedBuildRefusal,
45
+ NO_TRANSACTION_MARKER,
46
+ } from './concurrent-index';
33
47
  export { dbExecutor } from './db-executor';
34
48
  export type { DbHealthReport } from './db-health';
35
49
  export { checkDb } from './db-health';
@@ -59,11 +73,16 @@ export {
59
73
  FRAMEWORK_TABLE_PREFIX,
60
74
  } from './drift';
61
75
  export { dbDrift } from './drift-errors';
76
+ export type { RowSecurityDifference } from './drift-row-security';
77
+ export { rowSecurityDifferences, rowSecurityDrift } from './drift-row-security';
62
78
  export type {
63
79
  ColumnDescriptionLike,
80
+ CounterDescriptionLike,
64
81
  EntityDescriptionLike,
82
+ HistoryDescriptionLike,
65
83
  IndexDescriptionLike,
66
84
  InvariantDescriptionLike,
85
+ RetainDescriptionLike,
67
86
  } from './entity-shape';
68
87
  export type { DbErrorCode, DbErrorInit } from './errors';
69
88
  export {
@@ -82,11 +101,39 @@ export {
82
101
  serializationExhausted,
83
102
  sqlUnsafe,
84
103
  } from './errors';
85
- export { expectedQueryLoop, expectedQueryLoopReason } from './expected-loop';
104
+ export {
105
+ expectedQueryLoop,
106
+ expectedQueryLoopReason,
107
+ expectedSeqScan,
108
+ expectedSeqScanReason,
109
+ } from './expected-loop';
110
+ export type { FailureSummary, PostgresFailureStore } from './failure-store-pg';
111
+ export {
112
+ FAILURES_TABLE,
113
+ failurePurgeTarget,
114
+ MAX_FAILURE_LIST,
115
+ postgresFailureStore,
116
+ SQL_FAILURES_TABLE,
117
+ } from './failure-store-pg';
86
118
  export type { RecordedStatement, RecordingClient, StubResponse } from './fake';
87
119
  export { recordingClient } from './fake';
88
120
  export type { GeneratedMigration, GenerateOptions } from './generate';
89
121
  export { generateMigration, migrationStamp, slugify, snapshotOf } from './generate';
122
+ export {
123
+ counterTrigger,
124
+ counterTriggerName,
125
+ recountDriftSql,
126
+ recountSql,
127
+ } from './generate-counter';
128
+ export {
129
+ ACTOR_SETTING,
130
+ HISTORY_GUARD_TRIGGER,
131
+ HISTORY_TRIGGER,
132
+ PURGE_SETTING,
133
+ REQUEST_SETTING,
134
+ } from './generate-history';
135
+ export type { RowSecurityOption, TenantColumnLike } from './generate-row-security';
136
+ export { declaredTriggers } from './generate-triggers';
90
137
  export type { IndexMethod } from './index-method';
91
138
  export {
92
139
  declaredMethod,
@@ -103,6 +150,8 @@ export type {
103
150
  IntrospectOptions,
104
151
  SchemaDescription,
105
152
  TableDescription,
153
+ TriggerRecord,
154
+ ViewDescription,
106
155
  } from './introspect';
107
156
  export { buildSchema, findTable, introspectSchema } from './introspect';
108
157
  export {
@@ -112,8 +161,13 @@ export {
112
161
  uniqueColumns,
113
162
  } from './invariant-ddl';
114
163
  export { constraintExpressionUnsafe, constraintNameUnsafe } from './invariant-errors';
164
+ export type { Keyset, KeysetKey, KeysetKind, KeysetOptions, KeysetRow } from './keyset';
165
+ export { keyset } from './keyset';
166
+ export { retainInvalid } from './lifecycle-errors';
115
167
  export type { DbSubscription, ListeningClient } from './listen';
116
168
  export { canListen } from './listen';
169
+ export type { LocalSettings } from './local-settings';
170
+ export { readLocal, setLocal } from './local-settings';
117
171
  export type {
118
172
  AppliedMigration,
119
173
  LedgerRow,
@@ -140,6 +194,8 @@ export {
140
194
  } from './migrate';
141
195
  export {
142
196
  migrateConcurrent,
197
+ migrationConcurrentInTransaction,
198
+ migrationConcurrentPartitioned,
143
199
  migrationConflict,
144
200
  migrationDestructive,
145
201
  migrationIrreversible,
@@ -172,7 +228,23 @@ export type { LinkedExtensions, PgliteExtensionLoader } from './pglite-extension
172
228
  export { linkPgliteExtensions } from './pglite-extensions';
173
229
  export { pgliteVersion } from './pglite-snapshot';
174
230
  export type { PoolProfile } from './pool-profile';
175
- export { POOL_MAX_ENV, POOL_PROFILES, poolMaxFromEnv, poolProfileFor } from './pool-profile';
231
+ export {
232
+ POOL_MAX_ENV,
233
+ POOL_PROFILES,
234
+ poolMaxFromEnv,
235
+ poolProfileFor,
236
+ } from './pool-profile';
237
+ export type { PoolerMode, StatementBound } from './pooler';
238
+ export {
239
+ lostStatementBound,
240
+ POOLER_ENV,
241
+ POOLER_WAIT_MS,
242
+ poolerFromEnv,
243
+ poolerSessionRequired,
244
+ statementBound,
245
+ } from './pooler';
246
+ export type { ReadTransactionOptions } from './read-transaction';
247
+ export { withReadTransaction } from './read-transaction';
176
248
  export type { ReadOnlyQueryOptions, ReadOnlyQueryResult } from './readonly-query';
177
249
  export { READONLY_TIMEOUT_MS, readOnlyQuery } from './readonly-query';
178
250
  export type { ReadOnlyRoleOptions } from './readonly-role';
@@ -183,9 +255,53 @@ export type {
183
255
  ReplicatedClientOptions,
184
256
  } from './replica-client';
185
257
  export { BREAKER_COOLDOWN_MS, BREAKER_FAILURES, replicatedClient } from './replica-client';
258
+ export type { ReplayReader } from './replica-position';
259
+ export {
260
+ formatPosition,
261
+ POSITION_CACHE_MS,
262
+ parsePosition,
263
+ primaryPosition,
264
+ replayReader,
265
+ } from './replica-position';
186
266
  export { type DbNode, isPlainRead } from './replica-route';
187
- export type { ReplicaScope } from './replica-scope';
267
+ export type { ReplicaReadsOptions, ReplicaScope } from './replica-scope';
188
268
  export { markScopeWrote, replicaScope, withReplicaReads } from './replica-scope';
269
+ export { recordRetired } from './retired';
270
+ export type { RollingUnsafeKind, RollingUnsafeStatement } from './rolling';
271
+ export {
272
+ hasRollingUnsafeMarker,
273
+ ROLLING_UNSAFE_MARKER,
274
+ retiredColumnsIn,
275
+ rollingUnsafeStatements,
276
+ unretiredColumnsIn,
277
+ } from './rolling';
278
+ export { migrationRollingUnsafe } from './rolling-errors';
279
+ export { rowSecureClient } from './row-secure-client';
280
+ export {
281
+ CROSS_TENANT_SETTING,
282
+ dropRowSecuritySql,
283
+ enableRowSecuritySql,
284
+ ROW_SECURITY_POLICY,
285
+ TENANT_SETTING,
286
+ } from './row-security';
287
+ export { holdMigratorRole, migratorOwnershipSql } from './row-security-migrator';
288
+ export type { RowSecurityPlanInput, TenantTable } from './row-security-plan';
289
+ export { rowSecurityAfter, rowSecurityPlan } from './row-security-plan';
290
+ export type { RowSecurityRole } from './row-security-role';
291
+ export {
292
+ assertRowSecurityBinds,
293
+ rowSecurityExemption,
294
+ rowSecurityRole,
295
+ } from './row-security-role';
296
+ export {
297
+ APP_ROLE,
298
+ assumeRole,
299
+ ensureRowSecurityRoles,
300
+ MIGRATOR_ROLE,
301
+ rowSecurityRolesSql,
302
+ serveAsAppRole,
303
+ } from './row-security-roles';
304
+ export { sameTenantKeyName } from './same-tenant';
189
305
  export { SIBLING_SCOPE_WAIT_MS } from './sibling-turn';
190
306
  export { snapshotJson } from './snapshot-json';
191
307
  export { parseSnapshot } from './snapshot-parse';
@@ -203,13 +319,27 @@ export { stripSqlNoise } from './sql-noise';
203
319
  export { dollarTagAt, endOfBlockComment } from './sql-scan';
204
320
  export type { DbSqlStateCode } from './sqlstate';
205
321
  export { DB_SQLSTATE_CODES, isRetryableState, SQLSTATE, sqlState, sqlStateCode } from './sqlstate';
322
+ export type { StatementBoundOptions } from './statement-bound';
323
+ export { withStatementTimeout } from './statement-bound';
206
324
  export { statementFingerprint, statementKind, statementVerb } from './statement-shape';
207
325
  export { STATEMENT_ATTRIBUTE } from './statement-span';
208
326
  export { statementsOf } from './statement-split';
209
- export { currentTx, liveTxConnection, outsideTransaction, withTransaction } from './transaction';
210
- export { commitUnknown, siblingScopeTimeout, transactionAborted } from './transaction-errors';
327
+ export {
328
+ completeTransaction,
329
+ currentTx,
330
+ liveTxConnection,
331
+ outsideTransaction,
332
+ withTransaction,
333
+ } from './transaction';
334
+ export {
335
+ commitUnknown,
336
+ siblingScopeTimeout,
337
+ transactionAborted,
338
+ } from './transaction-errors';
339
+ export { refuseAbandoned } from './transaction-guard';
211
340
  export type { DbTx, IsolationLevel, TransactionOptions } from './transaction-options';
212
341
  export { beginStatement } from './transaction-options';
342
+ export { setTransactionSettings, transactionSettings } from './transaction-settings';
213
343
  export type { GeneratableForm } from './ungeneratable';
214
344
  export { GENERATABLE_FORMS, ungeneratableStatements } from './ungeneratable';
215
345
  export type { UnrenderedDeclaration } from './unrendered';
@@ -15,6 +15,7 @@ import {
15
15
  unrenderedRows,
16
16
  viewRows,
17
17
  } from './catalog-objects';
18
+ import { policiesOf, policyRows, rowSecurityOf, rowSecurityRows } from './catalog-policies';
18
19
  import {
19
20
  columnRows,
20
21
  constraintRows,
@@ -23,6 +24,7 @@ import {
23
24
  tableRows,
24
25
  } from './catalog-relations';
25
26
  import { type DbClient, db } from './client';
27
+ import { indexDefinitionOf } from './partition-order';
26
28
 
27
29
  export interface IntrospectCatalogOptions {
28
30
  readonly client?: DbClient | undefined;
@@ -60,7 +62,7 @@ async function typesOf(client: DbClient, schema: string): Promise<readonly Catal
60
62
  }
61
63
 
62
64
  /**
63
- * Eleven round trips, sequential: a pinned transaction connection answers one statement at a
65
+ * Fifteen round trips, sequential: a pinned transaction connection answers one statement at a
64
66
  * time, and this runs once per `x db gen`, never per request.
65
67
  */
66
68
  export async function introspectCatalog(
@@ -77,11 +79,15 @@ export async function introspectCatalog(
77
79
  const functions = await functionRows(client, schema);
78
80
  const triggers = await triggerRows(client, schema);
79
81
  const unrendered = await unrenderedRows(client, schema);
82
+ const security = await rowSecurityRows(client, schema);
83
+ const policies = await policyRows(client, schema);
80
84
  const known = new Set(tables.map((table) => table.name));
85
+ // A policy loads only onto a table the dump creates — the rule `loadable` states for a trigger.
86
+ const onKnown = (row: { readonly table_name: string }): boolean => known.has(row.table_name);
81
87
  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`.
88
+ // A trigger loads only onto a relation the dump creates: a table, or a view (`instead of`). One
89
+ // on an inheritance child was filed under `09_triggers/` for a table no file creates, and the
90
+ // load refused the whole dump with `X_SCHEMA_DUMP_DRIFT`.
85
91
  const viewNames = new Set(views.map((view) => view.name));
86
92
  const loadable = (trigger: { readonly table_name: string }): boolean =>
87
93
  known.has(trigger.table_name) || viewNames.has(trigger.table_name);
@@ -99,10 +105,14 @@ export async function introspectCatalog(
99
105
  tables: tables
100
106
  .map((table) => tableOf(table, columns, constraints, sequences))
101
107
  .sort(by((table) => table.name)),
102
- // An index on a relation this reading does not render (a partition) has nowhere to load.
108
+ // An index on a relation this reading does not render (an inheritance child) has nowhere to load.
103
109
  indexes: indexes
104
110
  .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 }))
111
+ .map((row) => ({
112
+ table: row.table_name,
113
+ name: row.name,
114
+ definition: indexDefinitionOf(row.definition),
115
+ }))
106
116
  .sort(
107
117
  by(
108
118
  (index) => index.table,
@@ -124,6 +134,7 @@ export async function introspectCatalog(
124
134
  materialized: row.kind === 'm',
125
135
  options: row.options,
126
136
  definition: row.definition,
137
+ columns: [...row.columns],
127
138
  }))
128
139
  .sort(by((view) => view.name)),
129
140
  functions: functions
@@ -148,8 +159,17 @@ export async function introspectCatalog(
148
159
  (trigger) => trigger.name,
149
160
  ),
150
161
  ),
162
+ rowSecurity: rowSecurityOf(security.filter(onKnown)),
163
+ policies: policiesOf(policies.filter(onKnown)),
151
164
  unrendered: [
152
165
  ...unrendered,
166
+ ...policies
167
+ .filter((policy) => !onKnown(policy))
168
+ .map((policy) => ({
169
+ kind: 'row security policy',
170
+ name: policy.name,
171
+ table_name: policy.table_name,
172
+ })),
153
173
  // Named rather than dropped, the rule `CatalogUnrendered` states.
154
174
  ...triggers
155
175
  .filter((trigger) => !loadable(trigger))
@@ -0,0 +1,82 @@
1
+ // Single responsibility: the catalog ROWS `introspectSchema()` reads, and the pure helpers that turn
2
+ // one into a description field — a key's rule with its list, a table's `NOT VALID` names. Split
3
+ // from `introspect.ts`, which issues the queries and folds the rows into tables.
4
+
5
+ export interface ColumnRow {
6
+ readonly table_name: string;
7
+ readonly column_name: string;
8
+ readonly data_type: string;
9
+ readonly is_nullable: string;
10
+ readonly column_default: string | null;
11
+ readonly ordinal_position: number;
12
+ }
13
+
14
+ export interface IndexRow {
15
+ readonly table_name: string;
16
+ readonly index_name: string;
17
+ readonly is_unique: boolean;
18
+ readonly is_primary: boolean;
19
+ readonly columns: readonly string[];
20
+ readonly predicate: string | null;
21
+ readonly descending: boolean;
22
+ readonly method?: string | undefined;
23
+ }
24
+
25
+ export interface ForeignKeyRow {
26
+ readonly table_name: string;
27
+ readonly constraint_name: string;
28
+ readonly columns: readonly string[];
29
+ readonly referenced_table: string;
30
+ readonly referenced_columns: readonly string[];
31
+ readonly on_delete: string | null;
32
+ /** `set null (a, b)`'s list as the server prints it. Postgres 15+; `null` for any other rule. */
33
+ readonly set_null_columns?: string | null | undefined;
34
+ readonly validated?: boolean | undefined;
35
+ }
36
+
37
+ /**
38
+ * A key's rule in the spelling a description holds, list included. The list reads the definition
39
+ * text, not `confdelsetcols`, which is a column only Postgres 15+ has: this query runs against
40
+ * whatever an operator points it at. The server quotes a reserved word and a description never
41
+ * does, so each name is unquoted or `user` is a permanent `changed-foreign-key`.
42
+ */
43
+ const unquotedName = (name: string): string => {
44
+ const trimmed = name.trim();
45
+ return trimmed.startsWith('"') && trimmed.endsWith('"')
46
+ ? trimmed.slice(1, -1).replaceAll('""', '"')
47
+ : trimmed;
48
+ };
49
+
50
+ export const onDeleteOf = (row: ForeignKeyRow): string | null =>
51
+ row.set_null_columns == null
52
+ ? row.on_delete
53
+ : `set null (${row.set_null_columns.split(',').map(unquotedName).join(', ')})`;
54
+
55
+ /** A CHECK constraint's NAME. There is deliberately no column for its definition — see `checkNames`. */
56
+ export interface CheckRow {
57
+ readonly table_name: string;
58
+ readonly constraint_name: string;
59
+ readonly validated?: boolean | undefined;
60
+ }
61
+
62
+ /** An enabled trigger's NAME — the half of `appendOnly` the catalog can answer. */
63
+ export interface TriggerNameRow {
64
+ readonly table_name: string;
65
+ readonly trigger_name: string;
66
+ }
67
+
68
+ /** Absent when none: `false` only, so a row a fake client wrote without the field is not one. */
69
+ export const notValidOf = (
70
+ rows: readonly {
71
+ readonly table_name: string;
72
+ readonly constraint_name: string;
73
+ readonly validated?: boolean | undefined;
74
+ }[],
75
+ table: string,
76
+ ): { readonly notValidNames?: readonly string[] } => {
77
+ const names = rows
78
+ .filter((row) => row.table_name === table && row.validated === false)
79
+ .map((row) => row.constraint_name)
80
+ .sort();
81
+ return names.length === 0 ? {} : { notValidNames: names };
82
+ };
package/src/introspect.ts CHANGED
@@ -5,6 +5,17 @@
5
5
 
6
6
  import { nonAppRelations } from './app-relation';
7
7
  import { type DbClient, db } from './client';
8
+ import {
9
+ type CheckRow,
10
+ type ColumnRow,
11
+ type ForeignKeyRow,
12
+ type IndexRow,
13
+ notValidOf,
14
+ onDeleteOf,
15
+ type TriggerNameRow,
16
+ } from './introspect-rows';
17
+ import { partitionedTables } from './partitioned-tables';
18
+ import { type PolicyNameRow, policyNameRows } from './row-security-catalog';
8
19
  import { sql } from './sql';
9
20
 
10
21
  export interface ColumnDescription {
@@ -23,6 +34,12 @@ export interface ColumnDescription {
23
34
  * spellings, which is the same rule `IndexDescription.where` states one field down.
24
35
  */
25
36
  readonly generated?: string | undefined;
37
+ /**
38
+ * `true` on a column an entity lists under `retired` and a migration has marked (`retired.ts`):
39
+ * old pods may still read it, so it stays until a later migration drops it. Absent otherwise, and
40
+ * always absent on a catalog read: the database has no such idea.
41
+ */
42
+ readonly retired?: true | undefined;
26
43
  }
27
44
 
28
45
  export interface IndexDescription {
@@ -109,6 +126,11 @@ export interface TableDescription {
109
126
  * read one instead of reporting every declared constraint as missing.
110
127
  */
111
128
  readonly checkNames?: readonly string[] | undefined;
129
+ /**
130
+ * The constraints the catalog holds `NOT VALID` (`convalidated`): rows that predate one are
131
+ * unchecked. A catalog reading, absent when there are none, and never on a sidecar.
132
+ */
133
+ readonly notValidNames?: readonly string[] | undefined;
112
134
  /**
113
135
  * That a migration set `replica identity full` on this table — what `@ultimat3/realtime` requires
114
136
  * of every table a live query subscribes to, and the only reason this generator emits it.
@@ -138,10 +160,57 @@ export interface TableDescription {
138
160
  * Written by `introspectSchema()` always, `[]` included; absent means nobody asked the catalog.
139
161
  */
140
162
  readonly triggerNames?: readonly string[] | undefined;
163
+ /**
164
+ * That the catalog holds this table as a PARTITIONED parent (`relkind = 'p'`). A catalog reading,
165
+ * `true` or absent, never on a sidecar: a partitioned table is declared by a hand edit to the
166
+ * generated `create table`, which no entity records, and drift compares what still holds on the
167
+ * parent — columns, keys, indexes. Absent on every ordinary table, so an unpartitioned app's
168
+ * descriptions are unchanged. Its partitions are Postgres' and appear in no description.
169
+ */
170
+ readonly partitioned?: true | undefined;
171
+ /**
172
+ * That a migration gave this table the tenant policy, enabled and forced (`row-security-plan.ts`).
173
+ * The SNAPSHOT's half, `true` or absent for `appendOnly`'s reason; the catalog's is `policyNames`.
174
+ */
175
+ readonly rowSecurity?: true | undefined;
176
+ /**
177
+ * The CATALOG's half of `rowSecurity`: the policies on the table when its row security is enabled
178
+ * and forced, `[]` otherwise (`row-security-catalog.ts`). Absent means nobody asked the catalog.
179
+ */
180
+ readonly policyNames?: readonly string[] | undefined;
181
+ /**
182
+ * The lifecycle triggers a migration installed on this table — history, its guard, counters
183
+ * (`generate-triggers.ts`). The SNAPSHOT's half, absent when none; the catalog's is `triggerNames`.
184
+ */
185
+ readonly triggers?: readonly TriggerRecord[] | undefined;
186
+ /**
187
+ * That the entity declares `retain` (`drift-triggers.ts` refuses it on a partitioned parent).
188
+ * SNAPSHOT-only, `true` or absent for `appendOnly`'s reason.
189
+ */
190
+ readonly retain?: true | undefined;
191
+ }
192
+
193
+ /** One installed trigger: its name, and the statements that install it — the drift repair too. */
194
+ export interface TriggerRecord {
195
+ readonly name: string;
196
+ readonly install: readonly string[];
197
+ }
198
+
199
+ /**
200
+ * A view `x db gen` wrote (`generate-view.ts`): SNAPSHOT-only, as `checks` is declared-only — the
201
+ * catalog reading never fills it (`app-relation.ts` keeps every view out of `tables`), so drift
202
+ * never reports one. `create` is the statements verbatim, which is what the next diff compares.
203
+ */
204
+ export interface ViewDescription {
205
+ readonly name: string;
206
+ readonly materialized?: true | undefined;
207
+ readonly create: readonly string[];
141
208
  }
142
209
 
143
210
  export interface SchemaDescription {
144
211
  readonly tables: readonly TableDescription[];
212
+ /** Absent on every snapshot written before views existed, and on every catalog reading. */
213
+ readonly views?: readonly ViewDescription[] | undefined;
145
214
  }
146
215
 
147
216
  export interface IntrospectOptions {
@@ -157,47 +226,6 @@ export interface IntrospectOptions {
157
226
  readonly exclude?: readonly string[] | undefined;
158
227
  }
159
228
 
160
- interface ColumnRow {
161
- readonly table_name: string;
162
- readonly column_name: string;
163
- readonly data_type: string;
164
- readonly is_nullable: string;
165
- readonly column_default: string | null;
166
- readonly ordinal_position: number;
167
- }
168
-
169
- interface IndexRow {
170
- readonly table_name: string;
171
- readonly index_name: string;
172
- readonly is_unique: boolean;
173
- readonly is_primary: boolean;
174
- readonly columns: readonly string[];
175
- readonly predicate: string | null;
176
- readonly descending: boolean;
177
- readonly method?: string | undefined;
178
- }
179
-
180
- interface ForeignKeyRow {
181
- readonly table_name: string;
182
- readonly constraint_name: string;
183
- readonly columns: readonly string[];
184
- readonly referenced_table: string;
185
- readonly referenced_columns: readonly string[];
186
- readonly on_delete: string | null;
187
- }
188
-
189
- /** A CHECK constraint's NAME. There is deliberately no column for its definition — see `checkNames`. */
190
- interface CheckRow {
191
- readonly table_name: string;
192
- readonly constraint_name: string;
193
- }
194
-
195
- /** An enabled trigger's NAME — the half of `appendOnly` the catalog can answer. */
196
- interface TriggerNameRow {
197
- readonly table_name: string;
198
- readonly trigger_name: string;
199
- }
200
-
201
229
  const byName = (a: { name: string }, b: { name: string }): number => (a.name < b.name ? -1 : 1);
202
230
 
203
231
  export async function introspectSchema(
@@ -272,7 +300,7 @@ export async function introspectSchema(
272
300
  join pg_am am on am.oid = i.relam
273
301
  cross join lateral unnest(ix.indkey::smallint[]) with ordinality as k(attnum, ord)
274
302
  left join pg_attribute a on a.attrelid = t.oid and a.attnum = k.attnum and k.attnum > 0
275
- where n.nspname = ${schema} and t.relkind = 'r' and k.ord <= ix.indnkeyatts
303
+ where n.nspname = ${schema} and t.relkind in ('r', 'p') and k.ord <= ix.indnkeyatts
276
304
  group by
277
305
  t.relname, i.relname, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid,
278
306
  ix.indexrelid, am.amname
@@ -284,6 +312,8 @@ export async function introspectSchema(
284
312
  // two-column key came back as four source columns against four referenced ones, duplicated and
285
313
  // misaligned. Ordered by `k.ord` (the constraint's own key position), never by `attnum`, for the
286
314
  // same reason `indkey` orders the index query: `references t (y, x)` is not `references t (x, y)`.
315
+ // `conparentid = 0`: a key into a partitioned table makes Postgres add one clone per partition
316
+ // (`notes_event_fkey_1`, `_2`, …), each a fact of the partitioning and never of a migration.
287
317
  const foreignKeys = await client.query<ForeignKeyRow>(sql`
288
318
  select
289
319
  src.relname as table_name,
@@ -291,7 +321,9 @@ export async function introspectSchema(
291
321
  array_agg(sa.attname order by k.ord) as columns,
292
322
  tgt.relname as referenced_table,
293
323
  array_agg(ta.attname order by k.ord) as referenced_columns,
294
- c.confdeltype as on_delete
324
+ c.confdeltype as on_delete,
325
+ substring(pg_get_constraintdef(c.oid) from 'ON DELETE SET NULL \\(([^)]*)\\)') as set_null_columns,
326
+ c.convalidated as validated
295
327
  from pg_constraint c
296
328
  join pg_class src on src.oid = c.conrelid
297
329
  join pg_class tgt on tgt.oid = c.confrelid
@@ -299,8 +331,8 @@ export async function introspectSchema(
299
331
  cross join lateral unnest(c.conkey, c.confkey) with ordinality as k(src_attnum, tgt_attnum, ord)
300
332
  join pg_attribute sa on sa.attrelid = src.oid and sa.attnum = k.src_attnum
301
333
  join pg_attribute ta on ta.attrelid = tgt.oid and ta.attnum = k.tgt_attnum
302
- where c.contype = 'f' and n.nspname = ${schema}
303
- group by src.relname, c.conname, tgt.relname, c.confdeltype
334
+ where c.contype = 'f' and n.nspname = ${schema} and c.conparentid = 0
335
+ group by c.oid, src.relname, c.conname, tgt.relname, c.confdeltype, c.convalidated
304
336
  order by src.relname, c.conname
305
337
  `);
306
338
 
@@ -310,11 +342,11 @@ export async function introspectSchema(
310
342
  // a correct database as wrong. `contype = 'c'` is the CHECK constraints alone — Postgres 17
311
343
  // onwards records a NOT NULL as `'n'`, and a domain's as `'c'` on the domain rather than here.
312
344
  const checks = await client.query<CheckRow>(sql`
313
- select src.relname as table_name, c.conname as constraint_name
345
+ select src.relname as table_name, c.conname as constraint_name, c.convalidated as validated
314
346
  from pg_constraint c
315
347
  join pg_class src on src.oid = c.conrelid
316
348
  join pg_namespace n on n.oid = src.relnamespace
317
- where c.contype = 'c' and n.nspname = ${schema} and src.relkind = 'r'
349
+ where c.contype = 'c' and n.nspname = ${schema} and src.relkind in ('r', 'p')
318
350
  order by src.relname, c.conname
319
351
  `);
320
352
 
@@ -326,11 +358,24 @@ export async function introspectSchema(
326
358
  join pg_class src on src.oid = t.tgrelid
327
359
  join pg_namespace n on n.oid = src.relnamespace
328
360
  where not t.tgisinternal and t.tgenabled in ('O', 'A')
329
- and n.nspname = ${schema} and src.relkind = 'r'
361
+ and n.nspname = ${schema} and src.relkind in ('r', 'p')
330
362
  order by src.relname, t.tgname
331
363
  `);
332
364
 
333
- return buildSchema(schema, excluded, columns, indexes, foreignKeys, checks, triggers);
365
+ const partitioned = await partitionedTables(client, schema);
366
+ const policies = await policyNameRows(client, schema);
367
+
368
+ return buildSchema(
369
+ schema,
370
+ excluded,
371
+ columns,
372
+ indexes,
373
+ foreignKeys,
374
+ checks,
375
+ triggers,
376
+ partitioned,
377
+ policies,
378
+ );
334
379
  }
335
380
 
336
381
  /** Pure, so the row -> description mapping is testable without a database. */
@@ -343,6 +388,9 @@ export function buildSchema(
343
388
  checks: readonly CheckRow[] = [],
344
389
  /** Absent leaves `triggerNames` off every table: nobody asked the catalog. */
345
390
  triggers?: readonly TriggerNameRow[],
391
+ partitioned: readonly string[] = [],
392
+ /** Absent leaves `policyNames` off every table, as `triggers` does `triggerNames`. */
393
+ policies?: readonly PolicyNameRow[],
346
394
  ): SchemaDescription {
347
395
  const names = [...new Set(columns.map((row) => row.table_name))]
348
396
  .filter((name) => !excluded.includes(name))
@@ -385,7 +433,7 @@ export function buildSchema(
385
433
  columns: [...row.columns],
386
434
  referencedTable: row.referenced_table,
387
435
  referencedColumns: [...row.referenced_columns],
388
- onDelete: row.on_delete,
436
+ onDelete: onDeleteOf(row),
389
437
  }))
390
438
  .sort(byName),
391
439
  // Always written, `[]` included: this reading of a table HAS asked the catalog, and absence
@@ -394,6 +442,7 @@ export function buildSchema(
394
442
  .filter((row) => row.table_name === name)
395
443
  .map((row) => row.constraint_name)
396
444
  .sort(),
445
+ ...notValidOf([...foreignKeys, ...checks], name),
397
446
  ...(triggers === undefined
398
447
  ? {}
399
448
  : {
@@ -402,6 +451,15 @@ export function buildSchema(
402
451
  .map((row) => row.trigger_name)
403
452
  .sort(),
404
453
  }),
454
+ ...(partitioned.includes(name) ? { partitioned: true as const } : {}),
455
+ ...(policies === undefined
456
+ ? {}
457
+ : {
458
+ policyNames: policies
459
+ .filter((row) => row.table_name === name)
460
+ .map((row) => row.policy_name)
461
+ .sort(),
462
+ }),
405
463
  };
406
464
  });
407
465