@prisma-next/adapter-postgres 0.12.0-dev.9 → 0.13.0-dev.1

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.
@@ -1,21 +1,35 @@
1
- import type { Contract, ContractMarkerRecord } from '@prisma-next/contract/types';
2
- import { parseMarkerRowSafely, withMarkerReadErrorHandling } from '@prisma-next/errors/execution';
1
+ import type {
2
+ Contract,
3
+ ContractMarkerRecord,
4
+ LedgerEntryRecord,
5
+ } from '@prisma-next/contract/types';
6
+ import {
7
+ parseMarkerRowSafely,
8
+ rethrowMarkerReadError,
9
+ withMarkerReadErrorHandling,
10
+ } from '@prisma-next/errors/execution';
3
11
  import type { SqlControlAdapter } from '@prisma-next/family-sql/control-adapter';
4
12
  import { parseContractMarkerRow } from '@prisma-next/family-sql/verify';
5
13
  import type { CodecLookup } from '@prisma-next/framework-components/codec';
6
- import {
7
- APP_SPACE_ID,
8
- type ControlDriverInstance,
9
- } from '@prisma-next/framework-components/control';
14
+ import { APP_SPACE_ID } from '@prisma-next/framework-components/control';
10
15
  import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
11
- import type { PostgresEnumStorageEntry, SqlStorage } from '@prisma-next/sql-contract/types';
16
+ import { ledgerOriginFromStored } from '@prisma-next/migration-tools/ledger-origin';
17
+ import type {
18
+ PostgresEnumStorageEntry,
19
+ SqlControlDriverInstance,
20
+ SqlStorage,
21
+ } from '@prisma-next/sql-contract/types';
12
22
  import type {
13
23
  AnyQueryAst,
24
+ DdlNode,
14
25
  LoweredStatement,
15
26
  LowererContext,
27
+ MarkerReadResult,
16
28
  } from '@prisma-next/sql-relational-core/ast';
