@prisma-next/adapter-postgres 0.14.0-dev.8 → 0.14.0-dev.81

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.
@@ -28,15 +28,13 @@ import type {
28
28
  } from '@prisma-next/sql-relational-core/ast';
29
29
  import { isDdlNode } from '@prisma-next/sql-relational-core/ast';
30
30
  import type {
31
- PrimaryKey,
31
+ PrimaryKeyInput,
32
32
  SqlCheckConstraintIRInput,
33
- SqlColumnIR,
34
- SqlForeignKeyIR,
35
- SqlIndexIR,
33
+ SqlColumnIRInput,
34
+ SqlForeignKeyIRInput,
35
+ SqlIndexIRInput,
36
36
  SqlReferentialAction,
37
- SqlSchemaIR,
38
- SqlTableIR,
39
- SqlUniqueIR,
37
+ SqlUniqueIRInput,
40
38
  } from '@prisma-next/sql-schema-ir/types';
41
39
  import {
42
40
  buildControlTableBootstrapQueries,
@@ -45,14 +43,31 @@ import {
45
43
  import type {
46
44
  AddColumnAction,
47
45
  AlterTableActionVisitor,
46
+ DropDefaultAction,
47
+ PostgresAlterPolicyRename,
48
48
  PostgresAlterTable,
49
+ PostgresCreatePolicy,
49
50
  PostgresCreateSchema,
50
51
  PostgresCreateTable,
52
+ PostgresCreateType,
51
53
  PostgresDdlNode,
54
+ PostgresDisableRowLevelSecurity,
55
+ PostgresDropPolicy,
56
+ PostgresDropType,
57
+ RlsPolicyOperation,
52
58
  } from '@prisma-next/target-postgres/ddl';
53
59
  import { parsePostgresDefault } from '@prisma-next/target-postgres/default-normalizer';
54
60
  import { normalizeSchemaNativeType } from '@prisma-next/target-postgres/native-type-normalizer';
61
+ import { parseRlsPolicyWireName } from '@prisma-next/target-postgres/rls-canonicalize';
55
62
  import { escapeLiteral, quoteIdentifier } from '@prisma-next/target-postgres/sql-utils';
63
+ import {
64
+ PostgresDatabaseSchemaNode,
65
+ PostgresNamespaceSchemaNode,
66
+ PostgresNativeEnumSchemaNode,
67
+ PostgresPolicySchemaNode,
68
+ PostgresRoleSchemaNode,
69
+ PostgresTableSchemaNode,
70
+ } from '@prisma-next/target-postgres/types';
56
71
  import { blindCast } from '@prisma-next/utils/casts';
57
72
  import { ifDefined } from '@prisma-next/utils/defined';
58
73
  import { encodeControlQueryParams } from './control-codecs';
@@ -60,6 +75,7 @@ import {
60
75
  execute,
61
76
  infoSchemaTables,
62
77
  ledger,
78
+ ledgerContract,
63
79
  ledgerReadShape,
64
80
  marker,
65
81
  mergeInvariants,
@@ -429,7 +445,11 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
429
445
  }
430
446
 
431
447
  /**
432
- * Appends a ledger entry for `space`. See the
448
+ * Appends a ledger entry for `space`. When the edge carries a
449
+ * destination contract snapshot, the content-addressed
450
+ * `prisma_contract.contract` store is populated first (keyed by the
451
+ * destination hash, DO NOTHING on revisit) so a reader never sees a
452
+ * ledger row whose stored destination contract is missing. See the
433
453
  * `SqlControlAdapter.writeLedgerEntry` contract.
434
454
  */
435
455
  async writeLedgerEntry(
@@ -442,10 +462,23 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
442
462
  readonly migrationName: string;
443
463
  readonly migrationHash: string;
444
464
  readonly operations: readonly unknown[];
465
+ readonly destinationContractJson?: unknown;
445
466
  },
446
467
  ): Promise<void> {
468
+ const lower = (query: AnyQueryAst) => this.lower(query, { contract: undefined });
469
+ if (entry.destinationContractJson !== undefined) {
470
+ await execute(
471
+ lower,
472
+ driver,
473
+ ledgerContract
474
+ .upsert({ core_hash: entry.to, contract_json: entry.destinationContractJson })
475
+ .onConflict(ledgerContract.core_hash)
476
+ .doNothing()
477
+ .build(),
478
+ );
479
+ }
447
480
  await execute(
448
- (query) => this.lower(query, { contract: undefined }),
481
+ lower,
449
482
  driver,
450
483
  ledger
451
484
  .insert({
@@ -548,26 +581,51 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
548
581
  driver: SqlControlDriverInstance<'postgres'>,
549
582
  contract?: unknown,
550
583
  schema = 'public',
551
- ): Promise<SqlSchemaIR> {
584
+ ): Promise<PostgresDatabaseSchemaNode> {
552
585
  const declaredNamespaces = extractContractNamespaceIds(contract);
553
- const ir =
586
+ const resolvedSchemas =
554
587
  declaredNamespaces.length > 0
555
- ? await this.introspectNamespaces(driver, declaredNamespaces)
556
- : await this.introspectSchema(driver, schema);
557
- // Capture the list of non-system schemas so downstream planners
558
- // (e.g. `verifyPostgresNamespacePresence`) can determine which
559
- // contract-declared namespaces need a `CREATE SCHEMA` before the
560
- // table DDL.
588
+ ? await this.resolveNamespaceSchemas(driver, declaredNamespaces)
589
+ : [schema];
590
+
591
+ // Walk schemas sequentially: every introspectSchema call shares the one
592
+ // control connection, so a parallel walk only serialises behind the wire
593
+ // protocol and trips pg's "already executing a query" deprecation.
594
+ const namespaces: Record<string, PostgresNamespaceSchemaNode> = {};
595
+ let pgVersion = 'unknown';
596
+ for (const resolved of resolvedSchemas) {
597
+ const { namespace, pgVersion: version } = await this.introspectSchema(driver, resolved);
598
+ namespaces[resolved] = namespace;
599
+ pgVersion = version;
600
+ }
601
+
602
+ const roles = await this.introspectRoles(driver);
561
603
  const existingSchemas = await this.listExistingSchemas(driver);
562
- const annotations = ir.annotations ?? {};
563
- const pg = (annotations as { pg?: Record<string, unknown> }).pg ?? {};
564
- return {
565
- ...ir,
566
- annotations: {
567
- ...annotations,
568
- pg: { ...pg, existingSchemas },
569
- },
570
- };
604
+ return new PostgresDatabaseSchemaNode({
605
+ namespaces,
606
+ roles,
607
+ existingSchemas,
608
+ pgVersion,
609
+ });
610
+ }
611
+
612
+ /**
613
+ * Reads cluster-scoped database roles. Roles are not schema-qualified, so
614
+ * this is queried once for the whole database rather than per namespace.
615
+ */
616
+ private async introspectRoles(
617
+ driver: SqlControlDriverInstance<'postgres'>,
618
+ ): Promise<readonly PostgresRoleSchemaNode[]> {
619
+ const rolesResult = await driver.query<{ rolname: string }>(
620
+ `SELECT rolname
621
+ FROM pg_catalog.pg_roles
622
+ WHERE rolname NOT LIKE 'pg_%'
623
+ AND rolname != 'postgres'
624
+ ORDER BY rolname`,
625
+ );
626
+ return rolesResult.rows.map(
627
+ (row) => new PostgresRoleSchemaNode({ name: row.rolname, namespaceId: UNBOUND_NAMESPACE_ID }),
628
+ );
571
629
  }
572
630
 
573
631
  /**
@@ -593,16 +651,16 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
593
651
  }
594
652
 
595
653
  /**
596
- * Walks every declared namespace, resolving `UNBOUND_NAMESPACE_ID` to
597
- * the connection's `current_schema()`, and merges the per-schema results
598
- * into a single `SqlSchemaIR`. The merged `tables` map is flat (keyed by
599
- * table name) so callers that look up by `tableName` see every contract
600
- * table regardless of which namespace it lives in.
654
+ * Resolves the declared namespace ids to their live DDL schema names,
655
+ * mapping `UNBOUND_NAMESPACE_ID` to the connection's `current_schema()`
656
+ * and de-duplicating. The caller introspects one namespace node per
657
+ * resolved schema there is no flat cross-schema merge, so two schemas
658
+ * holding a same-named table no longer collide.
601
659
  */
602
- private async introspectNamespaces(
660
+ private async resolveNamespaceSchemas(
603
661
  driver: SqlControlDriverInstance<'postgres'>,
604
662
  namespaceIds: readonly string[],
605
- ): Promise<SqlSchemaIR> {
663
+ ): Promise<readonly string[]> {
606
664
  const resolvedSchemas: string[] = [];
607
665
  for (const id of namespaceIds) {
608
666
  if (id === UNBOUND_NAMESPACE_ID) {
@@ -614,46 +672,19 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
614
672
  resolvedSchemas.push(id);
615
673
  }
616
674
  }
617
- const uniqueSchemas = Array.from(new Set(resolvedSchemas));
618
-
619
- // Walk schemas sequentially: every introspectSchema call shares the one
620
- // control connection, so a parallel walk only serialises behind the wire
621
- // protocol and trips pg's "already executing a query" deprecation.
622
- const perSchema: SqlSchemaIR[] = [];
623
- for (const schema of uniqueSchemas) {
624
- perSchema.push(await this.introspectSchema(driver, schema));
625
- }
626
-
627
- const mergedTables: Record<string, SqlTableIR> = {};
628
- for (const ir of perSchema) {
629
- for (const [tableName, table] of Object.entries(ir.tables)) {
630
- mergedTables[tableName] = table;
631
- }
632
- }
633
-
634
- const firstAnnotations = perSchema[0]?.annotations;
635
- const firstPg =
636
- blindCast<Record<string, unknown> | undefined, 'pg annotation envelope index slot'>(
637
- firstAnnotations?.['pg'],
638
- ) ?? {};
639
- return {
640
- tables: mergedTables,
641
- ...ifDefined('annotations', {
642
- ...firstAnnotations,
643
- pg: { ...firstPg },
644
- }),
645
- };
675
+ return Array.from(new Set(resolvedSchemas));
646
676
  }
647
677
 
648
678
  /**
649
- * Introspects a single Postgres schema and returns a raw SqlSchemaIR
650
- * containing only the tables in that schema. Used by `introspect` as
679
+ * Introspects a single Postgres schema and returns the namespace node for
680
+ * that schema (its tables, their policies, and its native enum type names),
681
+ * alongside the cluster-scoped Postgres version. Used by `introspect` as
651
682
  * the per-namespace walk.
652
683
  */
653
684
  private async introspectSchema(
654
685
  driver: SqlControlDriverInstance<'postgres'>,
655
686
  schema: string,
656
- ): Promise<SqlSchemaIR> {
687
+ ): Promise<{ readonly namespace: PostgresNamespaceSchemaNode; readonly pgVersion: string }> {
657
688
  // Issue the schema-wide queries one at a time. A single control connection
658
689
  // serialises queries anyway, so Promise.all buys no parallelism here and
659
690
  // makes pg emit a "client is already executing a query" deprecation. One
@@ -895,13 +926,24 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
895
926
  constraints.add(row.constraint_name);
896
927
  }
897
928
 
898
- const tables: Record<string, SqlTableIR> = {};
929
+ const tableInputs: Record<
930
+ string,
931
+ {
932
+ name: string;
933
+ columns: Record<string, SqlColumnIRInput>;
934
+ primaryKey?: PrimaryKeyInput;
935
+ foreignKeys: readonly SqlForeignKeyIRInput[];
936
+ uniques: readonly SqlUniqueIRInput[];
937
+ indexes: readonly SqlIndexIRInput[];
938
+ checks?: SqlCheckConstraintIRInput[];
939
+ }
940
+ > = {};
899
941
 
900
942
  for (const tableRow of tablesResult.rows) {
901
943
  const tableName = tableRow.table_name;
902
944
 
903
945
  // Process columns for this table
904
- const columns: Record<string, SqlColumnIR> = {};
946
+ const columns: Record<string, SqlColumnIRInput> = {};
905
947
  for (const colRow of columnsByTable.get(tableName) ?? []) {
906
948
  let nativeType = colRow.udt_name;
907
949
  const formattedType = colRow.formatted_type
@@ -927,11 +969,36 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
927
969
  nativeType = colRow.udt_name || colRow.data_type;
928
970
  }
929
971
 
972
+ // Postgres reports array columns as data_type='ARRAY'; the element type
973
+ // is the `nativeType` string minus the trailing `[]`. Strip the suffix,
974
+ // normalize the element type to the canonical form (e.g. `integer` →
975
+ // `int4`), and record `many: true` so introspection consumers (verifier,
976
+ // psl-contract-infer) can reconstruct the full array type as needed.
977
+ const many = nativeType.endsWith('[]') ? true : undefined;
978
+ if (many) {
979
+ nativeType = normalizeSchemaNativeType(nativeType.slice(0, -2));
980
+ }
981
+
982
+ // Resolved values comparable against the contract-derived expected
983
+ // side: the normalized full native type (`[]` appended for arrays)
984
+ // and the structured parse of the raw default. Raw fields stay
985
+ // untouched alongside — the relational walk still reads and
986
+ // normalizes them itself.
987
+ const resolvedNativeType = `${normalizeSchemaNativeType(nativeType)}${many ? '[]' : ''}`;
988
+ const rawDefault = colRow.column_default ?? undefined;
930
989
  columns[colRow.column_name] = {
931
990
  name: colRow.column_name,
932
991
  nativeType,
933
992
  nullable: colRow.is_nullable === 'YES',
934
- ...ifDefined('default', colRow.column_default ?? undefined),
993
+ ...ifDefined('default', rawDefault),
994
+ ...ifDefined('many', many),
995
+ resolvedNativeType,
996
+ ...ifDefined(
997
+ 'resolvedDefault',
998
+ rawDefault !== undefined
999
+ ? parsePostgresDefault(rawDefault, resolvedNativeType)
1000
+ : undefined,
1001
+ ),
935
1002
  };
936
1003
  }
937
1004
 
@@ -940,7 +1007,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
940
1007
  const primaryKeyColumns = pkRows
941
1008
  .sort((a, b) => a.ordinal_position - b.ordinal_position)
942
1009
  .map((row) => row.column_name);
943
- const primaryKey: PrimaryKey | undefined =
1010
+ const primaryKey: PrimaryKeyInput | undefined =
944
1011
  primaryKeyColumns.length > 0
945
1012
  ? {
946
1013
  columns: primaryKeyColumns,
@@ -978,7 +1045,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
978
1045
  });
979
1046
  }
980
1047
  }
981
- const foreignKeys: readonly SqlForeignKeyIR[] = Array.from(foreignKeysMap.values()).map(
1048
+ const foreignKeys: readonly SqlForeignKeyIRInput[] = Array.from(foreignKeysMap.values()).map(
982
1049
  (fk) => ({
983
1050
  columns: Object.freeze([...fk.columns]) as readonly string[],
984
1051
  referencedTable: fk.referencedTable,
@@ -1008,7 +1075,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
1008
1075
  });
1009
1076
  }
1010
1077
  }
1011
- const uniques: readonly SqlUniqueIR[] = Array.from(uniquesMap.values()).map((uq) => ({
1078
+ const uniques: readonly SqlUniqueIRInput[] = Array.from(uniquesMap.values()).map((uq) => ({
1012
1079
  columns: Object.freeze([...uq.columns]) as readonly string[],
1013
1080
  name: uq.name,
1014
1081
  }));
@@ -1046,7 +1113,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
1046
1113
  });
1047
1114
  }
1048
1115
  }
1049
- const indexes: readonly SqlIndexIR[] = Array.from(indexesMap.values()).map((idx) => ({
1116
+ const indexes: readonly SqlIndexIRInput[] = Array.from(indexesMap.values()).map((idx) => ({
1050
1117
  columns: Object.freeze([...idx.columns]) as readonly string[],
1051
1118
  name: idx.name,
1052
1119
  unique: idx.unique,
@@ -1069,7 +1136,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
1069
1136
  }
1070
1137
  }
1071
1138
 
1072
- tables[tableName] = {
1139
+ tableInputs[tableName] = {
1073
1140
  name: tableName,
1074
1141
  columns,
1075
1142
  ...ifDefined('primaryKey', primaryKey),
@@ -1080,29 +1147,110 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
1080
1147
  };
1081
1148
  }
1082
1149
 
1083
- const nativeEnumResult = await driver.query<{ typname: string }>(
1084
- `SELECT t.typname
1150
+ const nativeEnumResult = await driver.query<{ typname: string; enumvalues: unknown }>(
1151
+ `SELECT t.typname, array_agg(e.enumlabel ORDER BY e.enumsortorder) AS enumvalues
1085
1152
  FROM pg_catalog.pg_type t
1086
1153
  JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
1154
+ JOIN pg_catalog.pg_enum e ON e.enumtypid = t.oid
1087
1155
  WHERE t.typtype = 'e'
1088
1156
  AND n.nspname = $1
1157
+ GROUP BY t.typname
1089
1158
  ORDER BY t.typname`,
1090
1159
  [schema],
1091
1160
  );
1092
- const nativeEnumTypeNames = nativeEnumResult.rows.map((r) => r.typname);
1161
+ const enums = nativeEnumResult.rows.map(
1162
+ (r) =>
1163
+ new PostgresNativeEnumSchemaNode({
1164
+ typeName: r.typname,
1165
+ namespaceId: schema,
1166
+ members: parsePgNameArray(r.enumvalues),
1167
+ }),
1168
+ );
1169
+ const policiesResult = await driver.query<{
1170
+ schemaname: string;
1171
+ tablename: string;
1172
+ policyname: string;
1173
+ cmd: string;
1174
+ roles: string[];
1175
+ qual: string | null;
1176
+ with_check: string | null;
1177
+ permissive: string;
1178
+ }>(
1179
+ `SELECT schemaname, tablename, policyname, cmd, roles, qual, with_check, permissive
1180
+ FROM pg_catalog.pg_policies
1181
+ WHERE schemaname = $1
1182
+ ORDER BY tablename, policyname`,
1183
+ [schema],
1184
+ );
1185
+ const policiesByTable = new Map<string, PostgresPolicySchemaNode[]>();
1186
+ for (const row of policiesResult.rows) {
1187
+ const operation = mapPgCmd(row.cmd);
1188
+ const policyRoles = [
1189
+ ...new Set(parsePgNameArray(row.roles).map((r) => r.toLowerCase())),
1190
+ ].sort();
1191
+ const permissive = row.permissive.toUpperCase() === 'PERMISSIVE';
1192
+ const prefix = parseRlsPolicyWireName(row.policyname)?.prefix ?? row.policyname;
1193
+ const policy = new PostgresPolicySchemaNode({
1194
+ name: row.policyname,
1195
+ prefix,
1196
+ tableName: row.tablename,
1197
+ namespaceId: row.schemaname,
1198
+ operation,
1199
+ roles: policyRoles,
1200
+ ...(row.qual !== null ? { using: row.qual } : {}),
1201
+ ...(row.with_check !== null ? { withCheck: row.with_check } : {}),
1202
+ permissive,
1203
+ });
1204
+ const list = policiesByTable.get(row.tablename) ?? [];
1205
+ list.push(policy);
1206
+ policiesByTable.set(row.tablename, list);
1207
+ }
1093
1208
 
1094
- const annotations = {
1095
- pg: {
1096
- schema,
1097
- version: await this.getPostgresVersion(driver),
1098
- ...(nativeEnumTypeNames.length > 0 && { nativeEnumTypeNames }),
1099
- },
1100
- };
1209
+ // RLS enablement is a table attribute (`pg_class.relrowsecurity`), not a
1210
+ // function of the policy set — a table can have RLS on with zero
1211
+ // policies (deny-all) or policies present with RLS off. relkind covers
1212
+ // both plain ('r') and partitioned ('p') tables: the table listing above
1213
+ // (`information_schema.tables`, BASE TABLE) includes partitioned parents,
1214
+ // and Postgres supports RLS on them.
1215
+ //
1216
+ // Kept as a SEPARATE query from the table listing on purpose. Folding
1217
+ // relrowsecurity into the listing would mean replacing
1218
+ // `information_schema.tables` (which filters by the connection role's
1219
+ // grants) with a raw `pg_class` scan (which does not), changing WHICH
1220
+ // tables the introspection returns — a real behavior shift, not a
1221
+ // cleanup, and one the offline golden-diff can't catch (introspection is
1222
+ // live-only). The only cost of two queries is the concurrent-DDL window:
1223
+ // a table listed but missed by this scan defaults (`?? false`) to
1224
+ // RLS-off. That default is fail-safe — the worst case downstream is a
1225
+ // spurious ENABLE (idempotent), never a spurious DISABLE.
1226
+ const rlsEnabledResult = await driver.query<{ tablename: string; rls_enabled: boolean }>(
1227
+ `SELECT c.relname AS tablename, c.relrowsecurity AS rls_enabled
1228
+ FROM pg_catalog.pg_class c
1229
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
1230
+ WHERE n.nspname = $1
1231
+ AND c.relkind IN ('r', 'p')
1232
+ ORDER BY c.relname`,
1233
+ [schema],
1234
+ );
1235
+ const rlsEnabledByTable = new Map<string, boolean>(
1236
+ rlsEnabledResult.rows.map((row) => [row.tablename, row.rls_enabled]),
1237
+ );
1238
+
1239
+ const tables: Record<string, PostgresTableSchemaNode> = {};
1240
+ for (const [tableName, input] of Object.entries(tableInputs)) {
1241
+ tables[tableName] = new PostgresTableSchemaNode({
1242
+ ...input,
1243
+ policies: policiesByTable.get(tableName) ?? [],
1244
+ rlsEnabled: rlsEnabledByTable.get(tableName) ?? false,
1245
+ });
1246
+ }
1101
1247
 
1102
- return {
1248
+ const namespace = new PostgresNamespaceSchemaNode({
1249
+ schemaName: schema,
1103
1250
  tables,
1104
- annotations,
1105
- };
1251
+ nativeEnums: enums,
1252
+ });
1253
+ return { namespace, pgVersion: await this.getPostgresVersion(driver) };
1106
1254
  }
1107
1255
 
1108
1256
  /**
@@ -1117,6 +1265,106 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> {
1117
1265
  }
1118
1266
  }
1119
1267
 
1268
+ /**
1269
+ * Normalises a `name[]` column value from `pg_policies.roles`.
1270
+ *
1271
+ * The `pg` client's type-parser registry handles `text[]` (OID 1009) but not
1272
+ * `name[]` (OID 1003). When the parser is absent the raw Postgres text-array
1273
+ * literal (`{role1,role2}`) is returned as a string instead of a JS array.
1274
+ * This function accepts either form and returns a plain string array.
1275
+ *
1276
+ * The string branch honors Postgres array-literal quoting: an element
1277
+ * containing a comma, quote, backslash, brace, or significant whitespace is
1278
+ * emitted double-quoted with `\"` / `\\` escapes, and unquoted elements are
1279
+ * whitespace-trimmed — so a label like `in progress` or `say "hi"` parses to
1280
+ * its true value instead of being split or kept escaped.
1281
+ */
1282
+ export function parsePgNameArray(value: unknown): string[] {
1283
+ if (Array.isArray(value)) {
1284
+ return value.map(String);
1285
+ }
1286
+ if (typeof value !== 'string') {
1287
+ return [];
1288
+ }
1289
+ const trimmed = value.trim();
1290
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
1291
+ return [];
1292
+ }
1293
+ const inner = trimmed.slice(1, -1);
1294
+ if (inner === '') {
1295
+ return [];
1296
+ }
1297
+
1298
+ const elements: string[] = [];
1299
+ let current = '';
1300
+ let inQuotes = false;
1301
+ let wasQuoted = false;
1302
+ const pushCurrent = () => {
1303
+ elements.push(wasQuoted ? current : current.trim());
1304
+ current = '';
1305
+ wasQuoted = false;
1306
+ };
1307
+ let i = 0;
1308
+ while (i < inner.length) {
1309
+ const char = inner.charAt(i);
1310
+ if (inQuotes) {
1311
+ if (char === '\\') {
1312
+ current += inner[i + 1] ?? '';
1313
+ i += 2;
1314
+ continue;
1315
+ }
1316
+ if (char === '"') {
1317
+ inQuotes = false;
1318
+ i++;
1319
+ continue;
1320
+ }
1321
+ current += char;
1322
+ i++;
1323
+ continue;
1324
+ }
1325
+ if (char === '"') {
1326
+ inQuotes = true;
1327
+ wasQuoted = true;
1328
+ i++;
1329
+ continue;
1330
+ }
1331
+ if (char === ',') {
1332
+ pushCurrent();
1333
+ i++;
1334
+ continue;
1335
+ }
1336
+ current += char;
1337
+ i++;
1338
+ }
1339
+ // A still-open quote means the literal was malformed (e.g. `{"unterminated}`);
1340
+ // reject rather than emit the partial value.
1341
+ if (inQuotes) {
1342
+ return [];
1343
+ }
1344
+ pushCurrent();
1345
+ return elements;
1346
+ }
1347
+
1348
+ /**
1349
+ * Maps `pg_policies.cmd` text values to the `RlsPolicyOperation` union.
1350
+ * The `pg_policies` view renders the internal command code as an uppercase
1351
+ * English keyword; this function lowercases to match the IR type.
1352
+ */
1353
+ function mapPgCmd(cmd: string): RlsPolicyOperation {
1354
+ switch (cmd.toUpperCase()) {
1355
+ case 'SELECT':
1356
+ return 'select';
1357
+ case 'INSERT':
1358
+ return 'insert';
1359
+ case 'UPDATE':
1360
+ return 'update';
1361
+ case 'DELETE':
1362
+ return 'delete';
1363
+ default:
1364
+ return 'all';
1365
+ }
1366
+ }
1367
+
1120
1368
  /**
1121
1369
  * Extracts the namespace coordinate ids declared on a contract's storage,
1122
1370
  * or returns an empty array when no contract (or no storage / namespaces)
@@ -1133,6 +1381,9 @@ function extractContractNamespaceIds(contract: unknown): readonly string[] {
1133
1381
  }
1134
1382
 
1135
1383
  function normalizeFormattedType(formattedType: string, dataType: string, udtName: string): string {
1384
+ if (formattedType.endsWith('[]')) {
1385
+ return `${normalizeFormattedType(formattedType.slice(0, -2), dataType, udtName)}[]`;
1386
+ }
1136
1387
  if (formattedType === 'integer') {
1137
1388
  return 'int4';
1138
1389
  }
@@ -1376,6 +1627,18 @@ function pgIsTextLikeNativeType(nativeType: string): boolean {
1376
1627
  );
1377
1628
  }
1378
1629
 
1630
+ function pgRenderArrayElement(el: unknown): string {
1631
+ if (el === null) return 'NULL';
1632
+ if (typeof el === 'number' || typeof el === 'boolean') return String(el);
1633
+ if (typeof el === 'string') return `'${escapeLiteral(el)}'`;
1634
+ return `'${escapeLiteral(JSON.stringify(el))}'`;
1635
+ }
1636
+
1637
+ function pgRenderArrayLiteral(elements: unknown[]): string {
1638
+ if (elements.length === 0) return "'{}'";
1639
+ return `ARRAY[${elements.map(pgRenderArrayElement).join(', ')}]`;
1640
+ }
1641
+
1379
1642
  function pgInlineLiteral(wire: unknown, nativeType: string): string {
1380
1643
  if (wire === null) return 'NULL';
1381
1644
  if (typeof wire === 'boolean') return wire ? 'true' : 'false';
@@ -1407,6 +1670,9 @@ function pgInlineLiteral(wire: unknown, nativeType: string): string {
1407
1670
  .join('');
1408
1671
  return `'\\x${hex}'::${nativeType}`;
1409
1672
  }
1673
+ if (Array.isArray(wire) && nativeType.endsWith('[]')) {
1674
+ return pgRenderArrayLiteral(wire);
1675
+ }
1410
1676
  if (typeof wire === 'object') {
1411
1677
  const quoted = `'${escapeLiteral(JSON.stringify(wire))}'`;
1412
1678
  return `${quoted}::${nativeType}`;
@@ -1477,6 +1743,9 @@ function pgRenderDdlConstraint(constraint: DdlTableConstraint): string {
1477
1743
  }
1478
1744
  return sql;
1479
1745
  }
1746
+ if (constraint.kind === 'check-expression') {
1747
+ return `CONSTRAINT ${quoteIdentifier(constraint.name)} CHECK (${constraint.expression})`;
1748
+ }
1480
1749
  const cols = constraint.columns.map(quoteIdentifier).join(', ');
1481
1750
  if (constraint.name !== undefined) {
1482
1751
  return `CONSTRAINT ${quoteIdentifier(constraint.name)} UNIQUE (${cols})`;
@@ -1512,6 +1781,27 @@ function pgRenderCreateSchema(node: PostgresCreateSchema): SqlExecuteRequest {
1512
1781
  };
1513
1782
  }
1514
1783
 
1784
+ function pgRenderCreateType(node: PostgresCreateType): SqlExecuteRequest {
1785
+ const typeRef = node.schema
1786
+ ? `${quoteIdentifier(node.schema)}.${quoteIdentifier(node.name)}`
1787
+ : quoteIdentifier(node.name);
1788
+ const values = node.values.map((value) => `'${escapeLiteral(value)}'`).join(', ');
1789
+ return {
1790
+ sql: `CREATE TYPE ${typeRef} AS ENUM (${values})`,
1791
+ params: [],
1792
+ };
1793
+ }
1794
+
1795
+ function pgRenderDropType(node: PostgresDropType): SqlExecuteRequest {
1796
+ const typeRef = node.schema
1797
+ ? `${quoteIdentifier(node.schema)}.${quoteIdentifier(node.name)}`
1798
+ : quoteIdentifier(node.name);
1799
+ return {
1800
+ sql: `DROP TYPE ${typeRef}`,
1801
+ params: [],
1802
+ };
1803
+ }
1804
+
1515
1805
  async function pgRenderAlterTable(
1516
1806
  node: PostgresAlterTable,
1517
1807
  codecLookup: CodecLookup,
@@ -1524,6 +1814,9 @@ async function pgRenderAlterTable(
1524
1814
  const colFragment = await pgRenderDdlColumn(action.column, codecLookup);
1525
1815
  return `ADD COLUMN ${colFragment}`;
1526
1816
  },
1817
+ dropDefault(action: DropDefaultAction): Promise<string> {
1818
+ return Promise.resolve(`ALTER COLUMN ${quoteIdentifier(action.columnName)} DROP DEFAULT`);
1819
+ },
1527
1820
  };
1528
1821
  const actionSqls = await Promise.all(node.actions.map((a) => a.accept(actionVisitor)));
1529
1822
  return {
@@ -1532,6 +1825,53 @@ async function pgRenderAlterTable(
1532
1825
  };
1533
1826
  }
1534
1827
 
1828
+ const POLICY_OPERATION_SQL: Record<RlsPolicyOperation, string> = {
1829
+ select: 'SELECT',
1830
+ insert: 'INSERT',
1831
+ update: 'UPDATE',
1832
+ delete: 'DELETE',
1833
+ all: 'ALL',
1834
+ };
1835
+
1836
+ function pgRenderCreatePolicy(node: PostgresCreatePolicy): SqlExecuteRequest {
1837
+ const tableRef = `${quoteIdentifier(node.schema)}.${quoteIdentifier(node.table)}`;
1838
+ const permissiveness = node.permissive ? 'PERMISSIVE' : 'RESTRICTIVE';
1839
+ const command = POLICY_OPERATION_SQL[node.operation];
1840
+ const roles = node.roles.length === 0 ? 'PUBLIC' : node.roles.join(', ');
1841
+ let sql = `CREATE POLICY ${quoteIdentifier(node.name)} ON ${tableRef} AS ${permissiveness} FOR ${command} TO ${roles}`;
1842
+ if (node.using !== undefined) {
1843
+ sql += ` USING (${node.using})`;
1844
+ }
1845
+ if (node.withCheck !== undefined) {
1846
+ sql += ` WITH CHECK (${node.withCheck})`;
1847
+ }
1848
+ return { sql, params: [] };
1849
+ }
1850
+
1851
+ function pgRenderDropPolicy(node: PostgresDropPolicy): SqlExecuteRequest {
1852
+ const tableRef = `${quoteIdentifier(node.schema)}.${quoteIdentifier(node.table)}`;
1853
+ return {
1854
+ sql: `DROP POLICY ${quoteIdentifier(node.name)} ON ${tableRef}`,
1855
+ params: [],
1856
+ };
1857
+ }
1858
+
1859
+ function pgRenderAlterPolicyRename(node: PostgresAlterPolicyRename): SqlExecuteRequest {
1860
+ const tableRef = `${quoteIdentifier(node.schema)}.${quoteIdentifier(node.table)}`;
1861
+ return {
1862
+ sql: `ALTER POLICY ${quoteIdentifier(node.name)} ON ${tableRef} RENAME TO ${quoteIdentifier(node.newName)}`,
1863
+ params: [],
1864
+ };
1865
+ }
1866
+
1867
+ function pgRenderDisableRowLevelSecurity(node: PostgresDisableRowLevelSecurity): SqlExecuteRequest {
1868
+ const tableRef = `${quoteIdentifier(node.schema)}.${quoteIdentifier(node.table)}`;
1869
+ return {
1870
+ sql: `ALTER TABLE ${tableRef} DISABLE ROW LEVEL SECURITY`,
1871
+ params: [],
1872
+ };
1873
+ }
1874
+
1535
1875
  async function pgRenderDdlExecuteRequest(
1536
1876
  ast: PostgresDdlNode,
1537
1877
  codecLookup: CodecLookup,
@@ -1539,7 +1879,15 @@ async function pgRenderDdlExecuteRequest(
1539
1879
  const visitor = {
1540
1880
  createTable: (node: PostgresCreateTable) => pgRenderCreateTable(node, codecLookup),
1541
1881
  createSchema: (node: PostgresCreateSchema) => Promise.resolve(pgRenderCreateSchema(node)),
1882
+ createType: (node: PostgresCreateType) => Promise.resolve(pgRenderCreateType(node)),
1883
+ dropType: (node: PostgresDropType) => Promise.resolve(pgRenderDropType(node)),
1542
1884
  alterTable: (node: PostgresAlterTable) => pgRenderAlterTable(node, codecLookup),
1885
+ createPolicy: (node: PostgresCreatePolicy) => Promise.resolve(pgRenderCreatePolicy(node)),
1886
+ dropPolicy: (node: PostgresDropPolicy) => Promise.resolve(pgRenderDropPolicy(node)),
1887
+ alterPolicyRename: (node: PostgresAlterPolicyRename) =>
1888
+ Promise.resolve(pgRenderAlterPolicyRename(node)),
1889
+ disableRowLevelSecurity: (node: PostgresDisableRowLevelSecurity) =>
1890
+ Promise.resolve(pgRenderDisableRowLevelSecurity(node)),
1543
1891
  };
1544
1892
  return ast.accept(visitor);
1545
1893
  }