@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
@@ -65,7 +65,7 @@ describe('delete drops only what it can prove it created (Important 1)', () => {
65
65
  ),
66
66
  ),
67
67
  );
68
- expect(stdins.some((s) => s.startsWith('DROP SCHEMA'))).toBe(false);
68
+ expect(stdins.some((s) => s.startsWith('DO '))).toBe(false);
69
69
  });
70
70
 
71
71
  test('refuses on oid mismatch, CASCADE included — no DROP issued', async () => {
@@ -91,7 +91,7 @@ describe('delete drops only what it can prove it created (Important 1)', () => {
91
91
  lastOid: 16442,
92
92
  lastOwner: 'seat_role',
93
93
  });
94
- expect(stdins.some((s) => s.startsWith('DROP SCHEMA'))).toBe(false);
94
+ expect(stdins.some((s) => s.startsWith('DO '))).toBe(false);
95
95
  // Refused before the guarded drop, so the emptiness check never ran either.
96
96
  expect(stdins.some((s) => s.includes('AS empty'))).toBe(false);
97
97
  });
@@ -118,7 +118,7 @@ describe('delete drops only what it can prove it created (Important 1)', () => {
118
118
  lastOid: 16442,
119
119
  lastOwner: 'seat_role',
120
120
  });
121
- expect(stdins.some((s) => s.startsWith('DROP SCHEMA'))).toBe(false);
121
+ expect(stdins.some((s) => s.startsWith('DO '))).toBe(false);
122
122
  });
123
123
 
124
124
  test('a live row with no persisted proof fails closed — the delete client core refuses', async () => {
@@ -134,7 +134,7 @@ describe('delete drops only what it can prove it created (Important 1)', () => {
134
134
  lastOid: undefined,
135
135
  lastOwner: undefined,
136
136
  });
137
- expect(stdins.some((s) => s.startsWith('DROP SCHEMA'))).toBe(false);
137
+ expect(stdins.some((s) => s.startsWith('DO '))).toBe(false);
138
138
  });
139
139
  });
140
140
 
@@ -153,7 +153,7 @@ describe('a missing declared database is schema absent (Important 2)', () => {
153
153
  ),
154
154
  );
155
155
  expect(stdins.length).toBe(1);
156
- expect(stdins[0]).toContain('FROM pg_database');
156
+ expect(stdins[0]).toContain('FROM pg_catalog.pg_database');
157
157
  expect(argvs.length).toBe(1);
158
158
  expect(argvs[0]?.at(-1)).toBe('postgres');
159
159
  });
@@ -169,7 +169,7 @@ describe('a missing declared database is schema absent (Important 2)', () => {
169
169
  );
170
170
  expect(result).toBeUndefined();
171
171
  expect(stdins.length).toBe(1);
172
- expect(stdins[0]).toContain('FROM pg_database');
172
+ expect(stdins[0]).toContain('FROM pg_catalog.pg_database');
173
173
  });
174
174
 
175
175
  test('the `database` override reaches `psql -d`: the probe targets the family database, the rest the declared one', async () => {
@@ -185,7 +185,7 @@ describe('a missing declared database is schema absent (Important 2)', () => {
185
185
  ),
186
186
  ),
187
187
  );
188
- expect(stdins.some((s) => s.startsWith('DROP SCHEMA'))).toBe(true);
188
+ expect(stdins.some((s) => s.startsWith('DO '))).toBe(true);
189
189
  expect(argvs[0]?.at(-1)).toBe('postgres');
190
190
  expect(argvs.slice(1).every((argv) => argv.at(-1) === 'agents')).toBe(true);
191
191
  });
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `Postgres.Schema`'s drop path: prove the connection, prove the schema is still ours, then
3
- * the guarded `DROP SCHEMA`.
3
+ * ONE atomic statement that re-proves it under a lock and drops (`schema-drop-sql.ts`).
4
4
  *
5
5
  * ⛔ THE ENGINE DOES NOT RE-READ BEFORE A DELETE THAT HAS STATE (alchemy `Apply.ts`). When
6
6
  * persisted attributes exist, `delete` receives the LAST asserted `oid` + `owner` and no
@@ -10,6 +10,11 @@
10
10
  * row that no longer matches the persisted proof fails with
11
11
  * `PostgresSchemaDeleteForeignRefused` (`schema-assert.ts#deleteForeignRefusal`) before any
12
12
  * `DROP` is issued.
13
+ * ⛔ THE RE-READ ALONE IS A WINDOW. It is a separate autocommit statement (a separate `psql` on
14
+ * the runner transport), so the schema can change between the read and the drop. The drop
15
+ * therefore carries the proven `oid` + `owner` INTO one `DO` block that locks, re-verifies
16
+ * them, checks emptiness or cross-schema dependents, and drops in one transaction. This
17
+ * file's read is the typed, readable refusal; the block is the guarantee.
13
18
  */
14
19
  import * as Effect from 'effect/Effect';
15
20
  import type { SqlError } from 'effect/unstable/sql/SqlError';
@@ -17,80 +22,117 @@ import type { PgExecutor } from './database-sql.ts';
17
22
  import type { PostgresSchemaAttributes, PostgresSchemaProps } from './schema-attrs.ts';