29
+ import { isDdlNode } from '@prisma-next/sql-relational-core/ast';
17
30
  import type {
18
31
  PrimaryKey,
32
+ SqlCheckConstraintIRInput,
19
33
  SqlColumnIR,
20
34
  SqlForeignKeyIR,
21
35
  SqlIndexIR,
@@ -24,6 +38,11 @@ import type {
24
38
  SqlTableIR,
25
39
  SqlUniqueIR,
26
40
  } from '@prisma-next/sql-schema-ir/types';
41
+ import {
42
+ buildControlTableBootstrapQueries,
43
+ buildSignMarkerBootstrapQueries,
44
+ } from '@prisma-next/target-postgres/contract-free';
45
+ import type { PostgresDdlNode } from '@prisma-next/target-postgres/ddl';
27
46
  import { parsePostgresDefault } from '@prisma-next/target-postgres/default-normalizer';
28
47
  import {
29
48
  createResolveExistingEnumValues,
@@ -35,14 +54,35 @@ import { normalizeSchemaNativeType } from '@prisma-next/target-postgres/native-t
35
54
  import { blindCast } from '@prisma-next/utils/casts';
36
55
  import { ifDefined } from '@prisma-next/utils/defined';
37
56
  import { createPostgresBuiltinCodecLookup } from './codec-lookup';
57
+ import { renderLoweredDdl } from './ddl-renderer';
38
58
  import {
39
59
  introspectPostgresEnumTypes,
40
60
  type PostgresEnumStorageTypeAnnotation,
41
61
  } from './enum-control-hooks';
62
+ import {
63
+ execute,
64
+ infoSchemaTables,
65
+ ledger,
66
+ ledgerReadShape,
67
+ marker,
68
+ mergeInvariants,
69
+ NOW,
70
+ } from './marker-ledger';
42
71
  import { renderLoweredSql } from './sql-renderer';
43
72
  import type { PostgresContract } from './types';
44
73
 
45
74
  const POSTGRES_MARKER_TABLE = 'prisma_contract.marker';
75
+ const POSTGRES_LEDGER_TABLE = 'prisma_contract.ledger';
76
+
77
+ type PostgresLedgerRow = {
78
+ readonly space: string;
79
+ readonly migration_name: string;
80
+ readonly migration_hash: string;
81
+ readonly origin_core_hash: string | null;
82
+ readonly destination_core_hash: string;
83
+ readonly operations: unknown;
84
+ readonly created_at: Date | string;
85
+ };
46
86
 
47
87
  /**
48
88
  * Postgres control plane adapter for control-plane operations like introspection.
@@ -99,6 +139,14 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
99
139
  readonly resolveExistingEnumValuesForContract = (contract: Contract<SqlStorage>) =>
100
140
  createResolveExistingEnumValues(contract.storage);
101
141
 
142
+ bootstrapControlTableQueries(): readonly DdlNode[] {
143
+ return buildControlTableBootstrapQueries();
144
+ }
145
+
146
+ bootstrapSignMarkerQueries(): readonly DdlNode[] {
147
+ return buildSignMarkerBootstrapQueries();
148
+ }
149
+
102
150
  /**
103
151
  * Lower a SQL query AST into a Postgres-flavored `{ sql, params }` payload.
104
152
  *
@@ -107,7 +155,10 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
107
155
  * and contract. Used at migration plan/emit time (e.g. by `dataTransform`)
108
156
  * without instantiating the runtime adapter.
109
157
  */
110
- lower(ast: AnyQueryAst, context: LowererContext<unknown>): LoweredStatement {
158
+ lower(ast: AnyQueryAst | PostgresDdlNode, context: LowererContext<unknown>): LoweredStatement {
159
+ if (isDdlNode(ast)) {
160
+ return renderLoweredDdl(ast);
161
+ }
111
162
  return renderLoweredSql(ast, context.contract as PostgresContract, this.codecLookup);
112
163
  }
113
164
 
@@ -120,55 +171,19 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
120
171
  * parse errors, so we probe before reading.
121
172
  */
122
173
  async readMarker(
123
- driver: ControlDriverInstance<'sql', 'postgres'>,
174
+ driver: SqlControlDriverInstance<'postgres'>,
124
175
  space: string,
125
176
  ): Promise<ContractMarkerRecord | null> {
126
- const markerContext = { space, markerLocation: POSTGRES_MARKER_TABLE };
127
- const exists = await withMarkerReadErrorHandling(
128
- () =>
129
- driver.query(
130
- `select 1
131
- from information_schema.tables
132
- where table_schema = $1 and table_name = $2`,
133
- ['prisma_contract', 'marker'],
134
- ),
135
- markerContext,
136
- );
137
- if (exists.rows.length === 0) {
138
- return null;
139
- }
140
-
141
- const result = await withMarkerReadErrorHandling(
142
- () =>
143
- driver.query<{
144
- core_hash: string;
145
- profile_hash: string;
146
- contract_json: unknown | null;
147
- canonical_version: number | null;
148
- updated_at: Date | string;
149
- app_tag: string | null;
150
- meta: unknown | null;
151
- invariants: readonly string[];
152
- }>(
153
- `select
154
- core_hash,
155
- profile_hash,
156
- contract_json,
157
- canonical_version,
158
- updated_at,
159
- app_tag,
160
- meta,
161
- invariants
162
- from prisma_contract.marker
163
- where space = $1`,
164
- [space],
165
- ),
166
- markerContext,
167
- );
177
+ const result = await this.readMarkerDiscriminated(driver, space);
178
+ return result.kind === 'present' ? result.record : null;
179
+ }
168
180
 
169
- const row = result.rows[0];
170
- if (!row) return null;
171
- return parseMarkerRowSafely(row, parseContractMarkerRow, markerContext);
181
+ async readMarkerDiscriminated(
182
+ driver: SqlControlDriverInstance<'postgres'>,
183
+ space: string,
184
+ ): Promise<MarkerReadResult> {
185
+ const markerContext = { space, markerLocation: POSTGRES_MARKER_TABLE };
186
+ return withMarkerReadErrorHandling(() => this.readMarkerResult(driver, space), markerContext);
172
187
  }
173
188
 
174
189
  /**
@@ -178,54 +193,53 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
178
193
  * map rather than raising "relation does not exist".
179
194
  */
180
195
  async readAllMarkers(
181
- driver: ControlDriverInstance<'sql', 'postgres'>,
196
+ driver: SqlControlDriverInstance<'postgres'>,
182
197
  ): Promise<ReadonlyMap<string, ContractMarkerRecord>> {
183
198
  const markerContext = { space: APP_SPACE_ID, markerLocation: POSTGRES_MARKER_TABLE };
184
- const exists = await withMarkerReadErrorHandling(
185
- () =>
186
- driver.query(
187
- `select 1
188
- from information_schema.tables
189
- where table_schema = $1 and table_name = $2`,
190
- ['prisma_contract', 'marker'],
191
- ),
192
- markerContext,
193
- );
194
- if (exists.rows.length === 0) {
199
+ return withMarkerReadErrorHandling(() => this.readAllMarkersResult(driver), markerContext);
200
+ }
201
+
202
+ private async readAllMarkersResult(
203
+ driver: SqlControlDriverInstance<'postgres'>,
204
+ ): Promise<ReadonlyMap<string, ContractMarkerRecord>> {
205
+ const lower = (query: AnyQueryAst) => this.lower(query, { contract: undefined });
206
+ const probe = infoSchemaTables
207
+ .select(infoSchemaTables.table_schema)
208
+ .where(
209
+ infoSchemaTables.table_schema
210
+ .eq('prisma_contract')
211
+ .and(infoSchemaTables.table_name.eq('marker')),
212
+ )
213
+ .build();
214
+ const exists = await execute(lower, driver, probe);
215
+ if (exists.length === 0) {
195
216
  return new Map();
196
217
  }
197
218
 
198
- const result = await withMarkerReadErrorHandling(
199
- () =>
200
- driver.query<{
201
- space: string;
202
- core_hash: string;
203
- profile_hash: string;
204
- contract_json: unknown | null;
205
- canonical_version: number | null;
206
- updated_at: Date | string;
207
- app_tag: string | null;
208
- meta: unknown | null;
209
- invariants: readonly string[];
210
- }>(
211
- `select
212
- space,
213
- core_hash,
214
- profile_hash,
215
- contract_json,
216
- canonical_version,
217
- updated_at,
218
- app_tag,
219
- meta,
220
- invariants
221
- from prisma_contract.marker`,
222
- ),
223
- markerContext,
224
- );
219
+ await this.assertMarkerTableHasSpaceColumn(driver, APP_SPACE_ID);
220
+
221
+ const fetch = marker
222
+ .select(
223
+ marker.space,
224
+ marker.core_hash,
225
+ marker.profile_hash,
226
+ marker.contract_json,
227
+ marker.canonical_version,
228
+ marker.updated_at,
229
+ marker.app_tag,
230
+ marker.meta,
231
+ marker.invariants,
232
+ )
233
+ .build();
234
+ const rawRows = await execute(lower, driver, fetch);
235
+ const rows = blindCast<
236
+ ReadonlyArray<{ space: string } & Record<string, unknown>>,
237
+ 'Driver returns rows shaped by SELECT'
238
+ >(rawRows);
225
239
 
226
- const rows = new Map<string, ContractMarkerRecord>();
227
- for (const row of result.rows) {
228
- rows.set(
240
+ const out = new Map<string, ContractMarkerRecord>();
241
+ for (const row of rows) {
242
+ out.set(
229
243
  row.space,
230
244
  parseMarkerRowSafely(row, parseContractMarkerRow, {
231
245
  space: row.space,
@@ -233,7 +247,271 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
233
247
  }),
234
248
  );
235
249
  }
236
- return rows;
250
+ return out;
251
+ }
252
+
253
+ /**
254
+ * Reads per-migration ledger rows from `prisma_contract.ledger` in apply
255
+ * order. Probes `information_schema.tables` first so a fresh database
256
+ * without the ledger table returns `[]` instead of raising "relation does
257
+ * not exist".
258
+ */
259
+ async readLedger(
260
+ driver: SqlControlDriverInstance<'postgres'>,
261
+ space?: string,
262
+ ): Promise<readonly LedgerEntryRecord[]> {
263
+ const ledgerContext = { space: space ?? '*', markerLocation: POSTGRES_LEDGER_TABLE };
264
+ return withMarkerReadErrorHandling(() => this.readLedgerResult(driver, space), ledgerContext);
265
+ }
266
+
267
+ private async readLedgerResult(
268
+ driver: SqlControlDriverInstance<'postgres'>,
269
+ space: string | undefined,
270
+ ): Promise<readonly LedgerEntryRecord[]> {
271
+ const lower = (query: AnyQueryAst) => this.lower(query, { contract: undefined });
272
+ const probe = infoSchemaTables
273
+ .select(infoSchemaTables.table_schema)
274
+ .where(
275
+ infoSchemaTables.table_schema
276
+ .eq('prisma_contract')
277
+ .and(infoSchemaTables.table_name.eq('ledger')),
278
+ )
279
+ .build();
280
+ const exists = await execute(lower, driver, probe);
281
+ if (exists.length === 0) {
282
+ return [];
283
+ }
284
+
285
+ const base = ledgerReadShape.select(
286
+ ledgerReadShape.space,
287
+ ledgerReadShape.migration_name,
288
+ ledgerReadShape.migration_hash,
289
+ ledgerReadShape.origin_core_hash,
290
+ ledgerReadShape.destination_core_hash,
291
+ ledgerReadShape.operations,
292
+ ledgerReadShape.created_at,
293
+ );
294
+ const filtered = space !== undefined ? base.where(ledgerReadShape.space.eq(space)) : base;
295
+ const rawRows = await execute(lower, driver, filtered.orderBy(ledgerReadShape.id).build());
296
+ const rows = blindCast<readonly PostgresLedgerRow[], 'Driver returns rows shaped by SELECT'>(
297
+ rawRows,
298
+ );
299
+
300
+ return rows.map((row) => {
301
+ const appliedAt = row.created_at instanceof Date ? row.created_at : new Date(row.created_at);
302
+ return {
303
+ space: row.space,
304
+ migrationName: row.migration_name,
305
+ migrationHash: row.migration_hash,
306
+ from: ledgerOriginFromStored(row.origin_core_hash),
307
+ to: row.destination_core_hash,
308
+ appliedAt,
309
+ operationCount: Array.isArray(row.operations) ? row.operations.length : 0,
310
+ };
311
+ });
312
+ }
313
+
314
+ /**
315
+ * Stamps the initial marker row for `space` via the shared contract-free DML
316
+ * builder, lowered through {@link lower} and executed on the driver. See the
317
+ * `SqlControlAdapter.initMarker` contract.
318
+ */
319
+ async insertMarker(
320
+ driver: SqlControlDriverInstance<'postgres'>,
321
+ space: string,
322
+ destination: {
323
+ readonly storageHash: string;
324
+ readonly profileHash: string;
325
+ readonly invariants?: readonly string[];
326
+ },
327
+ ): Promise<void> {
328
+ await execute(
329
+ (query) => this.lower(query, { contract: undefined }),
330
+ driver,
331
+ marker
332
+ .insert({
333
+ space,
334
+ core_hash: destination.storageHash,
335
+ profile_hash: destination.profileHash,
336
+ contract_json: null,
337
+ canonical_version: null,
338
+ updated_at: NOW,
339
+ app_tag: null,
340
+ meta: {},
341
+ invariants: destination.invariants ?? [],
342
+ })
343
+ .build(),
344
+ );
345
+ }
346
+
347
+ async initMarker(
348
+ driver: SqlControlDriverInstance<'postgres'>,
349
+ space: string,
350
+ destination: {
351
+ readonly storageHash: string;
352
+ readonly profileHash: string;
353
+ readonly invariants?: readonly string[];
354
+ },
355
+ ): Promise<void> {
356
+ await execute(
357
+ (query) => this.lower(query, { contract: undefined }),
358
+ driver,
359
+ marker
360
+ .upsert({
361
+ space,
362
+ core_hash: destination.storageHash,
363
+ profile_hash: destination.profileHash,
364
+ contract_json: null,
365
+ canonical_version: null,
366
+ updated_at: NOW,
367
+ app_tag: null,
368
+ meta: {},
369
+ invariants: destination.invariants ?? [],
370
+ })
371
+ .onConflict(marker.space)
372
+ .doUpdate((excluded) => ({
373
+ core_hash: excluded.core_hash,
374
+ profile_hash: excluded.profile_hash,
375
+ contract_json: excluded.contract_json,
376
+ canonical_version: excluded.canonical_version,
377
+ updated_at: NOW,
378
+ app_tag: excluded.app_tag,
379
+ meta: excluded.meta,
380
+ invariants: excluded.invariants,
381
+ }))
382
+ .build(),
383
+ );
384
+ }
385
+
386
+ /**
387
+ * Compare-and-swap advance of the marker row for `space`. See the
388
+ * `SqlControlAdapter.updateMarker` contract.
389
+ */
390
+ async updateMarker(
391
+ driver: SqlControlDriverInstance<'postgres'>,
392
+ space: string,
393
+ expectedFrom: string,
394
+ destination: {
395
+ readonly storageHash: string;
396
+ readonly profileHash: string;
397
+ readonly invariants?: readonly string[];
398
+ },
399
+ ): Promise<boolean> {
400
+ const currentInvariants =
401
+ destination.invariants === undefined
402
+ ? []
403
+ : ((await this.readMarker(driver, space))?.invariants ?? []);
404
+ const mergedInvariants =
405
+ destination.invariants === undefined
406
+ ? undefined
407
+ : mergeInvariants(currentInvariants, destination.invariants);
408
+
409
+ const query = marker
410
+ .update()
411
+ .set({
412
+ core_hash: destination.storageHash,
413
+ profile_hash: destination.profileHash,
414
+ updated_at: NOW,
415
+ ...(mergedInvariants !== undefined ? { invariants: mergedInvariants } : {}),
416
+ })
417
+ .where(marker.space.eq(space).and(marker.core_hash.eq(expectedFrom)))
418
+ .returning(marker.space)
419
+ .build();
420
+
421
+ const rows = await execute((q) => this.lower(q, { contract: undefined }), driver, query);
422
+ return rows.length > 0;
423
+ }
424
+
425
+ /**
426
+ * Appends a ledger entry for `space`. See the
427
+ * `SqlControlAdapter.writeLedgerEntry` contract.
428
+ */
429
+ async writeLedgerEntry(
430
+ driver: SqlControlDriverInstance<'postgres'>,
431
+ space: string,
432
+ entry: {
433
+ readonly edgeId: string;
434
+ readonly from: string;
435
+ readonly to: string;
436
+ readonly migrationName: string;
437
+ readonly migrationHash: string;
438
+ readonly operations: readonly unknown[];
439
+ },
440
+ ): Promise<void> {
441
+ await execute(
442
+ (query) => this.lower(query, { contract: undefined }),
443
+ driver,
444
+ ledger
445
+ .insert({
446
+ space,
447
+ migration_name: entry.migrationName,
448
+ migration_hash: entry.migrationHash,
449
+ origin_core_hash: entry.from,
450
+ destination_core_hash: entry.to,
451
+ operations: entry.operations,
452
+ })
453
+ .build(),
454
+ );
455
+ }
456
+
457
+ private async assertMarkerTableHasSpaceColumn(
458
+ driver: SqlControlDriverInstance<'postgres'>,
459
+ space: string,
460
+ ): Promise<void> {
461
+ const result = await driver.query<{ column_name: string }>(
462
+ `select column_name
463
+ from information_schema.columns
464
+ where table_schema = 'prisma_contract'
465
+ and table_name = 'marker'`,
466
+ );
467
+ const rows = result.rows;
468
+ if (rows.length === 0) {
469
+ return;
470
+ }
471
+ if (!rows.every((row) => typeof row.column_name === 'string')) {
472
+ return;
473
+ }
474
+ if (rows.some((row) => row.column_name === 'space')) {
475
+ return;
476
+ }
477
+ rethrowMarkerReadError(new Error('column "space" does not exist'), {
478
+ space,
479
+ markerLocation: POSTGRES_MARKER_TABLE,
480
+ });
481
+ }
482
+
483
+ private async readMarkerResult(driver: SqlControlDriverInstance<'postgres'>, space: string) {
484
+ const lower = (query: AnyQueryAst) => this.lower(query, { contract: undefined });
485
+ const probe = infoSchemaTables
486
+ .select(infoSchemaTables.table_schema)
487
+ .where(
488
+ infoSchemaTables.table_schema
489
+ .eq('prisma_contract')
490
+ .and(infoSchemaTables.table_name.eq('marker')),
491
+ )
492
+ .build();
493
+ const exists = await execute(lower, driver, probe);
494
+ if (exists.length === 0) return { kind: 'no-table' as const };
495
+
496
+ await this.assertMarkerTableHasSpaceColumn(driver, space);
497
+
498
+ const fetch = marker
499
+ .select(
500
+ marker.core_hash,
501
+ marker.profile_hash,
502
+ marker.contract_json,
503
+ marker.canonical_version,
504
+ marker.updated_at,
505
+ marker.app_tag,
506
+ marker.meta,
507
+ marker.invariants,
508
+ )
509
+ .where(marker.space.eq(space))
510
+ .build();
511
+ const result = await execute(lower, driver, fetch);
512
+ const row = result[0];
513
+ if (!row) return { kind: 'absent' as const };
514
+ return { kind: 'present' as const, record: parseContractMarkerRow(row) };
237
515
  }
238
516
 
239
517
  /**
@@ -255,13 +533,13 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
255
533
  * Uses batched queries to minimize database round trips (6 queries per
256
534
  * schema walked).
257
535
  *
258
- * @param driver - ControlDriverInstance<'sql', 'postgres'> instance for executing queries
536
+ * @param driver - SqlControlDriverInstance<'postgres'> instance for executing queries
259
537
  * @param contract - Optional contract for contract-guided introspection (multi-namespace walk, filtering)
260
538
  * @param schema - Schema name to introspect when no contract is provided (defaults to 'public')
261
539
  * @returns Promise resolving to SqlSchemaIR representing the live database schema
262
540
  */
263
541
  async introspect(
264
- driver: ControlDriverInstance<'sql', 'postgres'>,
542
+ driver: SqlControlDriverInstance<'postgres'>,
265
543
  contract?: unknown,
266
544
  schema = 'public',
267
545
  ): Promise<SqlSchemaIR> {
@@ -295,7 +573,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
295
573
  * `CREATE SCHEMA` before table DDL.
296
574
  */
297
575
  private async listExistingSchemas(
298
- driver: ControlDriverInstance<'sql', 'postgres'>,
576
+ driver: SqlControlDriverInstance<'postgres'>,
299
577
  ): Promise<readonly string[]> {
300
578
  const result = await driver.query<{ nspname: string }>(
301
579
  `SELECT nspname
@@ -316,23 +594,29 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
316
594
  * table regardless of which namespace it lives in.
317
595
  */
318
596
  private async introspectNamespaces(
319
- driver: ControlDriverInstance<'sql', 'postgres'>,
597
+ driver: SqlControlDriverInstance<'postgres'>,
320
598
  namespaceIds: readonly string[],
321
599
  ): Promise<SqlSchemaIR> {
322
- const resolvedSchemas = await Promise.all(
323
- namespaceIds.map(async (id) => {
324
- if (id === UNBOUND_NAMESPACE_ID) {
325
- const { rows } = await driver.query<{ current_schema: string }>(
326
- 'SELECT current_schema() AS current_schema',
327
- );
328
- return rows[0]?.current_schema ?? 'public';
329
- }
330
- return id;
331
- }),
332
- );
600
+ const resolvedSchemas: string[] = [];
601
+ for (const id of namespaceIds) {
602
+ if (id === UNBOUND_NAMESPACE_ID) {
603
+ const { rows } = await driver.query<{ current_schema: string }>(
604
+ 'SELECT current_schema() AS current_schema',
605
+ );
606
+ resolvedSchemas.push(rows[0]?.current_schema ?? 'public');
607
+ } else {
608
+ resolvedSchemas.push(id);
609
+ }
610
+ }
333
611
  const uniqueSchemas = Array.from(new Set(resolvedSchemas));
334
612
 
335
- const perSchema = await Promise.all(uniqueSchemas.map((s) => this.introspectSchema(driver, s)));
613
+ // Walk schemas sequentially: every introspectSchema call shares the one
614
+ // control connection, so a parallel walk only serialises behind the wire
615
+ // protocol and trips pg's "already executing a query" deprecation.
616
+ const perSchema: SqlSchemaIR[] = [];
617
+ for (const schema of uniqueSchemas) {
618
+ perSchema.push(await this.introspectSchema(driver, schema));
619
+ }
336
620
 
337
621
  const mergedTables: Record<string, SqlTableIR> = {};
338
622
  for (const ir of perSchema) {
@@ -380,35 +664,36 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
380
664
  * the per-namespace walk.
381
665
  */
382
666
  private async introspectSchema(
383
- driver: ControlDriverInstance<'sql', 'postgres'>,
667
+ driver: SqlControlDriverInstance<'postgres'>,
384
668
  schema: string,
385
669
  ): Promise<SqlSchemaIR> {
386
- // Execute all queries in parallel for efficiency (6 queries instead of 5T+1)
387
- const [tablesResult, columnsResult, pkResult, fkResult, uniqueResult, indexResult] =
388
- await Promise.all([
389
- // Query all tables
390
- driver.query<{ table_name: string }>(
391
- `SELECT table_name
670
+ // Issue the schema-wide queries one at a time. A single control connection
671
+ // serialises queries anyway, so Promise.all buys no parallelism here and
672
+ // makes pg emit a "client is already executing a query" deprecation. One
673
+ // schema-wide query per relation kind keeps this to 7 round-trips, not 6T+1.
674
+ // Query all tables
675
+ const tablesResult = await driver.query<{ table_name: string }>(
676
+ `SELECT table_name
392
677
  FROM information_schema.tables
393
678
  WHERE table_schema = $1
394
679
  AND table_type = 'BASE TABLE'
395
680
  ORDER BY table_name`,
396
- [schema],
397
- ),
398
- // Query all columns for all tables in schema
399
- driver.query<{
400
- table_name: string;
401
- column_name: string;
402
- data_type: string;
403
- udt_name: string;
404
- is_nullable: string;
405
- character_maximum_length: number | null;
406
- numeric_precision: number | null;
407
- numeric_scale: number | null;
408
- column_default: string | null;
409
- formatted_type: string | null;
410
- }>(
411
- `SELECT
681
+ [schema],
682
+ );
683
+ // Query all columns for all tables in schema
684
+ const columnsResult = await driver.query<{
685
+ table_name: string;
686
+ column_name: string;
687
+ data_type: string;
688
+ udt_name: string;
689
+ is_nullable: string;
690
+ character_maximum_length: number | null;
691
+ numeric_precision: number | null;
692
+ numeric_scale: number | null;
693
+ column_default: string | null;
694
+ formatted_type: string | null;
695
+ }>(
696
+ `SELECT
412
697
  c.table_name,
413
698
  column_name,
414
699
  data_type,
@@ -432,16 +717,16 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
432
717
  AND NOT a.attisdropped
433
718
  WHERE c.table_schema = $1
434
719
  ORDER BY c.table_name, c.ordinal_position`,
435
- [schema],
436
- ),
437
- // Query all primary keys for all tables in schema
438
- driver.query<{
439
- table_name: string;
440
- constraint_name: string;
441
- column_name: string;
442
- ordinal_position: number;
443
- }>(
444
- `SELECT
720
+ [schema],
721
+ );
722
+ // Query all primary keys for all tables in schema
723
+ const pkResult = await driver.query<{
724
+ table_name: string;
725
+ constraint_name: string;
726
+ column_name: string;
727
+ ordinal_position: number;
728
+ }>(
729
+ `SELECT
445
730
  tc.table_name,
446
731
  tc.constraint_name,
447
732
  kcu.column_name,
@@ -454,24 +739,24 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
454
739
  WHERE tc.table_schema = $1
455
740
  AND tc.constraint_type = 'PRIMARY KEY'
456
741
  ORDER BY tc.table_name, kcu.ordinal_position`,
457
- [schema],
458
- ),
459
- // Query all foreign keys for all tables in schema, including referential actions.
460
- // Uses pg_catalog for correct positional pairing of composite FK columns
461
- // (information_schema.constraint_column_usage lacks ordinal_position,
462
- // which causes Cartesian products for multi-column FKs).
463
- driver.query<{
464
- table_name: string;
465
- constraint_name: string;
466
- column_name: string;
467
- ordinal_position: number;
468
- referenced_table_schema: string;
469
- referenced_table_name: string;
470
- referenced_column_name: string;
471
- delete_rule: string;
472
- update_rule: string;
473
- }>(
474
- `SELECT
742
+ [schema],
743
+ );
744
+ // Query all foreign keys for all tables in schema, including referential actions.
745
+ // Uses pg_catalog for correct positional pairing of composite FK columns
746
+ // (information_schema.constraint_column_usage lacks ordinal_position,
747
+ // which causes Cartesian products for multi-column FKs).
748
+ const fkResult = await driver.query<{
749
+ table_name: string;
750
+ constraint_name: string;
751
+ column_name: string;
752
+ ordinal_position: number;
753
+ referenced_table_schema: string;
754
+ referenced_table_name: string;
755
+ referenced_column_name: string;
756
+ delete_rule: string;
757
+ update_rule: string;
758
+ }>(
759
+ `SELECT
475
760
  tc.table_name,
476
761
  tc.constraint_name,
477
762
  kcu.column_name,
@@ -504,16 +789,16 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
504
789
  WHERE tc.table_schema = $1
505
790
  AND tc.constraint_type = 'FOREIGN KEY'
506
791
  ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position`,
507
- [schema],
508
- ),
509
- // Query all unique constraints for all tables in schema (excluding PKs)
510
- driver.query<{
511
- table_name: string;
512
- constraint_name: string;
513
- column_name: string;
514
- ordinal_position: number;
515
- }>(
516
- `SELECT
792
+ [schema],
793
+ );
794
+ // Query all unique constraints for all tables in schema (excluding PKs)
795
+ const uniqueResult = await driver.query<{
796
+ table_name: string;
797
+ constraint_name: string;
798
+ column_name: string;
799
+ ordinal_position: number;
800
+ }>(
801
+ `SELECT
517
802
  tc.table_name,
518
803
  tc.constraint_name,
519
804
  kcu.column_name,
@@ -526,31 +811,31 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
526
811
  WHERE tc.table_schema = $1
527
812
  AND tc.constraint_type = 'UNIQUE'
528
813
  ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position`,
529
- [schema],
530
- ),
531
- // Query all indexes for all tables in schema (excluding constraints).
532
- // `index_position` is the column's position within the index (1-based),
533
- // derived from `pg_index.indkey` so composite indexes round-trip with
534
- // their declared column order intact.
535
- driver.query<{
536
- tablename: string;
537
- indexname: string;
538
- indisunique: boolean;
539
- attname: string | null;
540
- index_position: number;
541
- amname: string | null;
542
- reloptions: string[] | null;
543
- }>(
544
- // `ix.indkey` is an int2vector of column numbers in the order the
545
- // columns appear in the index definition. Unnest it WITH ORDINALITY
546
- // so each (index, column) row carries its position in the index,
547
- // then ORDER BY that position. Without this the rows come back in
548
- // table-column order (`a.attnum`), which silently shuffles the
549
- // columns of any composite index whose index order differs from
550
- // the table order — verification compares against the contract
551
- // with order-sensitive equality and reports a spurious
552
- // `index_mismatch`.
553
- `SELECT
814
+ [schema],
815
+ );
816
+ // Query all indexes for all tables in schema (excluding constraints).
817
+ // `index_position` is the column's position within the index (1-based),
818
+ // derived from `pg_index.indkey` so composite indexes round-trip with
819
+ // their declared column order intact.
820
+ const indexResult = await driver.query<{
821
+ tablename: string;
822
+ indexname: string;
823
+ indisunique: boolean;
824
+ attname: string | null;
825
+ index_position: number;
826
+ amname: string | null;
827
+ reloptions: string[] | null;
828
+ }>(
829
+ // `ix.indkey` is an int2vector of column numbers in the order the
830
+ // columns appear in the index definition. Unnest it WITH ORDINALITY
831
+ // so each (index, column) row carries its position in the index,
832
+ // then ORDER BY that position. Without this the rows come back in
833
+ // table-column order (`a.attnum`), which silently shuffles the
834
+ // columns of any composite index whose index order differs from
835
+ // the table order — verification compares against the contract
836
+ // with order-sensitive equality and reports a spurious
837
+ // `index_mismatch`.
838
+ `SELECT
554
839
  i.tablename,
555
840
  i.indexname,
556
841
  ix.indisunique,
@@ -576,9 +861,33 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
576
861
  AND tc.constraint_name = i.indexname
577
862
  )
578
863
  ORDER BY i.tablename, i.indexname, k.ord`,
579
- [schema],
580
- ),
581
- ]);
864
+ [schema],
865
+ );
866
+ // Query all check constraints for enum-restricted columns.
867
+ // `pg_get_constraintdef(oid)` returns the predicate including the
868
+ // `CHECK (...)` wrapper. We parse the inner predicate to extract
869
+ // the column name and permitted values.
870
+ //
871
+ // Scope: only parses the `= ANY (ARRAY[...])` and `IN (...)` shapes
872
+ // that this slice emits. Arbitrary SQL predicates are left as-is
873
+ // and will not produce check IR entries (they are silently skipped).
874
+ const checkResult = await driver.query<{
875
+ table_name: string;
876
+ constraint_name: string;
877
+ constraintdef: string;
878
+ }>(
879
+ `SELECT
880
+ cl.relname AS table_name,
881
+ c.conname AS constraint_name,
882
+ pg_get_constraintdef(c.oid) AS constraintdef
883
+ FROM pg_catalog.pg_constraint c
884
+ JOIN pg_catalog.pg_class cl ON cl.oid = c.conrelid
885
+ JOIN pg_catalog.pg_namespace ns ON ns.oid = cl.relnamespace
886
+ WHERE ns.nspname = $1
887
+ AND c.contype = 'c'
888
+ ORDER BY cl.relname, c.conname`,
889
+ [schema],
890
+ );
582
891
 
583
892
  // Group results by table name for efficient lookup
584
893
  const columnsByTable = groupBy(columnsResult.rows, 'table_name');
@@ -586,6 +895,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
586
895
  const fksByTable = groupBy(fkResult.rows, 'table_name');
587
896
  const uniquesByTable = groupBy(uniqueResult.rows, 'table_name');
588
897
  const indexesByTable = groupBy(indexResult.rows, 'tablename');
898
+ const checksByTable = groupBy(checkResult.rows, 'table_name');
589
899
 
590
900
  // Get set of PK constraint names per table (to exclude from uniques)
591
901
  const pkConstraintsByTable = new Map<string, Set<string>>();
@@ -757,6 +1067,21 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
757
1067
  ...(idx.options !== undefined && { options: idx.options }),
758
1068
  }));
759
1069
 
1070
+ // Process check constraints — parse each predicate into column + value set.
1071
+ // Only the two shapes emitted by this slice are recognised; free-form
1072
+ // predicates are silently skipped (they won't produce check IR entries).
1073
+ const checksForTable: SqlCheckConstraintIRInput[] = [];
1074
+ for (const checkRow of checksByTable.get(tableName) ?? []) {
1075
+ const parsed = parseCheckConstraintDef(checkRow.constraintdef);
1076
+ if (parsed) {
1077
+ checksForTable.push({
1078
+ name: checkRow.constraint_name,
1079
+ column: parsed.column,
1080
+ permittedValues: parsed.permittedValues,
1081
+ });
1082
+ }
1083
+ }
1084
+
760
1085
  tables[tableName] = {
761
1086
  name: tableName,
762
1087
  columns,
@@ -764,6 +1089,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
764
1089
  foreignKeys,
765
1090
  uniques,
766
1091
  indexes,
1092
+ ...ifDefined('checks', checksForTable.length > 0 ? checksForTable : undefined),
767
1093
  };
768
1094
  }
769
1095
 
@@ -793,9 +1119,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
793
1119
  /**
794
1120
  * Gets the Postgres version from the database.
795
1121
  */
796
- private async getPostgresVersion(
797
- driver: ControlDriverInstance<'sql', 'postgres'>,
798
- ): Promise<string> {
1122
+ private async getPostgresVersion(driver: SqlControlDriverInstance<'postgres'>): Promise<string> {
799
1123
  const result = await driver.query<{ version: string }>('SELECT version() AS version', []);
800
1124
  const versionString = result.rows[0]?.version ?? '';
801
1125
  // Extract version number from "PostgreSQL 15.1 ..." format
@@ -947,3 +1271,100 @@ function groupBy<T, K extends keyof T>(items: readonly T[], key: K): Map<T[K], T
947
1271
  }
948
1272
  return map;
949
1273
  }
1274
+
1275
+ /**
1276
+ * Parses a Postgres check-constraint definition string (as returned by
1277
+ * `pg_get_constraintdef`) into a column name and permitted values array.
1278
+ *
1279
+ * Handles two shapes that Postgres emits for enum-style checks:
1280
+ *
1281
+ * 1. `= ANY (ARRAY[...])` — Postgres rewrites `col IN ('a','b')` to this form:
1282
+ * `CHECK ((col = ANY (ARRAY['a'::text, 'b'::text])))`
1283
+ *
1284
+ * 2. `IN (...)` — stays as-is when written directly:
1285
+ * `CHECK ((col IN ('a', 'b')))`
1286
+ *
1287
+ * Column names may be plain identifiers (`status`) or double-quoted identifiers
1288
+ * (`"my-col"`). Double-quoted identifiers with embedded `""` are un-escaped to a
1289
+ * single `"`.
1290
+ *
1291
+ * String literal values may contain Postgres-style doubled single-quotes (`''`),
1292
+ * which are un-escaped to a single `'` (e.g. `O''Brien` → `O'Brien`).
1293
+ *
1294
+ * Returns `{ column, permittedValues }` when the predicate matches one of
1295
+ * the two recognised shapes. Returns `undefined` for anything else (e.g.
1296
+ * a free-form SQL predicate that wasn't emitted by this slice).
1297
+ */
1298
+ export function parseCheckConstraintDef(
1299
+ constraintdef: string,
1300
+ ): { column: string; permittedValues: readonly string[] } | undefined {
1301
+ // Strip outer `CHECK (...)` wrapper and any extra parentheses.
1302
+ // pg_get_constraintdef returns e.g. `CHECK ((col = ANY (ARRAY[...])))` — note
1303
+ // the double parens: one from CHECK and one that Postgres wraps the predicate
1304
+ // in. Strip both outer layers.
1305
+ const afterCheck = constraintdef
1306
+ .replace(/^CHECK\s*\(/i, '')
1307
+ .replace(/\)$/, '')
1308
+ .trim();
1309
+ // Strip one more optional paren pair (the inner wrap Postgres adds)
1310
+ const inner =
1311
+ afterCheck.startsWith('(') && afterCheck.endsWith(')')
1312
+ ? afterCheck.slice(1, -1).trim()
1313
+ : afterCheck;
1314
+
1315
+ // Shape 1: col = ANY (ARRAY['a'::text, 'b'::text])
1316
+ // Accepts both plain identifiers and double-quoted identifiers for the column.
1317
+ // Anchored at the end so a composite predicate (e.g. `col = ANY (...) AND x > 0`)
1318
+ // does not partial-match.
1319
+ const anyArrayMatch = inner.match(
1320
+ /^(?:"((?:[^"]|"")*)"|(\w+))\s*=\s*ANY\s*\(\s*ARRAY\s*\[(.+)\]\s*\)\s*$/i,
1321
+ );
1322
+ if (anyArrayMatch) {
1323
+ const column =
1324
+ anyArrayMatch[1] !== undefined ? anyArrayMatch[1].replace(/""/g, '"') : anyArrayMatch[2];
1325
+ const arrayBody = anyArrayMatch[3];
1326
+ if (!column || !arrayBody) return undefined;
1327
+ const permittedValues = extractArrayLiterals(arrayBody);
1328
+ return permittedValues ? { column, permittedValues } : undefined;
1329
+ }
1330
+
1331
+ // Shape 2: col IN ('a', 'b')
1332
+ // Accepts both plain identifiers and double-quoted identifiers for the column.
1333
+ // Anchored at the end so a composite predicate (e.g. `col IN (...) AND x > 0`)
1334
+ // does not partial-match.
1335
+ const inMatch = inner.match(/^(?:"((?:[^"]|"")*)"|(\w+))\s+IN\s*\((.+)\)\s*$/i);
1336
+ if (inMatch) {
1337
+ const column = inMatch[1] !== undefined ? inMatch[1].replace(/""/g, '"') : inMatch[2];
1338
+ const listBody = inMatch[3];
1339
+ if (!column || !listBody) return undefined;
1340
+ const permittedValues = extractQuotedLiterals(listBody);
1341
+ return permittedValues ? { column, permittedValues } : undefined;
1342
+ }
1343
+
1344
+ return undefined;
1345
+ }
1346
+
1347
+ /**
1348
+ * Extracts string literals from an `ARRAY[...]` body.
1349
+ * Handles `'value'::type` casts by stripping the cast part.
1350
+ * Postgres stores single quotes inside values as doubled single-quotes (`''`);
1351
+ * each extracted value is un-escaped so `O''Brien` becomes `O'Brien`.
1352
+ */
1353
+ function extractArrayLiterals(arrayBody: string): readonly string[] | undefined {
1354
+ // Match 'value'::cast or 'value' (with possible spaces)
1355
+ const pattern = /'((?:[^'\\]|\\.|'')*)'\s*(?:::[^\s,\]]+)?/g;
1356
+ const values = [...arrayBody.matchAll(pattern)].map((m) => (m[1] ?? '').replace(/''/g, "'"));
1357
+ return values.length > 0 ? values : undefined;
1358
+ }
1359
+
1360
+ /**
1361
+ * Extracts string literals from an `IN (...)` list.
1362
+ * Handles single-quoted literals with possible escaped quotes.
1363
+ * Postgres stores single quotes inside values as doubled single-quotes (`''`);
1364
+ * each extracted value is un-escaped so `O''Brien` becomes `O'Brien`.
1365
+ */
1366
+ function extractQuotedLiterals(listBody: string): readonly string[] | undefined {
1367
+ const pattern = /'((?:[^'\\]|\\.|'')*)'/g;
1368
+ const values = [...listBody.matchAll(pattern)].map((m) => (m[1] ?? '').replace(/''/g, "'"));
1369
+ return values.length > 0 ? values : undefined;
1370
+ }