@homeflare/alchemy 0.46.0 → 0.46.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.
Files changed (71) hide show
  1. package/dist/postgres/connection.d.ts.map +1 -1
  2. package/dist/postgres/fake-schema-drop.d.ts +13 -0
  3. package/dist/postgres/fake-schema-drop.d.ts.map +1 -0
  4. package/dist/postgres/fake-schema-sql.d.ts +5 -0
  5. package/dist/postgres/fake-schema-sql.d.ts.map +1 -1
  6. package/dist/postgres/fake-sql.d.ts +4 -0
  7. package/dist/postgres/fake-sql.d.ts.map +1 -1
  8. package/dist/postgres/index.d.ts +2 -1
  9. package/dist/postgres/index.d.ts.map +1 -1
  10. package/dist/postgres/index.js +461 -278
  11. package/dist/postgres/index.js.map +19 -15
  12. package/dist/postgres/psql-executor.d.ts.map +1 -1
  13. package/dist/postgres/schema-closure-sql.d.ts +45 -0
  14. package/dist/postgres/schema-closure-sql.d.ts.map +1 -0
  15. package/dist/postgres/schema-delete.d.ts +17 -8
  16. package/dist/postgres/schema-delete.d.ts.map +1 -1
  17. package/dist/postgres/schema-drop-sql.d.ts +66 -0
  18. package/dist/postgres/schema-drop-sql.d.ts.map +1 -0
  19. package/dist/postgres/schema-errors.d.ts +19 -1
  20. package/dist/postgres/schema-errors.d.ts.map +1 -1
  21. package/dist/postgres/schema-sequence-error.d.ts +29 -0
  22. package/dist/postgres/schema-sequence-error.d.ts.map +1 -0
  23. package/dist/postgres/schema-sql.d.ts +16 -0
  24. package/dist/postgres/schema-sql.d.ts.map +1 -1
  25. package/dist/postgres/schema-test-kit.d.ts +3 -1
  26. package/dist/postgres/schema-test-kit.d.ts.map +1 -1
  27. package/dist/postgres/search-path.d.ts +38 -0
  28. package/dist/postgres/search-path.d.ts.map +1 -0
  29. package/docs/postgres-schema.md +22 -1
  30. package/package.json +1 -1
  31. package/src/postgres/connection.ts +10 -14
  32. package/src/postgres/database-bigint-serialization.test.ts +2 -2
  33. package/src/postgres/database-sql.ts +6 -6
  34. package/src/postgres/fake-grants-read.ts +3 -3
  35. package/src/postgres/fake-role-sql.ts +2 -2
  36. package/src/postgres/fake-schema-drop.ts +75 -0
  37. package/src/postgres/fake-schema-sql.ts +10 -1
  38. package/src/postgres/fake-sql.ts +10 -3
  39. package/src/postgres/grants-database.test.ts +5 -5
  40. package/src/postgres/grants-existence.ts +7 -7
  41. package/src/postgres/grants-ownership.ts +5 -5
  42. package/src/postgres/grants-psql-lifecycle.test.ts +7 -3
  43. package/src/postgres/grants-read-defaults.ts +5 -5
  44. package/src/postgres/grants-read.ts +16 -16
  45. package/src/postgres/index.ts +2 -0
  46. package/src/postgres/psql-executor-escaping.test.ts +3 -1
  47. package/src/postgres/psql-executor.test.ts +11 -5
  48. package/src/postgres/psql-executor.ts +4 -1
  49. package/src/postgres/role-adopt.test.ts +3 -1
  50. package/src/postgres/role-membership-options.test.ts +3 -1
  51. package/src/postgres/role-membership-repair.test.ts +3 -3
  52. package/src/postgres/role-membership-sql.ts +4 -4
  53. package/src/postgres/role-password-transports.test.ts +4 -2
  54. package/src/postgres/role-sql.ts +1 -1
  55. package/src/postgres/schema-closure-sql.ts +108 -0
  56. package/src/postgres/schema-delete-proofs.test.ts +7 -7
  57. package/src/postgres/schema-delete.ts +88 -46
  58. package/src/postgres/schema-drop-2bp01.test.ts +29 -17
  59. package/src/postgres/schema-drop-real.test.ts +190 -0
  60. package/src/postgres/schema-drop-sql.ts +135 -0
  61. package/src/postgres/schema-drop.test.ts +241 -0
  62. package/src/postgres/schema-errors.ts +26 -0
  63. package/src/postgres/schema-handlers.test.ts +21 -14
  64. package/src/postgres/schema-lifecycle.test.ts +6 -60
  65. package/src/postgres/schema-sequence-error.ts +38 -0
  66. package/src/postgres/schema-socket.test.ts +20 -6
  67. package/src/postgres/schema-sql.ts +16 -11
  68. package/src/postgres/schema-takeover.test.ts +7 -4
  69. package/src/postgres/schema-test-kit.ts +18 -4
  70. package/src/postgres/search-path.test.ts +100 -0
  71. package/src/postgres/search-path.ts +62 -0
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import * as Effect from 'effect/Effect';
12
12
  import { SqlError, SqlSyntaxError, UnknownError } from 'effect/unstable/sql/SqlError';
13
+ import { applyAtomicDrop } from './fake-schema-drop.ts';
13
14
  import { parseCommentSchema, parseCreateSchema, parseDropSchema } from './fake-sql-parse.ts';
14
15
  import type { PostgresSchemaAttributes } from './schema-attrs.ts';
15
16
 
