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

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