@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
@@ -11,6 +11,7 @@
11
11
  * branch's marker and matches its own.
12
12
  */
13
13
  import type { PsqlRunner } from './psql-executor.ts';
14
+ import { stripPin } from './search-path.ts';
14
15
  import type { PostgresSchemaAttributes, PostgresSchemaProps } from './schema-attrs.ts';
15
16
 
16
17
  export const ok = (stdout: string) => Promise.resolve({ code: 0, stdout, stderr: '' });
@@ -29,9 +30,12 @@ export interface RouteAnswers {
29
30
 
30
31
  /** Route one stdin to its answer by the markers above, `undefined` when no branch matches. */
31
32
  export const route = (stdin: string, answers: RouteAnswers): string | undefined => {
33
+ // The atomic drop (`schema-drop-sql.ts`) quotes every marker below inside its body: it answers
34
+ // nothing, and a refusal is injected by `runnerWith`'s `failOn`, never routed.
35
+ if (stdin.startsWith('DO ')) return undefined;
32
36
  if (stdin.includes('AS empty')) return answers.empty;
33
- if (stdin.includes('FROM pg_namespace')) return answers.schema;
34
- if (stdin.includes('FROM pg_database')) return answers.probe;
37
+ if (stdin.includes('FROM pg_catalog.pg_namespace')) return answers.schema;
38
+ if (stdin.includes('FROM pg_catalog.pg_database')) return answers.probe;
35
39
  if (stdin.includes('current_database()')) return answers.proof;
36
40
  return undefined;
37
41
  };
@@ -46,14 +50,24 @@ export const router = (answers: RouteAnswers) => (stdin: string) => route(stdin,
46
50
  */
47
51
  export const runnerWith = (
48
52
  answers: (stdin: string) => string,
53
+ /** The `psql` stderr a statement fails with (exit 3), or `undefined` to answer normally. */
54
+ failOn: (stdin: string) => string | undefined = () => undefined,
49
55
  ): { run: PsqlRunner; stdins: string[]; argvs: Array<readonly string[]> } => {
50
56
  const stdins: string[] = [];
51
57
  const argvs: Array<readonly string[]> = [];
52
58
  return {
53
- run: ({ stdin, argv }) => {
59
+ // The pin prefix (`search-path.ts`) is stripped: these tests assert the STATEMENT. The pin
60
+ // itself is asserted once, on the raw stdin, in `search-path.test.ts`.
61
+ run: ({ stdin: raw, argv }) => {
62
+ const stdin = stripPin(raw);
54
63
  stdins.push(stdin);
55
64
  argvs.push(argv);
56
- return Promise.resolve({ code: 0, stdout: answers(stdin), stderr: '' });
65
+ const stderr = failOn(stdin);
66
+ return Promise.resolve(
67
+ stderr === undefined
68
+ ? { code: 0, stdout: answers(stdin), stderr: '' }
69
+ : { code: 3, stdout: '', stderr },
70
+ );
57
71
  },
58
72
  stdins,
59
73
  argvs,
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The `search_path` pin (`search-path.ts`): every session this family opens starts with
3
+ * `SET search_path = pg_catalog, pg_temp`, on both transports, and the SQL builders only name
4
+ * catalogs as `pg_catalog.*`. The Opus read of PR 334 (2026-10-02) found that a role allowed to
5
+ * create objects in a schema on the path could forge `pg_namespace` and fake the delete proof.
6
+ */
7
+ import { readFileSync, readdirSync } from 'node:fs';
8
+ import { describe, expect, test } from 'bun:test';
9
+ import * as Effect from 'effect/Effect';
10
+ import { currentDatabase } from './schema-sql.ts';
11
+ import { makePsqlExecutor } from './psql-executor.ts';
12
+ import { PIN_SCRIPT_PREFIX, PIN_SEARCH_PATH_SQL } from './search-path.ts';
13
+
14
+ describe('runner transport', () => {
15
+ const target = { database: 'postgres', username: 'postgres' };
16
+ const record = () => {
17
+ const stdins: string[] = [];
18
+ const run = ({ stdin }: { stdin: string }) => {
19
+ stdins.push(stdin);
20
+ return Promise.resolve({ code: 0, stdout: '[]', stderr: '' });
21
+ };
22
+ return { stdins, run };
23
+ };
24
+
25
+ test('a read starts with the pin, in the same psql session as the statement', async () => {
26
+ const { stdins, run } = record();
27
+ await Effect.runPromise(currentDatabase(makePsqlExecutor(run, target)));
28
+ expect(stdins).toHaveLength(1);
29
+ expect(stdins[0]?.startsWith('SET search_path = pg_catalog, pg_temp;\n')).toBe(true);
30
+ expect(stdins[0]?.startsWith(PIN_SCRIPT_PREFIX)).toBe(true);
31
+ });
32
+
33
+ test('a write and a transaction start with the pin too', async () => {
34
+ const { stdins, run } = record();
35
+ const pg = makePsqlExecutor(run, target);
36
+ await Effect.runPromise(pg.unsafe('COMMENT ON SCHEMA "a" IS E\'b\''));
37
+ await Effect.runPromise(pg.transaction(['CREATE ROLE "r"']));
38
+ expect(stdins).toHaveLength(2);
39
+ for (const stdin of stdins) expect(stdin.startsWith(PIN_SCRIPT_PREFIX)).toBe(true);
40
+ // The pin comes BEFORE `BEGIN`: it is a session setting, not part of the transaction.
41
+ expect(stdins[1]).toBe(`${PIN_SCRIPT_PREFIX}BEGIN;\nCREATE ROLE "r";\nCOMMIT;`);
42
+ });
43
+
44
+ test('the pin is the one statement text both transports share', () => {
45
+ expect(PIN_SEARCH_PATH_SQL).toBe('SET search_path = pg_catalog, pg_temp');
46
+ });
47
+ });
48
+
49
+ /** Every hand-written SQL builder in this family. The `*errors.ts` files are excluded: they name
50
+ * catalogs in error-message PROSE, which no server ever parses. The fakes match on the qualified
51
+ * text the builders emit, so their own tests pin them. */
52
+ const sources = readdirSync(import.meta.dir).filter(
53
+ (f) =>
54
+ f.endsWith('.ts') &&
55
+ !f.endsWith('.test.ts') &&
56
+ !f.startsWith('fake-') &&
57
+ !/errors?\.ts$/.test(f),
58
+ );
59
+ const CATALOGS =
60
+ /(?<![\w.])(pg_(?:namespace|class|proc|type|operator|roles|authid|auth_members|database|tablespace|attribute|default_acl|depend|constraint|trigger|rewrite|attrdef|policy))\b/g;
61
+
62
+ describe('catalog references', () => {
63
+ test('no SQL builder names a catalog without the pg_catalog. qualifier', () => {
64
+ const offenders: string[] = [];
65
+ for (const file of sources) {
66
+ const text = readFileSync(`${import.meta.dir}/${file}`, 'utf8');
67
+ // Only template/quoted SQL is judged: skip comment lines and `'pg_namespace'` literals
68
+ // (an `obj_description` class argument is a string, not a relation reference).
69
+ for (const [index, line] of text.split('\n').entries()) {
70
+ const trimmed = line.trim();
71
+ if (/^(\/\/|\*|\/\*)/.test(trimmed)) continue;
72
+ for (const match of line.matchAll(CATALOGS)) {
73
+ const before = line[(match.index ?? 0) - 1];
74
+ if (before === "'" || before === '`') continue;
75
+ offenders.push(`${file}:${String(index + 1)} ${match[1] ?? ''}`);
76
+ }
77
+ }
78
+ }
79
+ expect(offenders).toEqual([]);
80
+ });
81
+
82
+ test('the scan sees the catalogs it is meant to police', () => {
83
+ const joined = sources.map((f) => readFileSync(`${import.meta.dir}/${f}`, 'utf8')).join('\n');
84
+ for (const needle of [
85
+ 'pg_catalog.pg_namespace',
86
+ 'pg_catalog.pg_class',
87
+ 'pg_catalog.pg_depend',
88
+ 'pg_catalog.pg_roles',
89
+ 'pg_catalog.pg_database',
90
+ ]) {
91
+ expect(joined).toContain(needle);
92
+ }
93
+ });
94
+
95
+ test('the scan itself fails on an unqualified reference', () => {
96
+ const bad = ' FROM pg_namespace n\n JOIN pg_catalog.pg_class c ON true';
97
+ const hits = [...bad.matchAll(CATALOGS)].map((m) => m[1]);
98
+ expect(hits).toEqual(['pg_namespace']);
99
+ });
100
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The session setup every `Postgres.*` connection runs first: `search_path = pg_catalog, pg_temp`.
3
+ *
4
+ * ⛔ AN UNPINNED SESSION READS ITS OWN CATALOGS THROUGH `search_path`. Postgres searches
5
+ * `pg_catalog` first only while the path does not NAME it (ddl.sgml 5.10.3); a role- or
6
+ * database-level `ALTER … SET search_path = public, pg_catalog` (or a path naming any schema
7
+ * another role may create in) puts a writable schema ahead of it, and a `public.pg_namespace`
8
+ * TABLE then answers the delete proof's `oid`/`owner` re-read. Found by the Opus read of PR 334
9
+ * (2026-10-02). The pin makes the order ours whatever the role, database or server says; the SQL
10
+ * builders ALSO write `pg_catalog.<name>` (`catalog-qualified.test.ts` greps for it), so no
11
+ * single layer is the whole defence.
12
+ * ★ `pg_temp` goes LAST on purpose: unnamed, it is searched FIRST for relations.
13
+ * ★ TWO TRANSPORTS, TWO MECHANISMS. The runner path prefixes the stdin script: `psql` argv is
14
+ * the consumer's to join into a remote command, and a value with a space would split. The
15
+ * socket path pins the ONE connection an operation reserves, because the driver's startup
16
+ * message carries user, database and application_name only (`PgConnection.ts`, 4.0.0-rc.115)
17
+ * and a pool hands each statement a different, unpinned connection.
18
+ */
19
+ import type { PgClient } from '@effect/sql-pg/PgClient';
20
+ import * as Effect from 'effect/Effect';
21
+ import type * as Scope from 'effect/Scope';
22
+ import type { SqlError } from 'effect/unstable/sql/SqlError';
23
+ import type { PgExecutor } from './database-sql.ts';
24
+
25
+ export const PIN_SEARCH_PATH_SQL = 'SET search_path = pg_catalog, pg_temp';
26
+
27
+ /** The runner path's script prefix. */
28
+ export const PIN_SCRIPT_PREFIX = `${PIN_SEARCH_PATH_SQL};\n`;
29
+
30
+ /** A runner stdin without its pin — for tests that assert the statement that follows it. */
31
+ export const stripPin = (stdin: string): string =>
32
+ stdin.startsWith(PIN_SCRIPT_PREFIX) ? stdin.slice(PIN_SCRIPT_PREFIX.length) : stdin;
33
+
34
+ /**
35
+ * A `PgExecutor` over ONE reserved, pinned connection, for the life of the surrounding scope.
36
+ * `unsafe` runs on it (the client routes any statement under `transactionService` to that
37
+ * connection, so spans and error classification stay the driver's); `transaction` is a plain
38
+ * `BEGIN` … `COMMIT` on it — `withTransaction` would open a SAVEPOINT, because the service is
39
+ * already provided. A failure rolls back, and the pin is re-asserted by the next operation's own
40
+ * reservation, never assumed to survive one.
41
+ */
42
+ export const pinnedSocketExecutor = (
43
+ pg: PgClient,
44
+ ): Effect.Effect<PgExecutor, SqlError, Scope.Scope> =>
45
+ Effect.gen(function* () {
46
+ const connection = yield* pg.reserve;
47
+ const run = <A extends object>(sql: string, params?: ReadonlyArray<unknown>) =>
48
+ Effect.provideService(pg.unsafe<A>(sql, params), pg.transactionService, [connection, 0]);
49
+ yield* run(PIN_SEARCH_PATH_SQL);
50
+ return {
51
+ unsafe: run,
52
+ transaction: (statements) =>
53
+ Effect.gen(function* () {
54
+ yield* run('BEGIN');
55
+ for (const sql of statements) yield* run(sql);
56
+ yield* run('COMMIT');
57
+ }).pipe(
58
+ Effect.onError(() => run('ROLLBACK').pipe(Effect.ignore)),
59
+ Effect.asVoid,
60
+ ),
61
+ };
62
+ });