@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.
- package/dist/postgres/connection.d.ts.map +1 -1
- package/dist/postgres/fake-schema-drop.d.ts +13 -0
- package/dist/postgres/fake-schema-drop.d.ts.map +1 -0
- package/dist/postgres/fake-schema-sql.d.ts +5 -0
- package/dist/postgres/fake-schema-sql.d.ts.map +1 -1
- package/dist/postgres/fake-sql.d.ts +4 -0
- package/dist/postgres/fake-sql.d.ts.map +1 -1
- package/dist/postgres/index.d.ts +2 -1
- package/dist/postgres/index.d.ts.map +1 -1
- package/dist/postgres/index.js +461 -278
- package/dist/postgres/index.js.map +19 -15
- package/dist/postgres/psql-executor.d.ts.map +1 -1
- package/dist/postgres/schema-closure-sql.d.ts +45 -0
- package/dist/postgres/schema-closure-sql.d.ts.map +1 -0
- package/dist/postgres/schema-delete.d.ts +17 -8
- package/dist/postgres/schema-delete.d.ts.map +1 -1
- package/dist/postgres/schema-drop-sql.d.ts +66 -0
- package/dist/postgres/schema-drop-sql.d.ts.map +1 -0
- package/dist/postgres/schema-errors.d.ts +19 -1
- package/dist/postgres/schema-errors.d.ts.map +1 -1
- package/dist/postgres/schema-sequence-error.d.ts +29 -0
- package/dist/postgres/schema-sequence-error.d.ts.map +1 -0
- package/dist/postgres/schema-sql.d.ts +16 -0
- package/dist/postgres/schema-sql.d.ts.map +1 -1
- package/dist/postgres/schema-test-kit.d.ts +3 -1
- package/dist/postgres/schema-test-kit.d.ts.map +1 -1
- package/dist/postgres/search-path.d.ts +38 -0
- package/dist/postgres/search-path.d.ts.map +1 -0
- package/docs/postgres-schema.md +22 -1
- package/package.json +1 -1
- package/src/postgres/connection.ts +10 -14
- package/src/postgres/database-bigint-serialization.test.ts +2 -2
- package/src/postgres/database-sql.ts +6 -6
- package/src/postgres/fake-grants-read.ts +3 -3
- package/src/postgres/fake-role-sql.ts +2 -2
- package/src/postgres/fake-schema-drop.ts +75 -0
- package/src/postgres/fake-schema-sql.ts +10 -1
- package/src/postgres/fake-sql.ts +10 -3
- package/src/postgres/grants-database.test.ts +5 -5
- package/src/postgres/grants-existence.ts +7 -7
- package/src/postgres/grants-ownership.ts +5 -5
- package/src/postgres/grants-psql-lifecycle.test.ts +7 -3
- package/src/postgres/grants-read-defaults.ts +5 -5
- package/src/postgres/grants-read.ts +16 -16
- package/src/postgres/index.ts +2 -0
- package/src/postgres/psql-executor-escaping.test.ts +3 -1
- package/src/postgres/psql-executor.test.ts +11 -5
- package/src/postgres/psql-executor.ts +4 -1
- package/src/postgres/role-adopt.test.ts +3 -1
- package/src/postgres/role-membership-options.test.ts +3 -1
- package/src/postgres/role-membership-repair.test.ts +3 -3
- package/src/postgres/role-membership-sql.ts +4 -4
- package/src/postgres/role-password-transports.test.ts +4 -2
- package/src/postgres/role-sql.ts +1 -1
- package/src/postgres/schema-closure-sql.ts +108 -0
- package/src/postgres/schema-delete-proofs.test.ts +7 -7
- package/src/postgres/schema-delete.ts +88 -46
- package/src/postgres/schema-drop-2bp01.test.ts +29 -17
- package/src/postgres/schema-drop-real.test.ts +190 -0
- package/src/postgres/schema-drop-sql.ts +135 -0
- package/src/postgres/schema-drop.test.ts +241 -0
- package/src/postgres/schema-errors.ts +26 -0
- package/src/postgres/schema-handlers.test.ts +21 -14
- package/src/postgres/schema-lifecycle.test.ts +6 -60
- package/src/postgres/schema-sequence-error.ts +38 -0
- package/src/postgres/schema-socket.test.ts +20 -6
- package/src/postgres/schema-sql.ts +16 -11
- package/src/postgres/schema-takeover.test.ts +7 -4
- package/src/postgres/schema-test-kit.ts +18 -4
- package/src/postgres/search-path.test.ts +100 -0
- 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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
*
|
|
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
|
-
|
|
25
|
+
PostgresSchemaCascadeCrossSchemaRefused,
|
|
26
|
+
PostgresSchemaDeleteForeignRefused,
|
|
21
27
|
PostgresSchemaDropNotEmptyError,
|
|
22
28
|
type PostgresSchemaWrongDatabase,
|
|
23
29
|
} from './schema-errors.ts';
|
|
24
30
|
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
57
|
+
const dropAtomic = (
|
|
39
58
|
pg: PgExecutor,
|
|
40
59
|
props: PostgresSchemaProps,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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 |
|
|
113
|
+
): Effect.Effect<void, PostgresSchemaWrongDatabase | DropError> =>
|
|
68
114
|
Effect.gen(function* () {
|
|
69
115
|
yield* assertDatabase(props, pg);
|
|
70
|
-
yield*
|
|
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
|
|
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*
|
|
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
|
-
/**
|
|
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('
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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.
|
|
62
|
-
return Promise.resolve({ code: 0, stdout: '[{"
|
|
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
|
-
|
|
97
|
-
|
|
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
|
+
};
|