18
23
  import { assertDatabase, deleteForeignRefusal } from './schema-assert.ts';
19
24
  import {
20
- type PostgresSchemaDeleteForeignRefused,
25
+ PostgresSchemaCascadeCrossSchemaRefused,
26
+ PostgresSchemaDeleteForeignRefused,
21
27
  PostgresSchemaDropNotEmptyError,
22
28
  type PostgresSchemaWrongDatabase,
23
29
  } from './schema-errors.ts';
24
30
  import {
25
- buildDropSchemaSql,
26
- isDependentObjectsError,
27
- schemaIsEmpty,
28
- selectSchema,
29
- } from './schema-sql.ts';
31
+ CASCADE_SEQUENCES,
32
+ CROSS_SCHEMA_DEPENDENTS,
33
+ IDENTITY_CHANGED,
34
+ NOT_EMPTY,
35
+ buildAtomicDropSql,
36
+ dependentsCount,
37
+ sqlStateOf,
38
+ } from './schema-drop-sql.ts';
39
+ import { PostgresSchemaCascadeSequencesRefused, sequencesCount } from './schema-sequence-error.ts';
40
+ import { isDependentObjectsError, selectSchema } from './schema-sql.ts';
41
+
42
+ type DropError =
43
+ | PostgresSchemaDropNotEmptyError
44
+ | PostgresSchemaCascadeCrossSchemaRefused
45
+ | PostgresSchemaCascadeSequencesRefused
46
+ | PostgresSchemaDeleteForeignRefused
47
+ | SqlError;
30
48
 
31
49
  /**
32
- * The guarded `DROP SCHEMA`, against any {@link PgExecutor}: refuses a non-empty schema
33
- * without `cascade`, and classifies the server's own `2BP01` refusal as the same typed tag —
34
- * for an object kind the emptiness check's four catalogs do not cover. Callers have already
35
- * proven the connected database (`assertDatabase`) and, on the delete path, the schema's
36
- * ownership.
50
+ * The atomic guarded `DROP SCHEMA` for a row the caller has already read, against any
51
+ * {@link PgExecutor}. The server's refusals come back as typed tags: `HF001` (the row changed
52
+ * between read and drop) re-reads and refuses as a foreign schema; `HF002` and the server's own
53
+ * `2BP01` (an object kind the emptiness check's four catalogs miss) are the not-empty tag;
54
+ * `HF003` is the cross-schema dependents refusal, with the count only; `HF004` refuses a cascade
55
+ * over a schema holding a sequence.
37
56
  */
38
- const dropGuarded = (
57
+ const dropAtomic = (
39
58
  pg: PgExecutor,
40
59
  props: PostgresSchemaProps,
41
- ): Effect.Effect<void, PostgresSchemaDropNotEmptyError | SqlError> =>
42
- Effect.gen(function* () {
43
- if (props.cascade !== true) {
44
- const empty = yield* schemaIsEmpty(pg, props.name);
45
- if (!empty) {
46
- return yield* Effect.fail(new PostgresSchemaDropNotEmptyError({ schema: props.name }));
47
- }
60
+ live: PostgresSchemaAttributes,
61
+ ): Effect.Effect<void, DropError> => {
62
+ const cascade = props.cascade === true;
63
+ const classify = (error: SqlError): Effect.Effect<void, DropError> => {
64
+ const state = sqlStateOf(error);
65
+ if (state === NOT_EMPTY || (!cascade && isDependentObjectsError(error))) {
66
+ return Effect.fail(new PostgresSchemaDropNotEmptyError({ schema: props.name }));
48
67
  }
49
- // The server's own `2BP01` refusal (an object kind the four catalogs miss) is classified
50
- // into the same typed tag the emptiness check raises — one fail branch, classified before
51
- // the fail, exactly as `database-sql.ts` classifies the `42P04` race.
52
- const dropRefusal = (error: SqlError): PostgresSchemaDropNotEmptyError | SqlError =>
53
- props.cascade !== true && isDependentObjectsError(error)
54
- ? new PostgresSchemaDropNotEmptyError({ schema: props.name })
55
- : error;
56
- yield* pg.unsafe(buildDropSchemaSql(props.name, props.cascade === true)).pipe(
57
- Effect.asVoid,
58
- Effect.catchTag('SqlError', (error) => Effect.fail(dropRefusal(error))),
59
- );
60
- });
68
+ if (state === CROSS_SCHEMA_DEPENDENTS) {
69
+ return Effect.fail(
70
+ new PostgresSchemaCascadeCrossSchemaRefused({
71
+ schema: props.name,
72
+ dependents: dependentsCount(error),
73
+ }),
74
+ );
75
+ }
76
+ if (state === CASCADE_SEQUENCES) {
77
+ return Effect.fail(
78
+ new PostgresSchemaCascadeSequencesRefused({
79
+ schema: props.name,
80
+ sequences: sequencesCount(error),
81
+ }),
82
+ );
83
+ }
84
+ if (state === IDENTITY_CHANGED) {
85
+ return Effect.flatMap(selectSchema(pg, props.name), (now) =>
86
+ now === undefined
87
+ ? Effect.void
88
+ : Effect.fail(
89
+ new PostgresSchemaDeleteForeignRefused({
90
+ schema: props.name,
91
+ database: props.database,
92
+ liveOid: now.oid,
93
+ liveOwner: now.owner,
94
+ lastOid: live.oid,
95
+ lastOwner: live.owner,
96
+ }),
97
+ ),
98
+ );
99
+ }
100
+ return Effect.fail(error);
101
+ };
102
+ return pg
103
+ .unsafe(buildAtomicDropSql({ name: props.name, oid: live.oid, owner: live.owner, cascade }))
104
+ .pipe(Effect.asVoid, Effect.catchTag('SqlError', classify));
105
+ };
61
106
 
62
- /** Prove the connected database, then the guarded drop — the drop core on its own, for the
63
- * tests that drive it directly. */
107
+ /** Prove the connected database, then the guarded drop of whatever row is live now — the drop
108
+ * core on its own, for the tests that drive it directly. No persisted proof is consulted: the
109
+ * row read here is the row the atomic drop re-verifies. */
64
110
  export const dropWithClient = (
65
111
  pg: PgExecutor,
66
112
  props: PostgresSchemaProps,
67
- ): Effect.Effect<void, PostgresSchemaWrongDatabase | PostgresSchemaDropNotEmptyError | SqlError> =>
113
+ ): Effect.Effect<void, PostgresSchemaWrongDatabase | DropError> =>
68
114
  Effect.gen(function* () {
69
115
  yield* assertDatabase(props, pg);
70
- yield* dropGuarded(pg, props);
116
+ const live = yield* selectSchema(pg, props.name);
117
+ if (live === undefined) return;
118
+ yield* dropAtomic(pg, props, live);
71
119
  });