@@ -18,6 +19,11 @@ export interface FakeSchemaState {
18
19
  readonly schemas: Map<string, PostgresSchemaAttributes>;
19
20
  /** Names of schemas this fake pretends hold at least one relation. */
20
21
  readonly relationsIn: Set<string>;
22
+ /** Per schema name: how many objects in OTHER schemas depend on it (what the atomic drop's
23
+ * `pg_depend` count answers). Absent means none. */
24
+ readonly dependentsOutside: Map<string, number>;
25
+ /** Per schema name: how many sequences it holds (a cascade drop refuses any: `HF004`). */
26
+ readonly sequencesIn: Map<string, number>;
21
27
  /** What `current_database()` answers, and the stamp on every read row. */
22
28
  readonly database: string;
23
29
  /** What `current_user` answers, and the owner of a `CREATE SCHEMA` without `AUTHORIZATION`. */
@@ -44,6 +50,9 @@ export const applySchemaStatement = <A extends object>(
44
50
  text: string,
45
51
  params: ReadonlyArray<unknown>,
46
52
  ): Effect.Effect<ReadonlyArray<A>, SqlError> | undefined => {
53
+ // The atomic drop's text contains every marker below; matched first, by its own prefix.
54
+ if (text.startsWith('DO ')) return applyAtomicDrop(state, text);
55
+
47
56
  if (text.startsWith('SELECT current_database()')) {
48
57
  return Effect.succeed([{ database: state.database }] as unknown as ReadonlyArray<A>);
49
58
  }
@@ -57,7 +66,7 @@ export const applySchemaStatement = <A extends object>(
57
66
  return Effect.succeed([{ empty: !state.relationsIn.has(name) }] as unknown as ReadonlyArray<A>);
58
67
  }
59
68
 
60
- if (text.includes('FROM pg_namespace')) {
69
+ if (text.includes('FROM pg_catalog.pg_namespace')) {
61
70
  const name = params[0] as string;
62
71
  const row = state.schemas.get(name);
63
72
  return Effect.succeed(
@@ -65,6 +65,10 @@ export interface FakeSqlOptions {
65
65
  readonly schemas?: ReadonlyArray<PostgresSchemaAttributes>;
66
66
  /** Names of schemas the fake answers `schemaIsEmpty` with `false` for. */
67
67
  readonly schemasWithRelations?: ReadonlyArray<string>;
68
+ /** Per schema name, how many objects in OTHER schemas depend on it (a cascade would drop
69
+ * them): what the atomic drop's `pg_depend` count answers. */
70
+ readonly dependentsOutside?: Readonly<Record<string, number>>;
71
+ readonly sequencesIn?: Readonly<Record<string, number>>; // per schema: sequences held (HF004)
68
72
  /** Accept the NEXT `CREATE SCHEMA` (no error) but record nothing — the S10 case where the
69
73
  * write's own report is a lie and the immediate re-read finds nothing. */
70
74
  readonly swallowNextCreateSchema?: boolean;
@@ -93,6 +97,7 @@ export const makeFakeSql = (options: FakeSqlOptions = {}): FakeSql => {
93
97
  >();
94
98
  const schemas = seedSchemas(options.schemas, options.database ?? 'postgres');
95
99
  const relationsIn = new Set(options.schemasWithRelations ?? []);
100
+ const dependentsOutside = new Map(Object.entries(options.dependentsOutside ?? {}));
96
101
  let raceRemaining = options.raceNextCreate === true ? 1 : 0;
97
102
  let failNext = options.failNext;
98
103
  let oidCounter = 20000;
@@ -111,6 +116,8 @@ export const makeFakeSql = (options: FakeSqlOptions = {}): FakeSql => {
111
116
  const schemaState: FakeSchemaState = {
112
117
  schemas,
113
118
  relationsIn,
119
+ dependentsOutside,
120
+ sequencesIn: new Map(Object.entries(options.sequencesIn ?? {})),
114
121
  database: options.database ?? 'postgres',
115
122
  standardConformingStrings: options.standardConformingStrings ?? true,
116
123
  sessionRole: options.currentUser ?? 'postgres',
@@ -139,7 +146,7 @@ export const makeFakeSql = (options: FakeSqlOptions = {}): FakeSql => {
139
146
  );
140
147
  }
141
148
 
142
- if (text.startsWith('SELECT 1 AS present FROM pg_roles')) {
149
+ if (text.startsWith('SELECT 1 AS present FROM pg_catalog.pg_roles')) {
143
150
  const role = params[0] as string;
144
151
  return Effect.succeed(
145
152
  (roleNames.has(role) || roleRows.has(role)
@@ -151,14 +158,14 @@ export const makeFakeSql = (options: FakeSqlOptions = {}): FakeSql => {
151
158
  // ⚠️ startsWith, BEFORE the `FROM pg_database` branch: the full-row select also contains
152
159
  // `FROM pg_database`, but starts with `SELECT d.oid` — only the existence probe starts
153
160
  // with `SELECT 1 AS present`.
154
- if (text.startsWith('SELECT 1 AS present FROM pg_database')) {
161
+ if (text.startsWith('SELECT 1 AS present FROM pg_catalog.pg_database')) {
155
162
  const name = params[0] as string;
156
163
  return Effect.succeed(
157
164
  (databases.has(name) ? [{ present: 1 }] : []) as unknown as ReadonlyArray<A>,
158
165
  );
159
166
  }
160
167
 
161
- if (text.includes('FROM pg_database')) {
168
+ if (text.includes('FROM pg_catalog.pg_database')) {
162
169
  const name = params[0] as string;
163
170
  const row = databases.get(name);
164
171
  return Effect.succeed((row === undefined ? [] : [row]) as unknown as ReadonlyArray<A>);
@@ -48,7 +48,7 @@ const recording = (probe: string, proof: string) => {
48
48
  const run: PsqlRunner = ({ stdin, argv }) => {
49
49
  stdins.push(stdin);
50
50
  argvs.push(argv);
51
- const stdout = stdin.includes('FROM pg_database')
51
+ const stdout = stdin.includes('FROM pg_catalog.pg_database')
52
52
  ? probe
53
53
  : stdin.includes('current_database()')
54
54
  ? proof
@@ -79,7 +79,7 @@ describe('a missing declared database is absent', () => {
79
79
  // current_database()/GRANT/REVOKE ever targeted the (absent) declared database.
80
80
  expect(result).toBeUndefined();
81
81
  expect(stdins.length).toBe(1);
82
- expect(stdins[0]).toContain('FROM pg_database');
82
+ expect(stdins[0]).toContain('FROM pg_catalog.pg_database');
83
83
  expect(stdins[0]).not.toContain('current_database()');
84
84
  expect(argvs[0]?.at(-1)).toBe('postgres');
85
85
  });
@@ -106,7 +106,7 @@ describe('a present database is opened and proved', () => {
106
106
  .pipe(Effect.provide(provide(run))),
107
107
  );
108
108
  expect(argvs[0]?.at(-1)).toBe('postgres');
109
- expect(stdins[0]).toContain('FROM pg_database');
109
+ expect(stdins[0]).toContain('FROM pg_catalog.pg_database');
110
110
  expect(argvs.slice(1).every((argv) => argv.at(-1) === 'agents')).toBe(true);
111
111
  expect(stdins[1]).toContain('current_database()');
112
112
  });
@@ -129,7 +129,7 @@ describe('a present database is opened and proved', () => {
129
129
  // declared database and proves current_database() there.
130
130
  expect(argvs.length).toBe(2);
131
131
  expect(argvs[0]?.at(-1)).toBe('postgres');
132
- expect(stdins[0]).toContain('FROM pg_database');
132
+ expect(stdins[0]).toContain('FROM pg_catalog.pg_database');
133
133
  expect(argvs[1]?.at(-1)).toBe('agents');
134
134
  expect(stdins[1]).toContain('current_database()');
135
135
  });
@@ -152,7 +152,7 @@ describe('a missing declared database on reconcile is a typed refusal', () => {
152
152
  expect(error).toMatchObject({ database: 'agents' });
153
153
  // Only the family-connection probe ran; reconcile never opened the declared database.
154
154
  expect(stdins.length).toBe(1);
155
- expect(stdins[0]).toContain('FROM pg_database');
155
+ expect(stdins[0]).toContain('FROM pg_catalog.pg_database');
156
156
  expect(argvs[0]?.at(-1)).toBe('postgres');
157
157
  });
158
158
  });
@@ -15,9 +15,9 @@ import { PostgresGrantsColumnMissing, PostgresGrantsTableMissing } from './grant
15
15
  * (`func.sgml@REL_18_6`). */
16
16
  const TABLES_PRESENT_SQL = `SELECT
17
17
  c.relname AS table
18
- FROM pg_class c
19
- WHERE c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1)
20
- AND c.relname::text IN (SELECT json_array_elements_text($2::json))`;
18
+ FROM pg_catalog.pg_class c
19
+ WHERE c.relnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = $1)
20
+ AND c.relname::text IN (SELECT pg_catalog.json_array_elements_text($2::json))`;
21
21
 
22
22
  const findMissingTable = (
23
23
  pg: PgExecutor,
@@ -41,13 +41,13 @@ const findMissingTable = (
41
41
  const COLUMNS_PRESENT_SQL = `SELECT
42
42
  c.relname AS table,
43
43
  a.attname AS column
44
- FROM pg_attribute a
45
- JOIN pg_class c ON c.oid = a.attrelid
46
- WHERE c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1)
44
+ FROM pg_catalog.pg_attribute a
45
+ JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
46
+ WHERE c.relnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = $1)
47
47
  AND a.attnum > 0
48
48
  AND NOT a.attisdropped
49
49
  AND (c.relname, a.attname) IN (
50
- SELECT x->>0, x->>1 FROM json_array_elements($2::json) x
50
+ SELECT x->>0, x->>1 FROM pg_catalog.json_array_elements($2::json) x
51
51
  )`;
52
52
 
53
53
  const findMissingColumn = (
@@ -17,8 +17,8 @@ import type { PgExecutor } from './database-sql.ts';
17
17
  /** `pg_namespace.nspowner = <role>` for one schema. No rows when the schema is absent —
18
18
  * the caller then reads the fact as `false`. */
19
19
  const SCHEMA_OWNER_SQL = `SELECT
20
- n.nspowner = (SELECT oid FROM pg_roles WHERE rolname = $2) AS role_owns
21
- FROM pg_namespace n
20
+ n.nspowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = $2) AS role_owns
21
+ FROM pg_catalog.pg_namespace n
22
22
  WHERE n.nspname = $1`;
23
23
 
24
24
  export const readSchemaOwnership = (
@@ -34,9 +34,9 @@ export const readSchemaOwnership = (
34
34
  /** Every relation in the schema the declared role OWNS, all relkinds (the table class is
35
35
  * not relkind-filtered, and neither is this). */
36
36
  const OWNED_TABLES_SQL = `SELECT c.relname AS table
37
- FROM pg_class c
38
- WHERE c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1)
39
- AND c.relowner = (SELECT oid FROM pg_roles WHERE rolname = $2)
37
+ FROM pg_catalog.pg_class c
38
+ WHERE c.relnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = $1)
39
+ AND c.relowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = $2)
40
40
  ORDER BY c.relname`;
41
41
 
42
42
  export const readOwnedTables = (
@@ -8,6 +8,7 @@ import { describe, expect, test } from 'bun:test';
8
8
  import * as Effect from 'effect/Effect';
9
9
  import type { PsqlRunner } from './psql-executor.ts';
10
10
  import { makePsqlExecutor } from './psql-executor.ts';
11
+ import { stripPin } from './search-path.ts';
11
12
  import { reconcileWithClient } from './grants-ops.ts';
12
13
  import { requireDeclaredObjectsExist } from './grants-existence.ts';
13
14
  import { resolveProps } from './grants-declare.ts';
@@ -26,7 +27,8 @@ const props: PostgresGrantsProps = {
26
27
  const runner = (): { run: PsqlRunner; stdins: string[] } => {
27
28
  const stdins: string[] = [];
28
29
  let granted = false;
29
- const run: PsqlRunner = ({ stdin }) => {
30
+ const run: PsqlRunner = ({ stdin: raw }) => {
31
+ const stdin = stripPin(raw);
30
32
  stdins.push(stdin);
31
33
  if (stdin.startsWith('BEGIN')) granted = true;
32
34
  return Promise.resolve({ code: 0, stdout: answer(stdin, granted), stderr: '' });
@@ -40,8 +42,10 @@ const answer = (stdin: string, granted: boolean): string => {
40
42
  if (stdin.includes('json_array_elements')) {
41
43
  return '[{"table":"a","column":"b.c"},{"table":"a.b","column":"c"}]';
42
44
  }
43
- if (stdin.includes('SELECT 1 AS present FROM pg_roles')) return '[{"present":1}]';
44
- if (stdin.includes('SELECT 1 AS present FROM pg_namespace')) return '[{"present":1}]';
45
+ if (stdin.includes('SELECT 1 AS present FROM pg_catalog.pg_roles')) return '[{"present":1}]';
46
+ if (stdin.includes('SELECT 1 AS present FROM pg_catalog.pg_namespace')) {
47
+ return '[{"present":1}]';
48
+ }
45
49
  if (stdin.includes('aclexplode(n.nspacl)')) {
46
50
  return granted ? '[{"public":false,"privilege":"usage","grantable":false}]' : '[]';
47
51
  }
@@ -15,14 +15,14 @@ import { type AclRow, encodeWord } from './grants-words.ts';
15
15
  * role's words, whatever role created the entry. Sorted by forRole so two reads of the
16
16
  * same state compare equal. */
17
17
  const DEFAULTS_SQL = `SELECT
18
- pg_get_userbyid(d.defaclrole) AS for_role,
18
+ pg_catalog.pg_get_userbyid(d.defaclrole) AS for_role,
19
19
  a.privilege_type AS privilege,
20
20
  a.is_grantable AS grantable
21
- FROM pg_default_acl d
22
- CROSS JOIN LATERAL aclexplode(d.defaclacl) AS a
23
- WHERE d.defaclnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1)
21
+ FROM pg_catalog.pg_default_acl d
22
+ CROSS JOIN LATERAL pg_catalog.aclexplode(d.defaclacl) AS a
23
+ WHERE d.defaclnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = $1)
24
24
  AND d.defaclobjtype = 'r'
25
- AND a.grantee = (SELECT oid FROM pg_roles WHERE rolname = $2)
25
+ AND a.grantee = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = $2)
26
26
  ORDER BY for_role`;
27
27
 
28
28
  export interface LiveDefault {
@@ -38,7 +38,7 @@ import { readOwnedTables, readSchemaOwnership } from './grants-ownership.ts';
38
38
  import { type LiveDefault, readDefaultAcls } from './grants-read-defaults.ts';
39
39
  import { type AclRow, encodeWord, wordsOf } from './grants-words.ts';
40
40
 
41
- const SCHEMA_PRESENT_SQL = 'SELECT 1 AS present FROM pg_namespace WHERE nspname = $1';
41
+ const SCHEMA_PRESENT_SQL = 'SELECT 1 AS present FROM pg_catalog.pg_namespace WHERE nspname = $1';
42
42
 
43
43
  export const schemaExists = (pg: PgExecutor, schema: string): Effect.Effect<boolean, SqlError> =>
44
44
  Effect.map(
@@ -52,10 +52,10 @@ const SCHEMA_ACL_SQL = `SELECT
52
52
  a.grantee = 0 AS public,
53
53
  a.privilege_type AS privilege,
54
54
  a.is_grantable AS grantable
55
- FROM pg_namespace n
56
- CROSS JOIN LATERAL aclexplode(n.nspacl) AS a
55
+ FROM pg_catalog.pg_namespace n
56
+ CROSS JOIN LATERAL pg_catalog.aclexplode(n.nspacl) AS a
57
57
  WHERE n.nspname = $1
58
- AND (a.grantee = 0 OR a.grantee = (SELECT oid FROM pg_roles WHERE rolname = $2))`;
58
+ AND (a.grantee = 0 OR a.grantee = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = $2))`;
59
59
 
60
60
  export interface LiveSchemaAcl {
61
61
  readonly role: ReadonlyArray<string>;
@@ -84,10 +84,10 @@ const TABLES_SQL = `SELECT
84
84
  a.grantee = 0 AS public,
85
85
  a.privilege_type AS privilege,
86
86
  a.is_grantable AS grantable
87
- FROM pg_class c
88
- CROSS JOIN LATERAL aclexplode(c.relacl) AS a
89
- WHERE c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1)
90
- AND (a.grantee = 0 OR a.grantee = (SELECT oid FROM pg_roles WHERE rolname = $2))
87
+ FROM pg_catalog.pg_class c
88
+ CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) AS a
89
+ WHERE c.relnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = $1)
90
+ AND (a.grantee = 0 OR a.grantee = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = $2))
91
91
  ORDER BY c.relname`;
92
92
 
93
93
  export interface LiveTable {
@@ -146,17 +146,17 @@ const COLUMNS_SQL = `SELECT
146
146
  a.grantee = 0 AS public,
147
147
  a.privilege_type AS privilege,
148
148
  a.is_grantable AS grantable,
149
- pg_get_userbyid(a.grantor) AS grantor,
150
- pg_get_userbyid(c.relowner) AS owner,
149
+ pg_catalog.pg_get_userbyid(a.grantor) AS grantor,
150
+ pg_catalog.pg_get_userbyid(c.relowner) AS owner,
151
151
  current_user AS revoker,
152
- pg_has_role(current_user, c.relowner, 'USAGE') AS revoker_as_owner
153
- FROM pg_attribute v
154
- JOIN pg_class c ON c.oid = v.attrelid
155
- CROSS JOIN LATERAL aclexplode(v.attacl) AS a
156
- WHERE c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1)
152
+ pg_catalog.pg_has_role(current_user, c.relowner, 'USAGE') AS revoker_as_owner
153
+ FROM pg_catalog.pg_attribute v
154
+ JOIN pg_catalog.pg_class c ON c.oid = v.attrelid
155
+ CROSS JOIN LATERAL pg_catalog.aclexplode(v.attacl) AS a
156
+ WHERE c.relnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = $1)
157
157
  AND v.attnum > 0
158
158
  AND NOT v.attisdropped
159
- AND (a.grantee = 0 OR a.grantee = (SELECT oid FROM pg_roles WHERE rolname = $2))
159
+ AND (a.grantee = 0 OR a.grantee = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = $2))
160
160
  ORDER BY c.relname, v.attname`;
161
161
 
162
162
  export interface LiveColumn {
@@ -49,7 +49,9 @@ export {
49
49
  diffPostgresSchema,
50
50
  isPostgresSchema,
51
51
  } from './schema.ts';
52
+ export { PostgresSchemaCascadeSequencesRefused } from './schema-sequence-error.ts';
52
53
  export {
54
+ PostgresSchemaCascadeCrossSchemaRefused,
53
55
  PostgresSchemaCreateVanished,
54
56
  PostgresSchemaExistsRefused,
55
57
  PostgresSchemaIdentityRefused,
@@ -6,6 +6,7 @@ import { parseLiteral } from './fake-sql-quote.ts';
6
6
  import { buildCommentSchemaSql } from './schema-sql.ts';
7
7
  import { buildSetPasswordSql } from './role-sql.ts';
8
8
  import { makePsqlExecutor, redactPasswordLiterals } from './psql-executor.ts';
9
+ import { stripPin } from './search-path.ts';
9
10
 
10
11
  const payload = "\\'; CREATE SCHEMA injected; --";
11
12
  const target = { database: 'postgres', username: 'postgres' };
@@ -22,7 +23,8 @@ for (const on of [true, false]) {
22
23
  test(`psql executor comment and bound param stay one literal with standard_conforming_strings=${String(on)}`, async () => {
23
24
  const fake = makeFakeSql({ schemas: [row], standardConformingStrings: on });
24
25
  const calls: string[] = [];
25
- const pg = makePsqlExecutor(async ({ stdin }) => {
26
+ const pg = makePsqlExecutor(async ({ stdin: raw }) => {
27
+ const stdin = stripPin(raw);
26
28
  calls.push(stdin);
27
29
  if (stdin.startsWith('COMMENT')) {
28
30
  await Effect.runPromise(fake.unsafe(stdin.slice(0, -1)));
@@ -7,6 +7,7 @@ import { buildCreateDatabaseSql, isDuplicateDatabaseRace } from './database-sql.
7
7
  import { buildSetPasswordSql } from './role-sql.ts';
8
8
  import { scramSha256Verifier } from './role-scram.ts';
9
9
  import { type PsqlRunner, inlineParams, makePsqlExecutor } from './psql-executor.ts';
10
+ import { stripPin } from './search-path.ts';
10
11
 
11
12
  const target = { database: 'postgres', username: 'postgres' };
12
13
  const ok = (stdout: string) => Promise.resolve({ code: 0, stdout, stderr: '' });
@@ -24,7 +25,10 @@ describe('psql executor', () => {
24
25
  return ok('[{"oid":"16400","name":"x"}]\n');
25
26
  };
26
27
  const rows = await Effect.runPromise(
27
- makePsqlExecutor(run, target).unsafe('SELECT 1 FROM pg_database WHERE datname = $1', ['x']),
28
+ makePsqlExecutor(run, target).unsafe(
29
+ 'SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1',
30
+ ['x'],
31
+ ),
28
32
  );
29
33
  expect(rows).toEqual([{ oid: 16400, name: 'x' }]);
30
34
  expect(calls[0]?.argv.slice(0, 1)).toEqual(['psql']);
@@ -114,10 +118,12 @@ describe('runner transport through withPg', () => {
114
118
  tablespace: 'pg_default',
115
119
  };
116
120
  let created = false;
117
- const run: PsqlRunner = ({ stdin }) => {
121
+ const run: PsqlRunner = ({ stdin: raw }) => {
122
+ const stdin = stripPin(raw);
118
123
  stdins.push(stdin);
119
- if (stdin.includes('pg_roles')) return ok('[{"present":1}]');
120
- if (stdin.includes('FROM pg_database')) return ok(created ? JSON.stringify([row]) : '[]');
124
+ if (stdin.includes('pg_catalog.pg_roles')) return ok('[{"present":1}]');
125
+ if (stdin.includes('FROM pg_catalog.pg_database'))
126
+ return ok(created ? JSON.stringify([row]) : '[]');
121
127
  if (stdin.startsWith('CREATE DATABASE')) created = true;
122
128
  return ok('');
123
129
  };
@@ -141,7 +147,7 @@ describe('runner transport through withPg', () => {
141
147
  test('transaction is one psql script from BEGIN through COMMIT', async () => {
142
148
  const calls: string[] = [];
143
149
  const run: PsqlRunner = (call) => {
144
- calls.push(call.stdin);
150
+ calls.push(stripPin(call.stdin));
145
151
  return ok('');
146
152
  };
147
153
  await Effect.runPromise(
@@ -14,6 +14,7 @@
14
14
  * ★ ROWS COME BACK AS JSON: a row-returning statement (`SELECT`, or a `WITH` query) is wrapped
15
15
  * in `json_agg`, so `oid` and `datconnlimit` arrive as JS numbers exactly as the socket client
16
16
  * decodes them (database-sql.ts, no bigint).
17
+ * ⛔ EVERY SCRIPT IS PREFIXED WITH `SET search_path = pg_catalog, pg_temp;` — see `search-path.ts`.
17
18
  * ★ `VERBOSITY=verbose` puts the SQLSTATE in the error line, so `42P04` (duplicate database)
18
19
  * stays recognisable to `isDuplicateDatabaseRace`.
19
20
  */
@@ -25,6 +26,7 @@ import {
25
26
  UnknownError,
26
27
  } from 'effect/unstable/sql/SqlError';
27
28
  import { type PgExecutor, quoteStringLiteral } from './database-sql.ts';
29
+ import { PIN_SCRIPT_PREFIX } from './search-path.ts';
28
30
 
29
31
  export interface PsqlResult {
30
32
  readonly code: number;
@@ -122,7 +124,8 @@ const runScript = (
122
124
  '-d',
123
125
  target.database,
124
126
  ],
125
- stdin,
127
+ // ⛔ EVERY psql SESSION STARTS PINNED (`search-path.ts`): one process, one session.
128
+ stdin: `${PIN_SCRIPT_PREFIX}${stdin}`,
126
129
  }),
127
130
  catch: (cause) =>
128
131
  new SqlError({ reason: new ConnectionError({ cause, operation: 'psql exec' }) }),
@@ -13,6 +13,7 @@ import * as Effect from 'effect/Effect';
13
13
  import { postgresRunnerConnection } from './connection.ts';
14
14
  import { parseLiteral } from './fake-sql-quote.ts';
15
15
  import { makeFakeSql } from './fake-sql.ts';
16
+ import { stripPin } from './search-path.ts';
16
17
  import { type PsqlRunner } from './psql-executor.ts';
17
18
  import { postgresRoleHandlers } from './role-provider.ts';
18
19
  import type { PostgresRoleAttributes, PostgresRoleProps } from './role-attrs.ts';
@@ -42,7 +43,8 @@ const liveRole = (): PostgresRoleAttributes => ({
42
43
  */
43
44
  const runnerFor =
44
45
  (fake: ReturnType<typeof makeFakeSql>): PsqlRunner =>
45
- async ({ stdin }) => {
46
+ async ({ stdin: raw }) => {
47
+ const stdin = stripPin(raw);
46
48
  const wrapped = /^SELECT coalesce\(json_agg\(t\), '\[\]'::json\)::text FROM \((.*)\) t;$/s.exec(
47
49
  stdin,
48
50
  );
@@ -3,6 +3,7 @@ import * as Effect from 'effect/Effect';
3
3
  import { makeFakeSql } from './fake-sql.ts';
4
4
  import { buildRepairMembershipSql, syncMemberships } from './role-membership-sql.ts';
5
5
  import { makePsqlExecutor } from './psql-executor.ts';
6
+ import { stripPin } from './search-path.ts';
6
7
 
7
8
  const props = {
8
9
  name: 'seat',
@@ -34,7 +35,8 @@ test.each(['2BP01', '42501'])(
34
35
  // denial); the real server is the dependency authority.
35
36
  const scripts: string[] = [];
36
37
  const pg = makePsqlExecutor(
37
- ({ stdin }) => {
38
+ ({ stdin: raw }) => {
39
+ const stdin = stripPin(raw);
38
40
  scripts.push(stdin);
39
41
  return Promise.resolve(
40
42
  stdin.startsWith('BEGIN')
@@ -84,9 +84,9 @@ test('reads INHERIT and repairs another grantor row for declared NOINHERIT', asy
84
84
  expect(before?.memberships).toEqual([
85
85
  { parent: 'parent', grantor: 'other', admin: false, set: false, inherit: true },
86
86
  ]);
87
- expect(fake.statements.find((s) => s.text.includes('FROM pg_auth_members'))?.text).toContain(
88
- 'm.inherit_option AS inherit',
89
- );
87
+ expect(
88
+ fake.statements.find((s) => s.text.includes('FROM pg_catalog.pg_auth_members'))?.text,
89
+ ).toContain('m.inherit_option AS inherit');
90
90
  expect(await Effect.runPromise(diffPostgresRole(props, live, {}, { found: before }))).toEqual({
91
91
  action: 'update',
92
92
  });
@@ -40,10 +40,10 @@ const MEMBERSHIP_SQL = `SELECT parent.rolname AS parent,
40
40
  m.admin_option AS admin,
41
41
  m.set_option AS set,
42
42
  m.inherit_option AS inherit
43
- FROM pg_auth_members m
44
- JOIN pg_roles member ON member.oid = m.member
45
- JOIN pg_roles parent ON parent.oid = m.roleid
46
- LEFT JOIN pg_roles grantor ON grantor.oid = m.grantor
43
+ FROM pg_catalog.pg_auth_members m
44
+ JOIN pg_catalog.pg_roles member ON member.oid = m.member
45
+ JOIN pg_catalog.pg_roles parent ON parent.oid = m.roleid
46
+ LEFT JOIN pg_catalog.pg_roles grantor ON grantor.oid = m.grantor
47
47
  WHERE member.rolname = $1
48
48
  ORDER BY parent.rolname, grantor.rolname NULLS FIRST`;
49
49
 
@@ -9,6 +9,7 @@ import type { Connection } from 'effect/unstable/sql/SqlConnection';
9
9
  import type { PgExecutor } from './database-sql.ts';
10
10
  import { makeFakeSql } from './fake-sql.ts';
11
11
  import { type PsqlRunner, makePsqlExecutor } from './psql-executor.ts';
12
+ import { stripPin } from './search-path.ts';
12
13
  import { reconcileWithClient } from './role.ts';
13
14
 
14
15
  const props = {
@@ -84,7 +85,8 @@ test('psql runner receives only a verifier; echoed failure and state hold no pla
84
85
  for (const fail of [false, true]) {
85
86
  const calls: Parameters<PsqlRunner>[0][] = [];
86
87
  let created = exists;
87
- const run: PsqlRunner = (call) => {
88
+ const run: PsqlRunner = (raw) => {
89
+ const call = { ...raw, stdin: stripPin(raw.stdin) };
88
90
  calls.push(call);
89
91
  if (call.stdin.includes('PASSWORD') && fail) {
90
92
  return Promise.resolve({
@@ -97,7 +99,7 @@ test('psql runner receives only a verifier; echoed failure and state hold no pla
97
99
  return Promise.resolve({
98
100
  code: 0,
99
101
  stderr: '',
100
- stdout: call.stdin.includes('FROM pg_roles r')
102
+ stdout: call.stdin.includes('FROM pg_catalog.pg_roles r')
101
103
  ? JSON.stringify(created ? [live] : [])
102
104
  : '[]',
103
105
  });
@@ -46,7 +46,7 @@ const SELECT_ROLE_SQL = `SELECT
46
46
  r.rolreplication AS replication,
47
47
  r.rolbypassrls AS bypassrls,
48
48
  to_char(r.rolvaliduntil AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "validUntil"
49
- FROM pg_roles r
49
+ FROM pg_catalog.pg_roles r
50
50
  WHERE r.rolname = $1`;
51
51
 
52
52
  export const selectRole = (
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The dependency closure `DROP SCHEMA … CASCADE` would walk, and the count of what it would take
3
+ * that does NOT live in the schema being dropped (`schema-drop-sql.ts` refuses when it is > 0).
4
+ *
5
+ * ⛔ THE CLOSURE IS DERIVED FROM THE NAMESPACE OBJECT, NOT FROM A LIST OF CLASSES. Until
6
+ * 2026-10-06 the scan started from four catalogs (`pg_class`, `pg_type`, `pg_proc`,
7
+ * `pg_operator`), so `a.c` (a collation) used by `b.t.x` was invisible and the cascade reached
8
+ * into `b.t.x` (Codex read of PR 357). Now the walk starts at `(pg_namespace, v_ns)` and follows
9
+ * EVERY `pg_depend` row whose referenced object is already inside — the same edges
10
+ * `findDependentObjects` (`dependency.c`) follows — so any object class `pg_depend` can name
11
+ * (collation, conversion, opclass, opfamily, text-search, statistics, extension members, …)
12
+ * is reached with no catalog enumerated to find it.
13
+ * ★ EVERY `deptype` FOLLOWS (`pg_depend`, catalogs.sgml); they differ only in attribution:
14
+ * n NORMAL cascade drops the dependent → followed; foreign if it lives elsewhere
15
+ * a AUTO dropped with any drop → followed; foreign if it lives elsewhere
16
+ * (an owned sequence in another schema is dropped with its table)
17
+ * i INTERNAL a part of the referenced object → followed; attributed to its owner (below)
18
+ * P / S partition primary / secondary → followed; a partition in another
19
+ * schema is dropped with its parent, so it is foreign
20
+ * e EXTENSION member of an extension → followed; the extension's members are
21
+ * dropped with it, wherever they live
22
+ * x AUTO-EXT auto, and dropped with the extension → followed like `a`
23
+ * p PIN system object, `refobjid = 0` → never a referenced row, never joins
24
+ * ★ "INSIDE" is a home-schema test per class. TOAST is the case the old scan got wrong: a text
25
+ * table's TOAST relation (and its index) lives in `pg_toast` and depends internally on the
26
+ * table, so it is attributed to the table that owns it via `reltoastrelid` — otherwise every
27
+ * cascade of a schema with a text column was refused. Constraints, triggers, rules, policies
28
+ * and defaults have no schema of their own and take their table's.
29
+ * ⚠️ A CLASS THIS FILE DOES NOT KNOW (a cast, a foreign server, a language, an event trigger)
30
+ * resolves to NULL, which is NOT inside: it counts as foreign. Unknown fails CLOSED. The walk
31
+ * stops at a foreign object — one is enough to refuse.
32
+ * ★ A dependent created AFTER this scan is held off by the caller's locks (`schema-drop-sql.ts`).
33
+ */
34
+
35
+ const cls = (name: string): string => `'pg_catalog.${name}'::pg_catalog.regclass`;
36
+
37
+ /** `WHEN <class> THEN <namespace column of the row>` for a catalog that carries its own. */
38
+ const direct = (table: string, column: string): string =>
39
+ `WHEN ${cls(table)} THEN (SELECT x.${column} FROM pg_catalog.${table} x WHERE x.oid = d.objid)`;
40
+
41
+ /** The same for an object with no schema of its own: the schema of the relation it hangs on. */
42
+ const viaRelation = (table: string, column: string): string =>
43
+ `WHEN ${cls(table)} THEN (SELECT c.relnamespace FROM pg_catalog.${table} x JOIN pg_catalog.pg_class c ON c.oid = x.${column} WHERE x.oid = d.objid)`;
44
+
45
+ /** An access-method member belongs to its operator family. */
46
+ const viaFamily = (table: string, column: string): string =>
47
+ `WHEN ${cls(table)} THEN (SELECT f.opfnamespace FROM pg_catalog.${table} x JOIN pg_catalog.pg_opfamily f ON f.oid = x.${column} WHERE x.oid = d.objid)`;
48
+
49
+ /** A relation's home. A TOAST relation, or the index on one, is its owner's. */
50
+ const RELATION_HOME = `WHEN ${cls('pg_class')} THEN COALESCE(
51
+ (SELECT o.relnamespace FROM pg_catalog.pg_class o
52
+ WHERE o.reltoastrelid <> 0
53
+ AND o.reltoastrelid IN (d.objid, (SELECT i.indrelid FROM pg_catalog.pg_index i WHERE i.indexrelid = d.objid))),
54
+ (SELECT x.relnamespace FROM pg_catalog.pg_class x WHERE x.oid = d.objid))`;
55
+
56
+ const HOME_OF_DEPENDENT = `CASE d.classid
57
+ WHEN ${cls('pg_namespace')} THEN d.objid
58
+ ${RELATION_HOME}
59
+ ${direct('pg_type', 'typnamespace')}
60
+ ${direct('pg_proc', 'pronamespace')}
61
+ ${direct('pg_operator', 'oprnamespace')}
62
+ ${direct('pg_collation', 'collnamespace')}
63
+ ${direct('pg_conversion', 'connamespace')}
64
+ ${direct('pg_opclass', 'opcnamespace')}
65
+ ${direct('pg_opfamily', 'opfnamespace')}
66
+ ${direct('pg_ts_config', 'cfgnamespace')}
67
+ ${direct('pg_ts_dict', 'dictnamespace')}
68
+ ${direct('pg_ts_parser', 'prsnamespace')}
69
+ ${direct('pg_ts_template', 'tmplnamespace')}
70
+ ${direct('pg_statistic_ext', 'stxnamespace')}
71
+ ${direct('pg_constraint', 'connamespace')}
72
+ ${direct('pg_default_acl', 'defaclnamespace')}
73
+ ${direct('pg_extension', 'extnamespace')}
74
+ ${viaRelation('pg_trigger', 'tgrelid')}
75
+ ${viaRelation('pg_rewrite', 'ev_class')}
76
+ ${viaRelation('pg_attrdef', 'adrelid')}
77
+ ${viaRelation('pg_policy', 'polrelid')}
78
+ ${viaRelation('pg_publication_rel', 'prrelid')}
79
+ ${viaFamily('pg_amop', 'amopfamily')}
80
+ ${viaFamily('pg_amproc', 'amprocfamily')}
81
+ END`;
82
+
83
+ /**
84
+ * `v_count := …`: how many objects the cascade would drop outside `v_ns`, PLUS how many objects
85
+ * INSIDE it hold an `e` or `i` edge to an object outside the closure.
86
+ * ⛔ THE SECOND TERM IS THE OWNERSHIP ESCAPE (Codex read of PR 357, 2026-10-06). An inside table
87
+ * added to a foreign extension (`ALTER EXTENSION x ADD TABLE a.t`) has an OUTGOING `e` edge; the
88
+ * walk only follows edges INTO the closure, so it saw nothing, while `findDependentObjects`
89
+ * redirects the deletion of an extension member / internal part to its OWNER and drops that
90
+ * owner's other objects too. Any outgoing `e` or `i` edge leaving the closure counts as foreign:
91
+ * refuse, never follow-and-classify (the simplest correct answer fails closed).
92
+ */
93
+ export const OUTSIDE_DEPENDENTS_SQL = `v_count := (
94
+ WITH RECURSIVE reach (classid, objid, inside) AS (
95
+ SELECT ${cls('pg_namespace')}::pg_catalog.oid, v_ns, true
96
+ UNION
97
+ SELECT d.classid, d.objid, (${HOME_OF_DEPENDENT}) IS NOT DISTINCT FROM v_ns
98
+ FROM pg_catalog.pg_depend d
99
+ JOIN reach r ON d.refclassid = r.classid AND d.refobjid = r.objid AND r.inside
100
+ WHERE d.deptype <> 'p'
101
+ )
102
+ SELECT (SELECT pg_catalog.count(*) FROM reach WHERE NOT inside)
103
+ + (SELECT pg_catalog.count(*) FROM pg_catalog.pg_depend e
104
+ JOIN reach r ON e.classid = r.classid AND e.objid = r.objid AND r.inside
105
+ WHERE e.deptype IN ('e', 'i') AND e.refobjid <> 0
106
+ AND NOT EXISTS (SELECT 1 FROM reach o
107
+ WHERE o.inside AND o.classid = e.refclassid AND o.objid = e.refobjid))
108
+ )`;