@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
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Postgres.Schema`'s atomic drop (`schema-drop-sql.ts`) against `fake-sql.ts`: one `DO`
|
|
3
|
+
* statement that re-verifies identity under a lock, refuses a non-empty schema or cross-schema
|
|
4
|
+
* dependents, and only then drops. The Opus read of PR 334 (2026-10-02) found the old
|
|
5
|
+
* three-autocommit drop racy, and `cascade: true` reaching other stacks' objects.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import { SqlError, UnknownError } from 'effect/unstable/sql/SqlError';
|
|
10
|
+
import type { PgExecutor } from './database-sql.ts';
|
|
11
|
+
import { makeFakeSql } from './fake-sql.ts';
|
|
12
|
+
import type { PostgresSchemaAttributes, PostgresSchemaProps } from './schema-attrs.ts';
|
|
13
|
+
import { buildAtomicDropSql } from './schema-drop-sql.ts';
|
|
14
|
+
import {
|
|
15
|
+
PostgresSchemaCascadeCrossSchemaRefused,
|
|
16
|
+
PostgresSchemaDeleteForeignRefused,
|
|
17
|
+
PostgresSchemaDropNotEmptyError,
|
|
18
|
+
} from './schema-errors.ts';
|
|
19
|
+
import { PostgresSchemaCascadeSequencesRefused } from './schema-sequence-error.ts';
|
|
20
|
+
import { deleteWithClient, dropWithClient } from './schema.ts';
|
|
21
|
+
|
|
22
|
+
const run = <A, E>(eff: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(eff);
|
|
23
|
+
const fails = <A, E>(eff: Effect.Effect<A, E>): Promise<E> => Effect.runPromise(Effect.flip(eff));
|
|
24
|
+
|
|
25
|
+
const baseProps: PostgresSchemaProps = { name: 'ledger', database: 'postgres', owner: 'tim' };
|
|
26
|
+
const liveRow = (over: Partial<PostgresSchemaAttributes> = {}): PostgresSchemaAttributes => ({
|
|
27
|
+
name: 'ledger',
|
|
28
|
+
database: 'postgres',
|
|
29
|
+
oid: 30000,
|
|
30
|
+
owner: 'tim',
|
|
31
|
+
comment: null,
|
|
32
|
+
...over,
|
|
33
|
+
});
|
|
34
|
+
const drops = (fake: ReturnType<typeof makeFakeSql>) =>
|
|
35
|
+
fake.statements.filter((s) => s.text.startsWith('DO '));
|
|
36
|
+
|
|
37
|
+
describe('drop: the proof and the DROP are one statement', () => {
|
|
38
|
+
test('an empty schema is dropped by ONE DO carrying the proven oid and owner', async () => {
|
|
39
|
+
const fake = makeFakeSql({ schemas: [liveRow()] });
|
|
40
|
+
await run(dropWithClient(fake, baseProps));
|
|
41
|
+
expect(drops(fake).map((s) => s.text)).toEqual([
|
|
42
|
+
buildAtomicDropSql({ name: 'ledger', oid: 30000, owner: 'tim', cascade: false }),
|
|
43
|
+
]);
|
|
44
|
+
// No bare DROP and no separate emptiness probe reached the server.
|
|
45
|
+
expect(fake.statements.some((s) => s.text.startsWith('DROP SCHEMA'))).toBe(false);
|
|
46
|
+
expect(fake.statements.some((s) => s.text.includes('AS empty'))).toBe(false);
|
|
47
|
+
expect(fake.schemas.get('ledger')).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('the block locks BEFORE it re-verifies, re-verifies oid + owner, and drops last', () => {
|
|
51
|
+
const sql = buildAtomicDropSql({ name: 'ledger', oid: 30000, owner: 'tim', cascade: false });
|
|
52
|
+
const at = (needle: string): number => sql.indexOf(needle);
|
|
53
|
+
expect(at('pg_advisory_xact_lock')).toBeGreaterThan(-1);
|
|
54
|
+
expect(at('pg_advisory_xact_lock')).toBeLessThan(at('n.oid = v_oid'));
|
|
55
|
+
expect(at('pg_get_userbyid(n.nspowner) = v_owner')).toBeGreaterThan(-1);
|
|
56
|
+
// The DDL-conflicting lock (a no-op COMMENT on the schema object) precedes the re-verify too.
|
|
57
|
+
expect(at('COMMENT ON SCHEMA %I')).toBeGreaterThan(-1);
|
|
58
|
+
expect(at('COMMENT ON SCHEMA %I')).toBeLessThan(at('n.oid = v_oid'));
|
|
59
|
+
expect(at("''lock_timeout''")).toBeLessThan(at('pg_advisory_xact_lock'));
|
|
60
|
+
expect(at('n.oid = v_oid')).toBeLessThan(sql.lastIndexOf('EXECUTE'));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a schema replaced between the read and the drop is refused, nothing dropped (HF001)', async () => {
|
|
64
|
+
const fake = makeFakeSql({ schemas: [liveRow()] });
|
|
65
|
+
// The read sees oid 30000; before the DO runs another role drops and recreates the name.
|
|
66
|
+
const racing: PgExecutor = {
|
|
67
|
+
...fake,
|
|
68
|
+
unsafe: (text, params) => {
|
|
69
|
+
if (text.startsWith('DO ')) {
|
|
70
|
+
fake.schemas.set('ledger', liveRow({ oid: 30001, owner: 'intruder' }));
|
|
71
|
+
}
|
|
72
|
+
return fake.unsafe(text, params);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
const error = await fails(deleteWithClient(racing, baseProps, liveRow()));
|
|
76
|
+
expect(error).toBeInstanceOf(PostgresSchemaDeleteForeignRefused);
|
|
77
|
+
expect(error).toMatchObject({ liveOid: 30001, liveOwner: 'intruder', lastOid: 30000 });
|
|
78
|
+
expect(fake.schemas.get('ledger')).toMatchObject({ oid: 30001 });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a schema that vanished between the read and the drop is idempotent success', async () => {
|
|
82
|
+
const fake = makeFakeSql({ schemas: [liveRow()] });
|
|
83
|
+
const racing: PgExecutor = {
|
|
84
|
+
...fake,
|
|
85
|
+
unsafe: (text, params) => {
|
|
86
|
+
if (text.startsWith('DO ')) fake.schemas.delete('ledger');
|
|
87
|
+
return fake.unsafe(text, params);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
await run(dropWithClient(racing, baseProps));
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('drop: emptiness', () => {
|
|
95
|
+
test('refuses a non-empty schema with cascade false (HF002 → typed tag)', async () => {
|
|
96
|
+
const fake = makeFakeSql({ schemas: [liveRow()], schemasWithRelations: ['ledger'] });
|
|
97
|
+
expect(await fails(dropWithClient(fake, baseProps))).toBeInstanceOf(
|
|
98
|
+
PostgresSchemaDropNotEmptyError,
|
|
99
|
+
);
|
|
100
|
+
expect(fake.schemas.get('ledger')).not.toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('drops a non-empty schema with cascade true when nothing outside depends on it', async () => {
|
|
104
|
+
const fake = makeFakeSql({ schemas: [liveRow()], schemasWithRelations: ['ledger'] });
|
|
105
|
+
await run(dropWithClient(fake, { ...baseProps, cascade: true }));
|
|
106
|
+
expect(fake.schemas.get('ledger')).toBeUndefined();
|
|
107
|
+
expect(drops(fake)[0]?.text).toContain(`'DROP SCHEMA IF EXISTS "ledger" CASCADE'`);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("the server's own 2BP01 (an object kind the four catalogs miss) is the same typed tag", async () => {
|
|
111
|
+
const refusing: PgExecutor = {
|
|
112
|
+
unsafe: <A extends object>(text: string) =>
|
|
113
|
+
text.startsWith('SELECT current_database()')
|
|
114
|
+
? Effect.succeed([{ database: 'postgres' }] as unknown as ReadonlyArray<A>)
|
|
115
|
+
: !text.startsWith('DO ') && text.includes('FROM pg_catalog.pg_namespace')
|
|
116
|
+
? Effect.succeed([liveRow()] as unknown as ReadonlyArray<A>)
|
|
117
|
+
: Effect.fail(
|
|
118
|
+
new SqlError({
|
|
119
|
+
reason: new UnknownError({
|
|
120
|
+
cause: Object.assign(new Error('ERROR: 2BP01: dependent objects'), {
|
|
121
|
+
code: '2BP01',
|
|
122
|
+
}),
|
|
123
|
+
message: 'dependent_objects_still_exist',
|
|
124
|
+
operation: 'DO',
|
|
125
|
+
}),
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
transaction: () => Effect.void,
|
|
129
|
+
};
|
|
130
|
+
expect(await fails(dropWithClient(refusing, baseProps))).toBeInstanceOf(
|
|
131
|
+
PostgresSchemaDropNotEmptyError,
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('drop: cascade never reaches another schema', () => {
|
|
137
|
+
test('refuses cascade when objects in other schemas depend on this one — count only (HF003)', async () => {
|
|
138
|
+
const fake = makeFakeSql({
|
|
139
|
+
schemas: [liveRow()],
|
|
140
|
+
schemasWithRelations: ['ledger'],
|
|
141
|
+
dependentsOutside: { ledger: 3 },
|
|
142
|
+
});
|
|
143
|
+
const error = await fails(dropWithClient(fake, { ...baseProps, cascade: true }));
|
|
144
|
+
expect(error).toBeInstanceOf(PostgresSchemaCascadeCrossSchemaRefused);
|
|
145
|
+
expect(error).toMatchObject({ schema: 'ledger', dependents: 3 });
|
|
146
|
+
// The message names a count and this schema — never another owner's object.
|
|
147
|
+
expect((error as Error).message).toContain('3 dependent object(s)');
|
|
148
|
+
expect(fake.schemas.get('ledger')).not.toBeUndefined();
|
|
149
|
+
expect(fake.relationsIn.has('ledger')).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('refuses cascade over a schema holding a sequence — count only (HF004), nothing dropped', async () => {
|
|
153
|
+
const fake = makeFakeSql({
|
|
154
|
+
schemas: [liveRow()],
|
|
155
|
+
schemasWithRelations: ['ledger'],
|
|
156
|
+
sequencesIn: { ledger: 2 },
|
|
157
|
+
});
|
|
158
|
+
const error = await fails(dropWithClient(fake, { ...baseProps, cascade: true }));
|
|
159
|
+
expect(error).toBeInstanceOf(PostgresSchemaCascadeSequencesRefused);
|
|
160
|
+
expect(error).toMatchObject({ schema: 'ledger', sequences: 2 });
|
|
161
|
+
expect((error as Error).message).toContain('2 sequence(s)');
|
|
162
|
+
expect(fake.schemas.get('ledger')).not.toBeUndefined();
|
|
163
|
+
expect(fake.relationsIn.has('ledger')).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('the sequence check precedes every table lock in the generated block', () => {
|
|
167
|
+
const sql = buildAtomicDropSql({ name: 'ledger', oid: 1, owner: 'tim', cascade: true });
|
|
168
|
+
expect(sql).toContain("c.relkind = ''S''");
|
|
169
|
+
expect(sql.indexOf("c.relkind = ''S''")).toBeLessThan(sql.indexOf('LOCK TABLE'));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('cascade false never runs the dependents check: the emptiness refusal comes first', async () => {
|
|
173
|
+
const fake = makeFakeSql({
|
|
174
|
+
schemas: [liveRow()],
|
|
175
|
+
schemasWithRelations: ['ledger'],
|
|
176
|
+
dependentsOutside: { ledger: 3 },
|
|
177
|
+
});
|
|
178
|
+
expect(await fails(dropWithClient(fake, baseProps))).toBeInstanceOf(
|
|
179
|
+
PostgresSchemaDropNotEmptyError,
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('the dependents query counts per catalog class and treats an unknown class as foreign', () => {
|
|
184
|
+
const sql = buildAtomicDropSql({ name: 'ledger', oid: 1, owner: 'tim', cascade: true });
|
|
185
|
+
expect(sql).toContain('pg_catalog.pg_depend');
|
|
186
|
+
// A class the CASE does not know yields NULL; IS DISTINCT FROM counts NULL as foreign, so an
|
|
187
|
+
// unclassifiable dependent refuses the cascade instead of slipping through.
|
|
188
|
+
expect(sql).toContain('IS NOT DISTINCT FROM v_ns');
|
|
189
|
+
// The walk follows every deptype except the pin (`p`): n a i P S e x each cascade or attribute.
|
|
190
|
+
expect(sql).toContain("d.deptype <> ''p''");
|
|
191
|
+
for (const catalog of [
|
|
192
|
+
'pg_collation',
|
|
193
|
+
'pg_conversion',
|
|
194
|
+
'pg_opclass',
|
|
195
|
+
'pg_opfamily',
|
|
196
|
+
'pg_ts_config',
|
|
197
|
+
'pg_statistic_ext',
|
|
198
|
+
'pg_extension',
|
|
199
|
+
'pg_constraint',
|
|
200
|
+
'pg_trigger',
|
|
201
|
+
'pg_rewrite',
|
|
202
|
+
'pg_attrdef',
|
|
203
|
+
'pg_policy',
|
|
204
|
+
]) {
|
|
205
|
+
// Inside the body's `E'…'` literal every quote is doubled.
|
|
206
|
+
expect(sql).toContain(`''pg_catalog.${catalog}''::pg_catalog.regclass`);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('drop: the generated text cannot be broken out of by a name', () => {
|
|
212
|
+
test('a schema name holding quotes, backslashes and a dollar-quote tag stays inside its literal', () => {
|
|
213
|
+
const name = `x$$; DROP TABLE t; --'\\"`;
|
|
214
|
+
const sql = buildAtomicDropSql({ name, oid: 1, owner: `o'wn\\er`, cascade: false });
|
|
215
|
+
expect(sql.startsWith("DO E'")).toBe(true);
|
|
216
|
+
expect(sql.endsWith("'")).toBe(true);
|
|
217
|
+
// No unescaped quote before the final one: every `'` inside is doubled by the quoter.
|
|
218
|
+
expect(sql.slice(5, -1).replace(/''/g, '')).not.toContain("'");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('applyAtomicDrop reads such a name and owner back exactly (fake parses its own output)', async () => {
|
|
222
|
+
const name = `x$$; DROP TABLE t; --'\\"`;
|
|
223
|
+
const owner = `o'wn\\er$tag$`;
|
|
224
|
+
const fake = makeFakeSql({ schemas: [liveRow({ name, owner })] });
|
|
225
|
+
await run(dropWithClient(fake, { name, database: 'postgres' }));
|
|
226
|
+
expect(fake.schemas.get(name)).toBeUndefined();
|
|
227
|
+
// A different owner with the same tricky name is a refusal, not a by-name drop.
|
|
228
|
+
const other = makeFakeSql({ schemas: [liveRow({ name, owner: 'someone' })] });
|
|
229
|
+
const racing: PgExecutor = {
|
|
230
|
+
...other,
|
|
231
|
+
unsafe: (text, params) => {
|
|
232
|
+
if (text.startsWith('DO ')) other.schemas.set(name, liveRow({ name, owner }));
|
|
233
|
+
return other.unsafe(text, params);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
await fails(
|
|
237
|
+
deleteWithClient(racing, { name, database: 'postgres' }, liveRow({ name, owner: 'someone' })),
|
|
238
|
+
);
|
|
239
|
+
expect(other.schemas.get(name)).toBeDefined();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Refusals use checked catalog facts or raw SQLSTATE (42P06 / 2BP01), never driver message text.
|
|
5
5
|
*/
|
|
6
6
|
import * as Data from 'effect/Data';
|
|
7
|
+
import type { PostgresSchemaCascadeSequencesRefused } from './schema-sequence-error.ts';
|
|
7
8
|
|
|
8
9
|
/** A declared name would be silently truncated (same as `PostgresDatabaseNameRefused`). */
|
|
9
10
|
export class PostgresSchemaNameRefused extends Data.TaggedError('PostgresSchemaNameRefused')<{
|
|
@@ -94,6 +95,29 @@ export class PostgresSchemaDropNotEmptyError extends Data.TaggedError(
|
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
/**
|
|
99
|
+
* `cascade: true` refused: `DROP SCHEMA … CASCADE` would also drop objects that live in OTHER
|
|
100
|
+
* schemas because they depend on something in this one (a view, a foreign key or a trigger
|
|
101
|
+
* another stack owns). The refusal carries the COUNT only — never an object name, so the error
|
|
102
|
+
* cannot disclose another owner's catalog. Nothing was dropped: move or drop those dependents
|
|
103
|
+
* deliberately, or empty this schema by hand and declare `cascade: false`.
|
|
104
|
+
*/
|
|
105
|
+
export class PostgresSchemaCascadeCrossSchemaRefused extends Data.TaggedError(
|
|
106
|
+
'PostgresSchemaCascadeCrossSchemaRefused',
|
|
107
|
+
)<{
|
|
108
|
+
readonly schema: string;
|
|
109
|
+
/** How many dependent objects live outside this schema (at least one when raised). */
|
|
110
|
+
readonly dependents: number;
|
|
111
|
+
}> {
|
|
112
|
+
override get message(): string {
|
|
113
|
+
return (
|
|
114
|
+
`Postgres.Schema "${this.schema}": cascade: true refused — ${String(this.dependents)} ` +
|
|
115
|
+
'dependent object(s) outside this schema would be dropped with it. No DROP was issued. ' +
|
|
116
|
+
'Remove or move those dependents first.'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
97
121
|
/** Every statement this resource issues must run against the DECLARED database. The family
|
|
98
122
|
* connection points at a maintenance database, so the schema handlers open `props.database`
|
|
99
123
|
* themselves (`withPg`'s database override); this failure means the server answered
|
|
@@ -204,6 +228,8 @@ export type PostgresSchemaError =
|
|
|
204
228
|
| PostgresSchemaDrift
|
|
205
229
|
| PostgresSchemaCreateVanished
|
|
206
230
|
| PostgresSchemaDropNotEmptyError
|
|
231
|
+
| PostgresSchemaCascadeCrossSchemaRefused
|
|
232
|
+
| PostgresSchemaCascadeSequencesRefused
|
|
207
233
|
| PostgresSchemaDeleteForeignRefused
|
|
208
234
|
| PostgresSchemaWrongDatabase
|
|
209
235
|
| PostgresSchemaDatabaseRefused;
|
|
@@ -14,6 +14,7 @@ import type { PostgresSchemaAttributes, PostgresSchemaProps } from './schema-att
|
|
|
14
14
|
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
15
15
|
import { postgresSchemaHandlers } from './schema.ts';
|
|
16
16
|
import { PostgresSchemaDropNotEmptyError, PostgresSchemaWrongDatabase } from './schema-errors.ts';
|
|
17
|
+
import { buildAtomicDropSql } from './schema-drop-sql.ts';
|
|
17
18
|
import { deleteArgs, ok, readArgs, route, router, runnerWith } from './schema-test-kit.ts';
|
|
18
19
|
|
|
19
20
|
const sampleOutput: PostgresSchemaAttributes = {
|
|
@@ -51,10 +52,12 @@ describe('delete handler (runner transport)', () => {
|
|
|
51
52
|
),
|
|
52
53
|
),
|
|
53
54
|
);
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
// The drop is ONE `DO` statement carrying the proven oid + owner (`schema-drop-sql.ts`),
|
|
56
|
+
// not a re-read followed by a separate `DROP`.
|
|
57
|
+
expect(stdins.find((s) => s.startsWith('DO '))).toBe(
|
|
58
|
+
`${buildAtomicDropSql({ name: 'ledger', oid: 1, owner: 'tim', cascade: true })};`,
|
|
56
59
|
);
|
|
57
|
-
// One `psql` argv per STATEMENT: probe, proof, ownership re-read, drop. The probe's argv
|
|
60
|
+
// One `psql` argv per STATEMENT: probe, proof, ownership re-read, atomic drop. The probe's argv
|
|
58
61
|
// targets the family database; the delete's argvs target the declared one — the `-d` value
|
|
59
62
|
// is the argv's last element.
|
|
60
63
|
expect(argvs.length).toBe(4);
|
|
@@ -80,17 +83,19 @@ describe('delete handler (runner transport)', () => {
|
|
|
80
83
|
),
|
|
81
84
|
),
|
|
82
85
|
);
|
|
83
|
-
expect(stdins.find((s) => s.startsWith('
|
|
86
|
+
expect(stdins.find((s) => s.startsWith('DO '))).toBe(
|
|
87
|
+
`${buildAtomicDropSql({ name: 'ledger', oid: 1, owner: 'tim', cascade: false })};`,
|
|
88
|
+
);
|
|
84
89
|
});
|
|
85
90
|
|
|
86
|
-
test('
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
test('a non-empty schema is the typed refusal the atomic drop raises (HF002), over one psql', async () => {
|
|
92
|
+
const answers = router({
|
|
93
|
+
probe: '[{"present":1}]',
|
|
94
|
+
schema: '[{"name":"ledger","oid":1,"owner":"tim"}]',
|
|
95
|
+
proof,
|
|
96
|
+
});
|
|
97
|
+
const { run, stdins } = runnerWith(answers, (stdin) =>
|
|
98
|
+
stdin.startsWith('DO ') ? 'ERROR: HF002: schema not empty' : undefined,
|
|
94
99
|
);
|
|
95
100
|
const error = await Effect.runPromise(
|
|
96
101
|
Effect.flip(
|
|
@@ -104,7 +109,9 @@ describe('delete handler (runner transport)', () => {
|
|
|
104
109
|
),
|
|
105
110
|
);
|
|
106
111
|
expect(error).toBeInstanceOf(PostgresSchemaDropNotEmptyError);
|
|
107
|
-
|
|
112
|
+
// Proof and drop were ONE statement: exactly one `DO`, and no separate emptiness probe.
|
|
113
|
+
expect(stdins.filter((s) => s.startsWith('DO ')).length).toBe(1);
|
|
114
|
+
expect(stdins.some((s) => s.includes('AS empty'))).toBe(false);
|
|
108
115
|
});
|
|
109
116
|
|
|
110
117
|
test('the real handler refuses a wrong connected database before any DROP', async () => {
|
|
@@ -128,7 +135,7 @@ describe('delete handler (runner transport)', () => {
|
|
|
128
135
|
),
|
|
129
136
|
);
|
|
130
137
|
expect(error).toBeInstanceOf(PostgresSchemaWrongDatabase);
|
|
131
|
-
expect(stdins.some((s) => s.startsWith('
|
|
138
|
+
expect(stdins.some((s) => s.startsWith('DO '))).toBe(false);
|
|
132
139
|
});
|
|
133
140
|
|
|
134
141
|
test('defaultRemovalPolicy is retain — declared on the Resource for Postgres.Schema itself', () => {
|
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `reconcile`, `read`, and `drop` for `Postgres.Schema` against `fake-sql.ts`'s recording fake.
|
|
3
3
|
* Tests the greenfield create, comment creation, already-present/drift, owner-missing guard,
|
|
4
|
-
* quoting,
|
|
4
|
+
* quoting, and the `current_database()` proof every write path runs first. The drop rules live
|
|
5
|
+
* in `schema-drop.test.ts`.
|
|
5
6
|
*/
|
|
6
7
|
import { describe, expect, test } from 'bun:test';
|
|
7
8
|
import * as Effect from 'effect/Effect';
|
|
8
|
-
import { SqlError, UnknownError } from 'effect/unstable/sql/SqlError';
|
|
9
9
|
import { makeFakeSql } from './fake-sql.ts';
|
|
10
10
|
import { type PostgresSchemaAttributes, type PostgresSchemaProps } from './schema-attrs.ts';
|
|
11
|
-
import type { PgExecutor } from './database-sql.ts';
|
|
12
11
|
import {
|
|
13
12
|
PostgresSchemaCreateVanished,
|
|
14
13
|
PostgresSchemaDrift,
|
|
15
|
-
PostgresSchemaDropNotEmptyError,
|
|
16
14
|
PostgresSchemaExistsRefused,
|
|
17
15
|
PostgresSchemaOwnerMissing,
|
|
18
16
|
PostgresSchemaWrongDatabase,
|
|
@@ -42,7 +40,9 @@ describe('the current_database() proof', () => {
|
|
|
42
40
|
expect(refusal.declared).toBe('agents');
|
|
43
41
|
expect(refusal.connected).toBe('postgres');
|
|
44
42
|
// The refusal fires before the first pg_namespace read, so nothing but the proof ran.
|
|
45
|
-
expect(fake.statements.some((s) => s.text.includes('FROM pg_namespace'))).toBe(
|
|
43
|
+
expect(fake.statements.some((s) => s.text.includes('FROM pg_catalog.pg_namespace'))).toBe(
|
|
44
|
+
false,
|
|
45
|
+
);
|
|
46
46
|
expect(fake.statements.some((s) => s.text.startsWith('CREATE SCHEMA'))).toBe(false);
|
|
47
47
|
});
|
|
48
48
|
|
|
@@ -50,7 +50,7 @@ describe('the current_database() proof', () => {
|
|
|
50
50
|
const fake = makeFakeSql({ schemas: [liveRow()] });
|
|
51
51
|
const error = await fails(dropWithClient(fake, { ...baseProps, database: 'agents' }));
|
|
52
52
|
expect(error).toBeInstanceOf(PostgresSchemaWrongDatabase);
|
|
53
|
-
expect(fake.statements.some((s) => s.text.startsWith('
|
|
53
|
+
expect(fake.statements.some((s) => s.text.startsWith('DO '))).toBe(false);
|
|
54
54
|
expect(fake.schemas.get('ledger')).not.toBeUndefined();
|
|
55
55
|
});
|
|
56
56
|
|
|
@@ -190,57 +190,3 @@ describe('read', () => {
|
|
|
190
190
|
expect(await run(readWithClient(fake, 'ledger'))).toEqual(liveRow());
|
|
191
191
|
});
|
|
192
192
|
});
|
|
193
|
-
|
|
194
|
-
describe('drop', () => {
|
|
195
|
-
test('drops an empty schema with cascade false', async () => {
|
|
196
|
-
const fake = makeFakeSql({ schemas: [liveRow()] });
|
|
197
|
-
await run(dropWithClient(fake, baseProps));
|
|
198
|
-
const drops = fake.statements.filter((s) => s.text.startsWith('DROP SCHEMA'));
|
|
199
|
-
expect(drops.length).toBe(1);
|
|
200
|
-
expect(drops[0]?.text).toBe('DROP SCHEMA IF EXISTS "ledger"');
|
|
201
|
-
expect(fake.schemas.get('ledger')).toBeUndefined();
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
test('refuses to drop a non-empty schema with cascade false', async () => {
|
|
205
|
-
const fake = makeFakeSql({ schemas: [liveRow()], schemasWithRelations: ['ledger'] });
|
|
206
|
-
const error = await fails(dropWithClient(fake, baseProps));
|
|
207
|
-
expect(error).toBeInstanceOf(PostgresSchemaDropNotEmptyError);
|
|
208
|
-
expect(fake.schemas.get('ledger')).not.toBeUndefined();
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
test('drops a non-empty schema with cascade true', async () => {
|
|
212
|
-
const fake = makeFakeSql({ schemas: [liveRow()], schemasWithRelations: ['ledger'] });
|
|
213
|
-
await run(dropWithClient(fake, { ...baseProps, cascade: true }));
|
|
214
|
-
const drop = fake.statements.find((s) => s.text.startsWith('DROP SCHEMA'));
|
|
215
|
-
expect(drop?.text).toBe('DROP SCHEMA IF EXISTS "ledger" CASCADE');
|
|
216
|
-
expect(fake.schemas.get('ledger')).toBeUndefined();
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
test('classifies the server 2BP01 refusal as the typed not-empty tag', async () => {
|
|
220
|
-
// The emptiness check said empty, but the server refuses with SQLSTATE 2BP01 — an object
|
|
221
|
-
// kind the check's four catalogs do not cover. Class `2B` is not `42`, so both drivers
|
|
222
|
-
// wrap it as UnknownError with the raw code on the cause; this executor replays that shape.
|
|
223
|
-
const refusing: PgExecutor = {
|
|
224
|
-
unsafe: <A extends object>(text: string) =>
|
|
225
|
-
text.startsWith('SELECT current_database()')
|
|
226
|
-
? Effect.succeed([{ database: 'postgres' }] as unknown as ReadonlyArray<A>)
|
|
227
|
-
: text.includes('AS empty')
|
|
228
|
-
? Effect.succeed([{ empty: true }] as unknown as ReadonlyArray<A>)
|
|
229
|
-
: Effect.fail(
|
|
230
|
-
new SqlError({
|
|
231
|
-
reason: new UnknownError({
|
|
232
|
-
cause: Object.assign(
|
|
233
|
-
new Error('ERROR: 2BP01: dependent objects still exist'),
|
|
234
|
-
{ code: '2BP01' },
|
|
235
|
-
),
|
|
236
|
-
message: 'dependent_objects_still_exist',
|
|
237
|
-
operation: 'DROP SCHEMA',
|
|
238
|
-
}),
|
|
239
|
-
}),
|
|
240
|
-
),
|
|
241
|
-
transaction: () => Effect.void,
|
|
242
|
-
};
|
|
243
|
-
const error = await fails(dropWithClient(refusing, baseProps));
|
|
244
|
-
expect(error).toBeInstanceOf(PostgresSchemaDropNotEmptyError);
|
|
245
|
-
});
|
|
246
|
-
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as Data from 'effect/Data';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `cascade: true` on a schema that holds a SEQUENCE is refused (`HF004`, `schema-drop-sql.ts`).
|
|
5
|
+
*
|
|
6
|
+
* ⛔ A sequence cannot be locked by `LOCK TABLE`, so the drop's closure proof has a hole there: a
|
|
7
|
+
* concurrent, uncommitted `CREATE VIEW other.v AS SELECT last_value FROM ours.seq` is invisible
|
|
8
|
+
* to the scan, and when it commits while the drop waits on the sequence, PostgreSQL's deletion
|
|
9
|
+
* cascades into `other.v` (Codex read of PR 357, 2026-10-06). Serial and identity columns each
|
|
10
|
+
* own a sequence, so such a schema cannot be cascade-dropped by this provider.
|
|
11
|
+
* The message carries a COUNT only. Nothing was dropped: drop the schema's contents deliberately
|
|
12
|
+
* (the sequences included), then declare `cascade: false`.
|
|
13
|
+
*/
|
|
14
|
+
export class PostgresSchemaCascadeSequencesRefused extends Data.TaggedError(
|
|
15
|
+
'PostgresSchemaCascadeSequencesRefused',
|
|
16
|
+
)<{
|
|
17
|
+
readonly schema: string;
|
|
18
|
+
/** How many sequences the schema holds (at least one when raised). */
|
|
19
|
+
readonly sequences: number;
|
|
20
|
+
}> {
|
|
21
|
+
override get message(): string {
|
|
22
|
+
return (
|
|
23
|
+
`Postgres.Schema "${this.schema}": cascade: true refused — the schema holds ` +
|
|
24
|
+
`${String(this.sequences)} sequence(s) (serial/identity columns own one), which cannot be ` +
|
|
25
|
+
'locked, so a concurrent dependent could escape the cascade proof. No DROP was issued. ' +
|
|
26
|
+
'Drop the schema contents first, then declare cascade: false.'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The sequence COUNT out of an `HF004` error; unreadable still refuses (1, never 0). */
|
|
32
|
+
export const sequencesCount = (error: {
|
|
33
|
+
readonly reason: { readonly cause?: unknown };
|
|
34
|
+
}): number => {
|
|
35
|
+
const cause = error.reason.cause;
|
|
36
|
+
const message = typeof cause === 'object' && cause !== null ? (cause as Error).message : '';
|
|
37
|
+
return Math.max(1, Number(/sequences:\s*(\d+)/.exec(message ?? '')?.[1] ?? 1));
|
|
38
|
+
};
|
|
@@ -11,7 +11,8 @@ import * as PgClientModule from '@effect/sql-pg/PgClient';
|
|
|
11
11
|
import type { PgClient as PgClientService } from '@effect/sql-pg/PgClient';
|
|
12
12
|
import * as Effect from 'effect/Effect';
|
|
13
13
|
import * as Layer from 'effect/Layer';
|
|
14
|
-
import { SqlClient } from 'effect/unstable/sql/SqlClient';
|
|
14
|
+
import { SqlClient, TransactionConnection } from 'effect/unstable/sql/SqlClient';
|
|
15
|
+
import { PIN_SEARCH_PATH_SQL } from './search-path.ts';
|
|
15
16
|
import { isDependentObjectsError } from './schema-sql.ts';
|
|
16
17
|
import { classifyInstalled } from './installed-classifier.ts';
|
|
17
18
|
import { PostgresPool, postgresConnection } from './connection.ts';
|
|
@@ -31,6 +32,8 @@ const schemaRead = postgresSchemaHandlers.read;
|
|
|
31
32
|
const pools: Array<{ database?: string | undefined }> = [];
|
|
32
33
|
/** Every SQL text the fake client ran, in order. */
|
|
33
34
|
const queries: string[] = [];
|
|
35
|
+
/** The pool count at each `SET search_path`: one pin per pool, before that pool's statements. */
|
|
36
|
+
const pins: number[] = [];
|
|
34
37
|
/** The router answers for the current test (`schema-test-kit.ts` markers, JSON strings). */
|
|
35
38
|
let answers: RouteAnswers = {};
|
|
36
39
|
|
|
@@ -38,7 +41,15 @@ let answers: RouteAnswers = {};
|
|
|
38
41
|
// callbacks), and `Statement<A>` is an `Effect<ReadonlyArray<A>, SqlError>` — a plain succeed
|
|
39
42
|
// satisfies the same call shape.
|
|
40
43
|
const fakeClient = {
|
|
44
|
+
// `pinnedSocketExecutor` reserves one connection and routes every statement through the
|
|
45
|
+
// client's own `transactionService` — the fake only has to answer both.
|
|
46
|
+
reserve: Effect.succeed({}),
|
|
47
|
+
transactionService: TransactionConnection(0),
|
|
41
48
|
unsafe: (text: string) => {
|
|
49
|
+
if (text === PIN_SEARCH_PATH_SQL) {
|
|
50
|
+
pins.push(pools.length);
|
|
51
|
+
return Effect.succeed([] as never);
|
|
52
|
+
}
|
|
42
53
|
queries.push(text);
|
|
43
54
|
const answer = route(text, answers);
|
|
44
55
|
return Effect.succeed(
|
|
@@ -72,6 +83,7 @@ describe('socket transport: a missing declared database is schema absent', () =>
|
|
|
72
83
|
beforeEach(() => {
|
|
73
84
|
pools.length = 0;
|
|
74
85
|
queries.length = 0;
|
|
86
|
+
pins.length = 0;
|
|
75
87
|
answers = {};
|
|
76
88
|
});
|
|
77
89
|
|
|
@@ -85,8 +97,8 @@ describe('socket transport: a missing declared database is schema absent', () =>
|
|
|
85
97
|
expect(pools.length).toBe(1);
|
|
86
98
|
expect(pools[0]?.database).toBe('postgres');
|
|
87
99
|
expect(queries.length).toBe(1);
|
|
88
|
-
expect(queries[0]).toContain('FROM pg_database');
|
|
89
|
-
expect(queries.some((q) => q.startsWith('
|
|
100
|
+
expect(queries[0]).toContain('FROM pg_catalog.pg_database');
|
|
101
|
+
expect(queries.some((q) => q.startsWith('DO '))).toBe(false);
|
|
90
102
|
});
|
|
91
103
|
|
|
92
104
|
test('read answers absent: the untyped 3D000 connect never happens', async () => {
|
|
@@ -98,7 +110,7 @@ describe('socket transport: a missing declared database is schema absent', () =>
|
|
|
98
110
|
expect(pools.length).toBe(1);
|
|
99
111
|
expect(pools[0]?.database).toBe('postgres');
|
|
100
112
|
expect(queries.length).toBe(1);
|
|
101
|
-
expect(queries[0]).toContain('FROM pg_database');
|
|
113
|
+
expect(queries[0]).toContain('FROM pg_catalog.pg_database');
|
|
102
114
|
});
|
|
103
115
|
|
|
104
116
|
test('the `database` override reaches the pool: probe over the family pool, statements over the declared one', async () => {
|
|
@@ -113,7 +125,9 @@ describe('socket transport: a missing declared database is schema absent', () =>
|
|
|
113
125
|
);
|
|
114
126
|
// The probe opened the family database; every statement after it opened `agents`.
|
|
115
127
|
expect(pools.map((pool) => pool.database)).toEqual(['postgres', 'agents']);
|
|
116
|
-
|
|
128
|
+
// Each pool's reserved connection is pinned before its first statement: pool 1, then pool 2.
|
|
129
|
+
expect(pins).toEqual([1, 2]);
|
|
130
|
+
expect(queries.some((q) => q.startsWith('DO '))).toBe(true);
|
|
117
131
|
});
|
|
118
132
|
|
|
119
133
|
test('read over an existing declared database answers the row from the overridden pool', async () => {
|
|
@@ -147,7 +161,7 @@ describe('socket transport: a missing declared database is schema absent', () =>
|
|
|
147
161
|
);
|
|
148
162
|
expect(error).toBeInstanceOf(PostgresSchemaDeleteForeignRefused);
|
|
149
163
|
expect(error).toMatchObject({ liveOid: 16443, liveOwner: 'other_role', lastOid: 16442 });
|
|
150
|
-
expect(queries.some((q) => q.startsWith('
|
|
164
|
+
expect(queries.some((q) => q.startsWith('DO '))).toBe(false);
|
|
151
165
|
});
|
|
152
166
|
|
|
153
167
|
test('the installed classifier keeps 3D000 as UnknownError with the raw code — the untyped failure the probe prevents', async () => {
|
|
@@ -32,10 +32,10 @@ export const buildDropSchemaSql = (name: string, cascade: boolean): string =>
|
|
|
32
32
|
const SELECT_SCHEMA_SQL = `SELECT
|
|
33
33
|
n.oid AS oid,
|
|
34
34
|
n.nspname AS name,
|
|
35
|
-
pg_get_userbyid(n.nspowner) AS owner,
|
|
36
|
-
obj_description(n.oid, 'pg_namespace') AS comment,
|
|
35
|
+
pg_catalog.pg_get_userbyid(n.nspowner) AS owner,
|
|
36
|
+
pg_catalog.obj_description(n.oid, 'pg_namespace') AS comment,
|
|
37
37
|
current_database() AS database
|
|
38
|
-
FROM pg_namespace n
|
|
38
|
+
FROM pg_catalog.pg_namespace n
|
|
39
39
|
WHERE n.nspname = $1`;
|
|
40
40
|
|
|
41
41
|
/** The read: one bound `SELECT` on `pg_namespace`. `undefined` when absent. The row carries
|
|
@@ -84,18 +84,23 @@ export const currentUser = (pg: PgExecutor): Effect.Effect<string, SqlError> =>
|
|
|
84
84
|
*
|
|
85
85
|
* ⚠️ THE FAKE (`fake-schema-sql.ts`) ROUTES THIS STATEMENT BY `AS empty` + `pg_class` — keep both
|
|
86
86
|
* markers in any rewrite, and keep this check matched BEFORE the plain `pg_namespace` branch
|
|
87
|
-
* (this SQL contains `FROM pg_namespace` too, inside its `WITH`).
|
|
87
|
+
* (this SQL contains `FROM pg_catalog.pg_namespace` too, inside its `WITH`).
|
|
88
88
|
*/
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
/** The four-catalog emptiness predicate over a namespace oid expression — shared by this read
|
|
90
|
+
* and by the atomic drop's in-transaction check (`schema-drop-sql.ts`), so the two cannot
|
|
91
|
+
* disagree about what "empty" means. */
|
|
92
|
+
export const emptyPredicate = (ns: string): string => `NOT EXISTS (
|
|
93
|
+
SELECT 1 FROM pg_catalog.pg_class c WHERE c.relnamespace = ${ns}
|
|
92
94
|
) AND NOT EXISTS (
|
|
93
|
-
SELECT 1 FROM pg_proc p WHERE p.pronamespace =
|
|
95
|
+
SELECT 1 FROM pg_catalog.pg_proc p WHERE p.pronamespace = ${ns}
|
|
94
96
|
) AND NOT EXISTS (
|
|
95
|
-
SELECT 1 FROM pg_type t WHERE t.typnamespace =
|
|
97
|
+
SELECT 1 FROM pg_catalog.pg_type t WHERE t.typnamespace = ${ns}
|
|
96
98
|
) AND NOT EXISTS (
|
|
97
|
-
SELECT 1 FROM pg_operator o WHERE o.oprnamespace =
|
|
98
|
-
)
|
|
99
|
+
SELECT 1 FROM pg_catalog.pg_operator o WHERE o.oprnamespace = ${ns}
|
|
100
|
+
)`;
|
|
101
|
+
|
|
102
|
+
const SCHEMA_EMPTY_SQL = `WITH ns AS (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = $1)
|
|
103
|
+
SELECT ${emptyPredicate('(SELECT oid FROM ns)')} AS empty`;
|
|
99
104
|
|
|
100
105
|
/** `true` when the schema holds none of the four object catalogs above (used before a
|
|
101
106
|
* non-cascade `DROP SCHEMA`). */
|
|
@@ -5,6 +5,7 @@ import { postgresSchemaHandlers, reconcileWithClient } from './schema.ts';
|
|
|
5
5
|
import { postgresRunnerConnection } from './connection.ts';
|
|
6
6
|
import { makeFakeSql } from './fake-sql.ts';
|
|
7
7
|
import { readArgs, router, runnerWith } from './schema-test-kit.ts';
|
|
8
|
+
import { stripPin } from './search-path.ts';
|
|
8
9
|
import type { PostgresSchemaAttributes } from './schema-attrs.ts';
|
|
9
10
|
|
|
10
11
|
const props = { name: 'ledger', database: 'postgres', owner: 'postgres', cascade: true };
|
|
@@ -101,21 +102,23 @@ test('same-owner create race is a typed exists refusal, never a comment or adopt
|
|
|
101
102
|
|
|
102
103
|
test('runner 42P06 becomes an adoption refusal; other SQLSTATEs remain SqlError', async () => {
|
|
103
104
|
for (const code of ['42P06', '42501']) {
|
|
104
|
-
const run = ({ stdin }: { stdin: string }) =>
|
|
105
|
-
|
|
105
|
+
const run = ({ stdin: raw }: { stdin: string }) => {
|
|
106
|
+
const stdin = stripPin(raw);
|
|
107
|
+
return Promise.resolve(
|
|
106
108
|
stdin.startsWith('CREATE')
|
|
107
109
|
? { code: 3, stdout: '', stderr: `ERROR: ${code}: refused` }
|
|
108
110
|
: {
|
|
109
111
|
code: 0,
|
|
110
112
|
stdout:
|
|
111
|
-
stdin.includes('current_database()') && !stdin.includes('pg_namespace')
|
|
113
|
+
stdin.includes('current_database()') && !stdin.includes('pg_catalog.pg_namespace')
|
|
112
114
|
? '[{"database":"postgres"}]'
|
|
113
|
-
: stdin.includes('pg_roles')
|
|
115
|
+
: stdin.includes('pg_catalog.pg_roles')
|
|
114
116
|
? '[{"present":1}]'
|
|
115
117
|
: '[]',
|
|
116
118
|
stderr: '',
|
|
117
119
|
},
|
|
118
120
|
);
|
|
121
|
+
};
|
|
119
122
|
const error = await Effect.runPromise(
|
|
120
123
|
postgresSchemaHandlers
|
|
121
124
|
.reconcile(args())
|