72
120
 
73
121
  /**
74
122
  * The core of `delete`: re-read first (see the file header), refuse a schema the persisted
75
- * proof cannot vouch for, then the guarded drop. `output` is the persisted state the engine
76
- * hands over — `undefined` is no proof and refuses too.
123
+ * proof cannot vouch for, then the atomic guarded drop. `output` is the persisted state the
124
+ * engine hands over — `undefined` is no proof and refuses too.
77
125
  */
78
126
  export const deleteWithClient = (
79
127
  pg: PgExecutor,
80
128
  props: PostgresSchemaProps,
81
129
  output: PostgresSchemaAttributes | undefined,
82
- ): Effect.Effect<
83
- void,
84
- | PostgresSchemaWrongDatabase
85
- | PostgresSchemaDeleteForeignRefused
86
- | PostgresSchemaDropNotEmptyError
87
- | SqlError
88
- > =>
130
+ ): Effect.Effect<void, PostgresSchemaWrongDatabase | DropError> =>
89
131
  Effect.gen(function* () {
90
132
  yield* assertDatabase(props, pg);
91
133
  const live = yield* selectSchema(pg, props.name);
92
134
  if (live === undefined) return;
93
135
  const refusal = deleteForeignRefusal(props, live, output);
94
136
  if (refusal !== undefined) return yield* Effect.fail(refusal);
95
- yield* dropGuarded(pg, props);
137
+ yield* dropAtomic(pg, props, live);
96
138
  });
@@ -17,6 +17,7 @@ import { classifyInstalled } from './installed-classifier.ts';
17
17
  import { postgresRunnerConnection } from './connection.ts';
18
18
  import { type PsqlRunner, makePsqlExecutor } from './psql-executor.ts';
19
19
  import { isDependentObjectsError } from './schema-sql.ts';
20
+ import { stripPin } from './search-path.ts';
20
21
  import { PostgresSchemaDropNotEmptyError } from './schema-errors.ts';
21
22
  import { dropWithClient, postgresSchemaHandlers } from './schema.ts';
22
23
  import type { PostgresSchemaAttributes, PostgresSchemaProps } from './schema-attrs.ts';
@@ -25,16 +26,16 @@ const baseProps: PostgresSchemaProps = { name: 'ledger', database: 'postgres', o
25
26
 
26
27
  const socket2bp01 = (): Promise<SqlError> => classifyInstalled('2BP01');
27
28
 
28
- /** Emptiness check says empty; the following DROP is the server's real `2BP01`. */
29
+ /** The live row is ours; the atomic `DO` drop is the server's real `2BP01`. */
29
30
  const emptyThenRefuse = (drop: Effect.Effect<ReadonlyArray<object>, SqlError>): PgExecutor => ({
30
31
  unsafe: <A extends object>(text: string) => {
31
- if (text.startsWith('SELECT current_database()')) {
32
- return Effect.succeed([{ database: 'postgres' }] as unknown as ReadonlyArray<A>);
32
+ if (text.startsWith('DO ')) return drop as Effect.Effect<ReadonlyArray<A>, SqlError>;
33
+ if (text.includes('FROM pg_catalog.pg_namespace')) {
34
+ return Effect.succeed([
35
+ { name: 'ledger', oid: 1, owner: 'tim', comment: null, database: 'postgres' },
36
+ ] as unknown as ReadonlyArray<A>);
33
37
  }
34
- if (text.includes('AS empty')) {
35
- return Effect.succeed([{ empty: true }] as unknown as ReadonlyArray<A>);
36
- }
37
- return drop as Effect.Effect<ReadonlyArray<A>, SqlError>;
38
+ return Effect.succeed([{ database: 'postgres' }] as unknown as ReadonlyArray<A>);
38
39
  },
39
40
  transaction: () => Effect.void,
40
41
  });
@@ -54,12 +55,18 @@ describe('2BP01 classification', () => {
54
55
  });
55
56
 
56
57
  test('the runner transport classifies a verbose 2BP01 line as the typed not-empty tag', async () => {
57
- const run: PsqlRunner = ({ stdin }) => {
58
- if (stdin.includes('current_database()')) {
59
- return Promise.resolve({ code: 0, stdout: '[{"database":"postgres"}]', stderr: '' });
58
+ const run: PsqlRunner = ({ stdin: raw }) => {
59
+ const stdin = stripPin(raw);
60
+ if (!stdin.startsWith('DO ') && stdin.includes('FROM pg_catalog.pg_namespace')) {
61
+ return Promise.resolve({
62
+ code: 0,
63
+ stdout:
64
+ '[{"name":"ledger","oid":"1","owner":"tim","comment":null,"database":"postgres"}]',
65
+ stderr: '',
66
+ });
60
67
  }
61
- if (stdin.includes('AS empty')) {
62
- return Promise.resolve({ code: 0, stdout: '[{"empty":true}]', stderr: '' });
68
+ if (!stdin.startsWith('DO ') && stdin.includes('current_database()')) {
69
+ return Promise.resolve({ code: 0, stdout: '[{"database":"postgres"}]', stderr: '' });
63
70
  }
64
71
  return Promise.resolve({
65
72
  code: 3,
@@ -92,18 +99,23 @@ describe('2BP01 classification', () => {
92
99
  // Order is load-bearing (schema-handlers.test.ts#route): the probe must answer PRESENT so
93
100
  // the absent-database short-circuit does not swallow the test, and the ownership re-read
94
101
  // must answer the row the persisted output vouches for, so the delete reaches the DROP.
95
- const run: PsqlRunner = ({ stdin }) => {
96
- if (stdin.includes('AS empty')) {
97
- return Promise.resolve({ code: 0, stdout: '[{"empty":true}]', stderr: '' });
102
+ const run: PsqlRunner = ({ stdin: raw }) => {
103
+ const stdin = stripPin(raw);
104
+ if (stdin.startsWith('DO ')) {
105
+ return Promise.resolve({
106
+ code: 3,
107
+ stdout: '',
108
+ stderr: 'ERROR: 2BP01: dependent objects still exist\n',
109
+ });
98
110
  }
99
- if (stdin.includes('FROM pg_namespace')) {
111
+ if (stdin.includes('FROM pg_catalog.pg_namespace')) {
100
112
  return Promise.resolve({
101
113
  code: 0,
102
114
  stdout: '[{"name":"ledger","oid":1,"owner":"tim"}]',
103
115
  stderr: '',
104
116
  });
105
117
  }
106
- if (stdin.includes('FROM pg_database')) {
118
+ if (stdin.includes('FROM pg_catalog.pg_database')) {
107
119
  return Promise.resolve({ code: 0, stdout: '[{"present":1}]', stderr: '' });
108
120
  }
109
121
  if (stdin.includes('current_database()')) {
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The atomic schema drop against a REAL PostgreSQL. The in-memory fake substitutes a COUNT for the
3
+ * dependency scan and cannot lock, so it cannot see the three defects Codex found in PR 357:
4
+ * an advisory lock that DDL ignores, a closure that missed `pg_collation`, and a TOAST table
5
+ * counted as foreign.
6
+ *
7
+ * ⛔ SKIPPED LOUDLY WITHOUT A SERVER. Set `HF_TEST_POSTGRES=1` and the libpq variables
8
+ * (`PGHOST`, `PGUSER`, `PGDATABASE`, `PGPASSWORD` or a socket); the kit has no container
9
+ * harness of its own (measured 2026-10-06: no podman/initdb use in `packages/alchemy` tests).
10
+ * The connected role needs `CREATE` on the database. Every schema is uniquely named and dropped
11
+ * on every exit path; nothing else is touched.
12
+ */
13
+ import { afterAll, describe, expect, test } from 'bun:test';
14
+ import * as Effect from 'effect/Effect';
15
+ import { type PsqlRunner, makePsqlExecutor } from './psql-executor.ts';
16
+ import { PostgresSchemaCascadeCrossSchemaRefused } from './schema-errors.ts';
17
+ import { PostgresSchemaCascadeSequencesRefused } from './schema-sequence-error.ts';
18
+ import { dropWithClient } from './schema.ts';
19
+
20
+ const enabled = process.env['HF_TEST_POSTGRES'] === '1';
21
+ if (!enabled) {
22
+ console.warn(
23
+ 'SKIPPED schema-drop-real.test.ts: no PostgreSQL. Set HF_TEST_POSTGRES=1 (+ PG* env) to run it.',
24
+ );
25
+ }
26
+ const suite = enabled ? describe : describe.skip;
27
+
28
+ const database = process.env['PGDATABASE'] ?? 'postgres';
29
+ const username = process.env['PGUSER'] ?? 'postgres';
30
+
31
+ const spawnPsql = async (argv: readonly string[], stdin: string) => {
32
+ const proc = Bun.spawn([...argv], {
33
+ stdin: new TextEncoder().encode(stdin),
34
+ stdout: 'pipe',
35
+ stderr: 'pipe',
36
+ });
37
+ const [stdout, stderr, code] = await Promise.all([
38
+ new Response(proc.stdout).text(),
39
+ new Response(proc.stderr).text(),
40
+ proc.exited,
41
+ ]);
42
+ return { code, stdout, stderr };
43
+ };
44
+ const runner: PsqlRunner = ({ argv, stdin }) => spawnPsql(argv, stdin);
45
+ const pg = makePsqlExecutor(runner, { database, username });
46
+ const psql = (sql: string) =>
47
+ spawnPsql(
48
+ ['psql', '-X', '-q', '-A', '-t', '-v', 'ON_ERROR_STOP=1', '-U', username, '-d', database],
49
+ sql,
50
+ );
51
+
52
+ const stamp = `${Date.now().toString(36)}${Math.floor(Math.random() * 1e4)}`;
53
+ const names: string[] = [];
54
+ const fresh = (label: string): string => {
55
+ const name = `hf_${label}_${stamp}`;
56
+ names.push(name);
57
+ return name;
58
+ };
59
+ afterAll(async () => {
60
+ if (!enabled) return;
61
+ for (const name of names) await psql(`DROP SCHEMA IF EXISTS "${name}" CASCADE;`);
62
+ });
63
+
64
+ const present = async (name: string): Promise<boolean> =>
65
+ (await psql(`SELECT count(*) FROM pg_namespace WHERE nspname = '${name}';`)).stdout.trim() ===
66
+ '1';
67
+ const props = (name: string) => ({ name, database, cascade: true });
68
+
69
+ suite('atomic drop on a real PostgreSQL', () => {
70
+ test("an isolated text table cascades: its TOAST relation is the table's", async () => {
71
+ const a = fresh('toast');
72
+ await psql(`CREATE SCHEMA "${a}"; CREATE TABLE "${a}".t (x text);`);
73
+ await Effect.runPromise(dropWithClient(pg, props(a)));
74
+ expect(await present(a)).toBe(false);
75
+ });
76
+
77
+ test("a collation used by another schema's table refuses the cascade", async () => {
78
+ const a = fresh('coll_a');
79
+ const b = fresh('coll_b');
80
+ await psql(
81
+ `CREATE SCHEMA "${a}"; CREATE COLLATION "${a}".c (provider = libc, locale = 'C');
82
+ CREATE SCHEMA "${b}"; CREATE TABLE "${b}".t (x text COLLATE "${a}".c);
83
+ INSERT INTO "${b}".t VALUES ('kept');`,
84
+ );
85
+ const error = await Effect.runPromise(Effect.flip(dropWithClient(pg, props(a))));
86
+ expect(error).toBeInstanceOf(PostgresSchemaCascadeCrossSchemaRefused);
87
+ expect(await present(a)).toBe(true);
88
+ expect((await psql(`SELECT x FROM "${b}".t;`)).stdout.trim()).toBe('kept');
89
+ });
90
+
91
+ test('a replacement attempted while the drop holds its locks waits and is refused', async () => {
92
+ const a = fresh('race');
93
+ await psql(`CREATE SCHEMA "${a}"; CREATE TABLE "${a}".t (x text);`);
94
+ // Holder: an open transaction reading the table keeps our LOCK TABLE waiting AFTER the
95
+ // schema-object lock is taken — a deterministic pause between lock and drop.
96
+ const holder = spawnPsql(
97
+ ['psql', '-X', '-q', '-v', 'ON_ERROR_STOP=1', '-U', username, '-d', database],
98
+ `BEGIN; SELECT 1 FROM "${a}".t; SELECT pg_sleep(4); COMMIT;`,
99
+ );
100
+ await Bun.sleep(700);
101
+ const drop = Effect.runPromise(dropWithClient(pg, props(a)));
102
+ // ⛔ MATCH THE WAIT BY ITS BLOCKER, NOT BY QUERY TEXT: `pg_stat_activity.query` is cut at
103
+ // `track_activity_query_size` (1024 bytes by default) and the `DO` body's `LOCK TABLE`
104
+ // sits past it, so a text match never fired and the rival ran after the drop had finished
105
+ // (measured on PostgreSQL 18, 2026-10-06). A backend blocked by another one is the drop.
106
+ let parked = false;
107
+ for (let i = 0; i < 30 && !parked; i += 1) {
108
+ const waiting = await psql(
109
+ `SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND cardinality(pg_catalog.pg_blocking_pids(pid)) > 0 AND query LIKE 'DO %' AND pid <> pg_backend_pid();`,
110
+ );
111
+ parked = waiting.stdout.trim() !== '0';
112
+ if (!parked) await Bun.sleep(100);
113
+ }
114
+ expect(parked).toBe(true);
115
+ // The drop is now parked holding the namespace lock. A competing DROP + CREATE must not get in.
116
+ const rival = await psql(
117
+ `SET lock_timeout = '800ms'; BEGIN; DROP SCHEMA "${a}" CASCADE; CREATE SCHEMA "${a}"; COMMIT;`,
118
+ );
119
+ expect(rival.code).not.toBe(0);
120
+ expect(rival.stderr).toContain('lock timeout');
121
+ await holder;
122
+ await drop;
123
+ expect(await present(a)).toBe(false);
124
+ });
125
+
126
+ test('a cascade of a schema holding a sequence refuses and loses nothing', async () => {
127
+ const a = fresh('seq_a');
128
+ const b = fresh('seq_b');
129
+ await psql(
130
+ `CREATE SCHEMA "${a}"; CREATE TABLE "${a}".t (id serial, x text);
131
+ INSERT INTO "${a}".t (x) VALUES ('kept');
132
+ CREATE SCHEMA "${b}"; CREATE VIEW "${b}".v AS SELECT last_value FROM "${a}".t_id_seq;`,
133
+ );
134
+ const error = await Effect.runPromise(Effect.flip(dropWithClient(pg, props(a))));
135
+ expect(error).toBeInstanceOf(PostgresSchemaCascadeSequencesRefused);
136
+ expect(error).toMatchObject({ schema: a, sequences: 1 });
137
+ expect(await present(a)).toBe(true);
138
+ expect((await psql(`SELECT x FROM "${a}".t;`)).stdout.trim()).toBe('kept');
139
+ expect((await psql(`SELECT count(*) FROM "${b}".v;`)).stdout.trim()).toBe('1');
140
+ });
141
+
142
+ test('a table added to a foreign extension refuses the cascade (ownership edge leaves)', async () => {
143
+ const a = fresh('ext_a');
144
+ const b = fresh('ext_b');
145
+ await psql(
146
+ `CREATE SCHEMA "${b}"; CREATE EXTENSION hstore SCHEMA "${b}";
147
+ CREATE SCHEMA "${a}"; CREATE TABLE "${a}".t (id int);
148
+ ALTER EXTENSION hstore ADD TABLE "${a}".t;`,
149
+ );
150
+ try {
151
+ const error = await Effect.runPromise(Effect.flip(dropWithClient(pg, props(a))));
152
+ expect(error).toBeInstanceOf(PostgresSchemaCascadeCrossSchemaRefused);
153
+ expect(await present(a)).toBe(true);
154
+ const kept = await psql(`SELECT count(*) FROM pg_extension WHERE extname = 'hstore';`);
155
+ expect(kept.stdout.trim()).toBe('1');
156
+ } finally {
157
+ await psql(`ALTER EXTENSION hstore DROP TABLE "${a}".t; DROP EXTENSION IF EXISTS hstore;`);
158
+ }
159
+ });
160
+
161
+ test('a rename-then-recreate while the drop is parked is refused, the replacement survives', async () => {
162
+ const a = fresh('ren');
163
+ const saved = fresh('ren_saved');
164
+ await psql(`CREATE SCHEMA "${a}"; CREATE TABLE "${a}".t (x text);`);
165
+ const holder = spawnPsql(
166
+ ['psql', '-X', '-q', '-v', 'ON_ERROR_STOP=1', '-U', username, '-d', database],
167
+ `BEGIN; SELECT 1 FROM "${a}".t; SELECT pg_sleep(4); COMMIT;`,
168
+ );
169
+ await Bun.sleep(700);
170
+ const drop = Effect.runPromise(Effect.flip(dropWithClient(pg, props(a))));
171
+ let parked = false;
172
+ for (let i = 0; i < 30 && !parked; i += 1) {
173
+ const waiting = await psql(
174
+ `SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND cardinality(pg_catalog.pg_blocking_pids(pid)) > 0 AND query LIKE 'DO %' AND pid <> pg_backend_pid();`,
175
+ );
176
+ parked = waiting.stdout.trim() !== '0';
177
+ if (!parked) await Bun.sleep(100);
178
+ }
179
+ expect(parked).toBe(true);
180
+ // RENAME takes no namespace object lock, so it commits while the drop waits.
181
+ const swap = await psql(
182
+ `ALTER SCHEMA "${a}" RENAME TO "${saved}"; CREATE SCHEMA "${a}"; CREATE TABLE "${a}".keep (id int);`,
183
+ );
184
+ expect(swap.code).toBe(0);
185
+ await holder;
186
+ await drop;
187
+ expect((await psql(`SELECT count(*) FROM "${a}".keep;`)).stdout.trim()).toBe('0');
188
+ expect(await present(saved)).toBe(true);
189
+ });
190
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The atomic `DROP SCHEMA` `Postgres.Schema` issues: ONE `DO` statement that takes a lock,
3
+ * re-proves the schema inside the same transaction, refuses what must not be dropped, and only
4
+ * then drops.
5
+ *
6
+ * ⛔ PROOF AND DROP ARE ONE STATEMENT, ONE TRANSACTION. Before 2026-10-02 the re-read, the
7
+ * emptiness check and the `DROP` were three autocommit statements — three `psql` processes on
8
+ * the runner transport — so an out-of-band drop-and-recreate between them was dropped by name,
9
+ * `CASCADE` included (Opus read of PR 334). A `DO` block is atomic on both transports and needs
10
+ * no `BEGIN`/`COMMIT` the runner would have to thread through.
11
+ * ⛔ THE LOCK MUST CONFLICT WITH DDL, NOT ONLY WITH OTHER RUNS OF THIS PROVIDER. An advisory lock
12
+ * (`pg_advisory_xact_lock`) is cooperative: an ordinary `DROP SCHEMA` + `CREATE SCHEMA` ignores
13
+ * it, and the by-name `DROP` below would then drop the REPLACEMENT, `CASCADE` included (Codex
14
+ * read of PR 357, 2026-10-06). So, BEFORE the re-verify and held to the end of the transaction:
15
+ * (1) `COMMENT ON SCHEMA <name> IS <its current comment>` — `CommentObject` resolves the schema
16
+ * through `get_object_address(…, ShareUpdateExclusiveLock)`, which locks the namespace OBJECT
17
+ * (`LockDatabaseObject`), and `DROP SCHEMA` takes `AccessExclusiveLock` on that same object
18
+ * (`AcquireDeletionLock`), so a concurrent drop waits for us; (2) on a cascade, `LOCK TABLE …
19
+ * IN ACCESS EXCLUSIVE MODE` on every table-like relation in the schema, which holds off a
20
+ * concurrent creator of a dependent (a view or foreign key reads the table it references).
21
+ * Lock modes: PostgreSQL docs, explicit-locking.html §13.3; `COMMENT`: sql-comment.html.
22
+ * `lock_timeout` turns any wait into `55P03`, an unclassified error: the drop is REFUSED, never
23
+ * forced. A comment-less schema is rewritten as `IS NULL`, which still takes the lock.
24
+ * ⛔ RENAME TAKES NO SUCH LOCK (Codex read of PR 357, 2026-10-06): `ALTER SCHEMA … RENAME` updates
25
+ * `pg_namespace` without locking the object, so a rename-then-`CREATE SCHEMA` of the same name
26
+ * committed while we waited on a table lock would make the by-name `DROP` delete the
27
+ * REPLACEMENT. The last statement before the `DROP` re-reads the name of the verified OID and
28
+ * refuses `HF001` if it moved; only the statements between that read and the `DROP` remain.
29
+ * ⛔ SEQUENCES REFUSE THE CASCADE (`HF004`, KNOWN LIMIT; `schema-sequence-error.ts`). `LOCK TABLE`
30
+ * cannot lock a sequence, so an uncommitted `CREATE VIEW b.v AS SELECT last_value FROM a.s` is
31
+ * invisible to the closure scan, and PostgreSQL's deletion (locks first, THEN scans dependents)
32
+ * would cascade into `b.v` once it commits. A schema with serial or identity columns therefore
33
+ * cannot be cascade-dropped here: drop its contents first. The check runs before any table lock.
34
+ * ⚠️ NOT COVERED: a type,
35
+ * function or collation created in ANOTHER schema to use ours has no relation to wait on. The
36
+ * advisory lock stays: it serialises runs of this provider, which is cheap and correct.
37
+ * ★ THE VALUES ARE LITERALS INSIDE THE BODY, AND THE BODY IS ONE `E'…'` LITERAL. A `DO` body
38
+ * takes no bind parameters, and a dollar-quote tag could be forged by a schema NAME containing
39
+ * it; `quoteStringLiteral` doubles backslashes and quotes, so nesting it twice is exact.
40
+ * The `DROP` text inside is `buildDropSchemaSql` run through `EXECUTE`, so the quoting rule is
41
+ * the same single one.
42
+ * ★ REFUSALS ARE CUSTOM SQLSTATEs, read from `reason.cause.code` exactly like `2BP01`
43
+ * (`schema-sql.ts#isDependentObjectsError`): `HF001` identity changed, `HF002` not empty,
44
+ * `HF003` dependents in another schema, `HF004` a cascade over a schema holding a sequence. Class `HF` is not `42`, so both transports wrap it as
45
+ * `UnknownError` with the raw code. The `HF003` message carries a COUNT only: another owner's
46
+ * object names must not reach this stack's logs.
47
+ */
48
+ import type { SqlError } from 'effect/unstable/sql/SqlError';
49
+ import { quoteStringLiteral } from './database-sql.ts';
50
+ import { OUTSIDE_DEPENDENTS_SQL } from './schema-closure-sql.ts';
51
+ import { buildDropSchemaSql, emptyPredicate } from './schema-sql.ts';
52
+
53
+ export const IDENTITY_CHANGED = 'HF001';
54
+ export const NOT_EMPTY = 'HF002';
55
+ export const CROSS_SCHEMA_DEPENDENTS = 'HF003';
56
+ export const CASCADE_SEQUENCES = 'HF004';
57
+
58
+ /** How long any lock in the block may wait before the drop is refused (`55P03`, fails closed). */
59
+ const LOCK_TIMEOUT = '10s';
60
+
61
+ export interface AtomicDrop {
62
+ readonly name: string;
63
+ readonly oid: number;
64
+ readonly owner: string;
65
+ readonly cascade: boolean;
66
+ }
67
+
68
+ /** The whole atomic drop: lock → re-verify → refuse or drop. Pure text, no client. */
69
+ export const buildAtomicDropSql = (drop: AtomicDrop): string => {
70
+ const body = `DECLARE
71
+ v_name text := ${quoteStringLiteral(drop.name)};
72
+ v_oid oid := ${String(Math.trunc(drop.oid))};
73
+ v_owner text := ${quoteStringLiteral(drop.owner)};
74
+ v_cascade boolean := ${drop.cascade ? 'true' : 'false'};
75
+ v_ns oid;
76
+ v_count bigint;
77
+ v_comment text;
78
+ v_rel text;
79
+ BEGIN
80
+ SELECT n.oid INTO v_ns FROM pg_catalog.pg_namespace n WHERE n.nspname = v_name;
81
+ IF v_ns IS NULL THEN RETURN; END IF;
82
+ PERFORM pg_catalog.set_config('lock_timeout', '${LOCK_TIMEOUT}', true);
83
+ PERFORM pg_catalog.pg_advisory_xact_lock(v_ns::pg_catalog.int8);
84
+ v_comment := pg_catalog.obj_description(v_ns, 'pg_namespace');
85
+ EXECUTE pg_catalog.format('COMMENT ON SCHEMA %I IS %L', v_name, v_comment);
86
+ SELECT n.oid INTO v_ns FROM pg_catalog.pg_namespace n
87
+ WHERE n.nspname = v_name AND n.oid = v_oid
88
+ AND pg_catalog.pg_get_userbyid(n.nspowner) = v_owner;
89
+ IF v_ns IS NULL THEN
90
+ RAISE EXCEPTION 'schema identity changed' USING ERRCODE = '${IDENTITY_CHANGED}';
91
+ END IF;
92
+ IF NOT v_cascade THEN
93
+ IF NOT (${emptyPredicate('v_ns')}) THEN
94
+ RAISE EXCEPTION 'schema not empty' USING ERRCODE = '${NOT_EMPTY}';
95
+ END IF;
96
+ ELSE
97
+ SELECT count(*) INTO v_count FROM pg_catalog.pg_class c
98
+ WHERE c.relnamespace = v_ns AND c.relkind = 'S';
99
+ IF v_count > 0 THEN
100
+ RAISE EXCEPTION 'cascade sequences: %', v_count USING ERRCODE = '${CASCADE_SEQUENCES}';
101
+ END IF;
102
+ FOR v_rel IN SELECT c.oid::pg_catalog.regclass::pg_catalog.text FROM pg_catalog.pg_class c
103
+ WHERE c.relnamespace = v_ns AND c.relkind IN ('r', 'p', 'v', 'm', 'f') ORDER BY c.oid LOOP
104
+ EXECUTE pg_catalog.format('LOCK TABLE %s IN ACCESS EXCLUSIVE MODE', v_rel);
105
+ END LOOP;
106
+ ${OUTSIDE_DEPENDENTS_SQL};
107
+ IF v_count > 0 THEN
108
+ RAISE EXCEPTION 'cross-schema dependents: %', v_count USING ERRCODE = '${CROSS_SCHEMA_DEPENDENTS}';
109
+ END IF;
110
+ END IF;
111
+ PERFORM 1 FROM pg_catalog.pg_namespace n WHERE n.oid = v_oid AND n.nspname = v_name;
112
+ IF NOT FOUND THEN
113
+ RAISE EXCEPTION 'schema identity changed' USING ERRCODE = '${IDENTITY_CHANGED}';
114
+ END IF;
115
+ EXECUTE ${quoteStringLiteral(buildDropSchemaSql(drop.name, drop.cascade))};
116
+ END`;
117
+ return `DO ${quoteStringLiteral(body)}`;
118
+ };
119
+
120
+ /** The server's SQLSTATE on a failed statement, whichever transport raised it. */
121
+ export const sqlStateOf = (error: SqlError): string | undefined => {
122
+ const cause = error.reason.cause;
123
+ const code =
124
+ typeof cause === 'object' && cause !== null ? (cause as { code?: unknown }).code : '';
125
+ return typeof code === 'string' ? code : undefined;
126
+ };
127
+
128
+ /** The dependents COUNT out of an `HF003` error. An unreadable message still refuses: it
129
+ * answers 1, never 0 — the refusal is the fail-closed answer. */
130
+ export const dependentsCount = (error: SqlError): number => {
131
+ const cause = error.reason.cause;
132
+ const message = typeof cause === 'object' && cause !== null ? (cause as Error).message : '';
133
+ const parsed = /dependents:\s*(\d+)/.exec(message ?? '')?.[1];
134
+ return Math.max(1, Number(parsed ?? 1));
135
+ };