@rebasepro/server-postgresql 0.6.1 → 0.7.0

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 (75) hide show
  1. package/package.json +24 -18
  2. package/src/PostgresBackendDriver.ts +65 -44
  3. package/src/PostgresBootstrapper.ts +34 -2
  4. package/src/auth/ensure-tables.ts +56 -1
  5. package/src/auth/services.ts +94 -1
  6. package/src/cli-errors.ts +162 -0
  7. package/src/cli-helpers.ts +183 -0
  8. package/src/cli.ts +198 -251
  9. package/src/schema/auth-default-policies.ts +90 -0
  10. package/src/schema/auth-schema.ts +25 -2
  11. package/src/schema/doctor.ts +2 -4
  12. package/src/schema/generate-drizzle-schema-logic.ts +4 -3
  13. package/src/schema/generate-drizzle-schema.ts +3 -5
  14. package/src/schema/generate-postgres-ddl-logic.ts +524 -0
  15. package/src/schema/generate-postgres-ddl.ts +116 -0
  16. package/src/services/EntityPersistService.ts +10 -8
  17. package/src/services/entityService.ts +28 -3
  18. package/src/utils/pg-error-utils.ts +16 -0
  19. package/src/utils/table-classification.ts +16 -0
  20. package/src/websocket.ts +9 -0
  21. package/test/auth-default-policies.test.ts +89 -0
  22. package/test/cli-helpers-extended.test.ts +324 -0
  23. package/test/cli-helpers.test.ts +59 -0
  24. package/test/connection.test.ts +292 -0
  25. package/test/databasePoolManager.test.ts +289 -0
  26. package/test/doctor-extended.test.ts +443 -0
  27. package/test/e2e/db-e2e.test.ts +293 -0
  28. package/test/e2e/pg-setup.ts +79 -0
  29. package/test/entity-persist-composite-keys.test.ts +451 -0
  30. package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
  31. package/test/generate-postgres-ddl.test.ts +300 -0
  32. package/test/mfa-service.test.ts +544 -0
  33. package/test/pg-error-utils.test.ts +50 -1
  34. package/test/realtimeService-channels.test.ts +696 -0
  35. package/test/unmapped-tables-safety.test.ts +55 -342
  36. package/vitest.e2e.config.ts +10 -0
  37. package/build-errors.txt +0 -37
  38. package/dist/PostgresAdapter.d.ts +0 -6
  39. package/dist/PostgresBackendDriver.d.ts +0 -110
  40. package/dist/PostgresBootstrapper.d.ts +0 -46
  41. package/dist/auth/ensure-tables.d.ts +0 -10
  42. package/dist/auth/services.d.ts +0 -231
  43. package/dist/cli.d.ts +0 -1
  44. package/dist/collections/PostgresCollectionRegistry.d.ts +0 -47
  45. package/dist/connection.d.ts +0 -65
  46. package/dist/data-transformer.d.ts +0 -55
  47. package/dist/databasePoolManager.d.ts +0 -20
  48. package/dist/history/HistoryService.d.ts +0 -71
  49. package/dist/history/ensure-history-table.d.ts +0 -7
  50. package/dist/index.d.ts +0 -14
  51. package/dist/index.es.js +0 -10803
  52. package/dist/index.es.js.map +0 -1
  53. package/dist/interfaces.d.ts +0 -18
  54. package/dist/schema/auth-schema.d.ts +0 -2149
  55. package/dist/schema/doctor-cli.d.ts +0 -2
  56. package/dist/schema/doctor.d.ts +0 -52
  57. package/dist/schema/generate-drizzle-schema-logic.d.ts +0 -2
  58. package/dist/schema/generate-drizzle-schema.d.ts +0 -1
  59. package/dist/schema/introspect-db-inference.d.ts +0 -5
  60. package/dist/schema/introspect-db-logic.d.ts +0 -118
  61. package/dist/schema/introspect-db.d.ts +0 -1
  62. package/dist/schema/test-schema.d.ts +0 -24
  63. package/dist/services/BranchService.d.ts +0 -47
  64. package/dist/services/EntityFetchService.d.ts +0 -214
  65. package/dist/services/EntityPersistService.d.ts +0 -40
  66. package/dist/services/RelationService.d.ts +0 -98
  67. package/dist/services/entity-helpers.d.ts +0 -38
  68. package/dist/services/entityService.d.ts +0 -110
  69. package/dist/services/index.d.ts +0 -4
  70. package/dist/services/realtimeService.d.ts +0 -220
  71. package/dist/types.d.ts +0 -3
  72. package/dist/utils/drizzle-conditions.d.ts +0 -138
  73. package/dist/utils/pg-array-null-patch.d.ts +0 -16
  74. package/dist/utils/pg-error-utils.d.ts +0 -55
  75. package/dist/websocket.d.ts +0 -11
@@ -0,0 +1,59 @@
1
+ import { getDevDatabaseUrl, getTableIncludesFromCollections } from "../src/cli-helpers";
2
+
3
+ describe("CLI Helpers", () => {
4
+ describe("getDevDatabaseUrl", () => {
5
+ it("should parse standard postgres URL and append _dev_diff", () => {
6
+ const dbUrl = "postgresql://user:pass@localhost:5432/my_db";
7
+ expect(getDevDatabaseUrl(dbUrl)).toBe("postgresql://user:pass@localhost:5432/my_db_dev_diff");
8
+ });
9
+
10
+ it("should parse postgres URL with query parameters", () => {
11
+ const dbUrl = "postgresql://user:pass@localhost:5432/my_db?sslmode=disable";
12
+ expect(getDevDatabaseUrl(dbUrl)).toBe("postgresql://user:pass@localhost:5432/my_db_dev_diff?sslmode=disable");
13
+ });
14
+
15
+ it("should fall back to simple string concatenation on invalid URL", () => {
16
+ const dbUrl = "invalid-url-string";
17
+ expect(getDevDatabaseUrl(dbUrl)).toBe("invalid-url-string_dev_diff");
18
+ });
19
+ });
20
+
21
+ describe("getTableIncludesFromCollections", () => {
22
+ it("should parse collections and return correct includes array", async () => {
23
+ const mockCollections = [
24
+ {
25
+ slug: "users",
26
+ table: "users",
27
+ properties: {
28
+ name: { type: "string" }
29
+ }
30
+ },
31
+ {
32
+ slug: "posts",
33
+ table: "posts",
34
+ properties: {
35
+ title: { type: "string" }
36
+ },
37
+ relations: [
38
+ {
39
+ relationName: "tags",
40
+ target: () => ({ table: "tags", slug: "tags" }),
41
+ cardinality: "many",
42
+ direction: "owning",
43
+ through: {
44
+ table: "posts_to_tags",
45
+ sourceColumn: "post_id",
46
+ targetColumn: "tag_id"
47
+ }
48
+ }
49
+ ]
50
+ }
51
+ ];
52
+
53
+ const includes = await getTableIncludesFromCollections(mockCollections);
54
+ expect(includes).toContain("public.users");
55
+ expect(includes).toContain("public.posts");
56
+ expect(includes).toContain("public.posts_to_tags");
57
+ });
58
+ });
59
+ });
@@ -0,0 +1,292 @@
1
+ import { Pool, PoolConfig } from "pg";
2
+
3
+ // ── Capture every Pool instantiation so we can inspect config & event handlers ──
4
+ interface MockPoolInstance {
5
+ config: PoolConfig;
6
+ handlers: Record<string, ((...args: unknown[]) => void)[]>;
7
+ on: jest.Mock;
8
+ end: jest.Mock;
9
+ }
10
+
11
+ const poolInstances: MockPoolInstance[] = [];
12
+
13
+ jest.mock("pg", () => {
14
+ const actual = jest.requireActual<typeof import("pg")>("pg");
15
+ const MockPool = jest.fn().mockImplementation((config: PoolConfig) => {
16
+ const handlers: Record<string, ((...args: unknown[]) => void)[]> = {};
17
+ const instance: MockPoolInstance = {
18
+ config,
19
+ handlers,
20
+ on: jest.fn((event: string, handler: (...args: unknown[]) => void) => {
21
+ handlers[event] = handlers[event] || [];
22
+ handlers[event].push(handler);
23
+ return instance;
24
+ }),
25
+ end: jest.fn().mockResolvedValue(undefined)
26
+ };
27
+ poolInstances.push(instance);
28
+ return instance;
29
+ });
30
+ return { ...actual, Pool: MockPool };
31
+ });
32
+
33
+ // ── Stub drizzle – we only care that it is called, not what it returns ──
34
+ const mockDrizzle = jest.fn().mockReturnValue({ __drizzle: true });
35
+ jest.mock("drizzle-orm/node-postgres", () => ({
36
+ drizzle: mockDrizzle
37
+ }));
38
+
39
+ // ── Stub the logger so it doesn't try to write anywhere ──
40
+ jest.mock("@rebasepro/server-core", () => ({
41
+ logger: {
42
+ error: jest.fn(),
43
+ warn: jest.fn(),
44
+ info: jest.fn(),
45
+ debug: jest.fn()
46
+ }
47
+ }));
48
+
49
+ import {
50
+ createPostgresDatabaseConnection,
51
+ createDirectDatabaseConnection,
52
+ createReadReplicaConnection
53
+ } from "../src/connection";
54
+ import { logger } from "@rebasepro/server-core";
55
+
56
+ // ── Helpers ──────────────────────────────────────────────────────────────────────
57
+ function lastPool(): MockPoolInstance {
58
+ return poolInstances[poolInstances.length - 1];
59
+ }
60
+
61
+ // ─────────────────────────────────────────────────────────────────────────────────
62
+ // Tests
63
+ // ─────────────────────────────────────────────────────────────────────────────────
64
+ beforeEach(() => {
65
+ poolInstances.length = 0;
66
+ jest.clearAllMocks();
67
+ });
68
+
69
+ describe("createPostgresDatabaseConnection", () => {
70
+ const connStr = "postgresql://user:pass@localhost:5432/mydb";
71
+
72
+ it("returns an object with db, pool, and connectionString", () => {
73
+ const result = createPostgresDatabaseConnection(connStr);
74
+
75
+ expect(result).toHaveProperty("db");
76
+ expect(result).toHaveProperty("pool");
77
+ expect(result).toHaveProperty("connectionString", connStr);
78
+ });
79
+
80
+ it("creates a Pool with the provided connectionString", () => {
81
+ createPostgresDatabaseConnection(connStr);
82
+
83
+ expect(Pool).toHaveBeenCalledTimes(1);
84
+ expect(lastPool().config.connectionString).toBe(connStr);
85
+ });
86
+
87
+ it("applies default pool config values", () => {
88
+ createPostgresDatabaseConnection(connStr);
89
+ const cfg = lastPool().config;
90
+
91
+ expect(cfg.max).toBe(20);
92
+ expect(cfg.idleTimeoutMillis).toBe(30_000);
93
+ expect(cfg.connectionTimeoutMillis).toBe(10_000);
94
+ expect(cfg.query_timeout).toBe(30_000);
95
+ expect(cfg.statement_timeout).toBe(30_000);
96
+ expect(cfg.keepAlive).toBe(true);
97
+ expect(cfg.keepAliveInitialDelayMillis).toBe(0);
98
+ });
99
+
100
+ it("merges custom poolConfig over defaults", () => {
101
+ createPostgresDatabaseConnection(connStr, undefined, {
102
+ max: 50,
103
+ idleTimeoutMillis: 60_000
104
+ });
105
+ const cfg = lastPool().config;
106
+
107
+ expect(cfg.max).toBe(50);
108
+ expect(cfg.idleTimeoutMillis).toBe(60_000);
109
+ // other defaults are preserved
110
+ expect(cfg.connectionTimeoutMillis).toBe(10_000);
111
+ expect(cfg.keepAlive).toBe(true);
112
+ });
113
+
114
+ it("registers an error handler on the pool", () => {
115
+ createPostgresDatabaseConnection(connStr);
116
+ const pool = lastPool();
117
+
118
+ expect(pool.on).toHaveBeenCalledWith("error", expect.any(Function));
119
+ expect(pool.handlers["error"]).toHaveLength(1);
120
+ });
121
+
122
+ it("error handler logs unexpected pool errors", () => {
123
+ createPostgresDatabaseConnection(connStr);
124
+ const handler = lastPool().handlers["error"][0];
125
+
126
+ handler(new Error("something broke"));
127
+
128
+ expect(logger.error).toHaveBeenCalledWith(
129
+ "[pg-pool] Unexpected pool error",
130
+ expect.objectContaining({ detail: "something broke" })
131
+ );
132
+ });
133
+
134
+ it("error handler logs additional warning for ETIMEDOUT errors", () => {
135
+ createPostgresDatabaseConnection(connStr);
136
+ const handler = lastPool().handlers["error"][0];
137
+
138
+ handler(new Error("connect ETIMEDOUT 1.2.3.4:5432"));
139
+
140
+ expect(logger.error).toHaveBeenCalled();
141
+ expect(logger.warn).toHaveBeenCalledWith(
142
+ "[pg-pool] Connection timeout detected — pool will auto-retry"
143
+ );
144
+ });
145
+
146
+ it("calls drizzle with the pool when no schema is provided", () => {
147
+ createPostgresDatabaseConnection(connStr);
148
+
149
+ expect(mockDrizzle).toHaveBeenCalledWith(lastPool());
150
+ });
151
+
152
+ it("calls drizzle with pool and schema when schema is provided", () => {
153
+ const schema = { users: {} };
154
+ createPostgresDatabaseConnection(connStr, schema);
155
+
156
+ expect(mockDrizzle).toHaveBeenCalledWith(lastPool(), { schema });
157
+ });
158
+
159
+ it("passes schema as Record<string, unknown>", () => {
160
+ const schema: Record<string, unknown> = { orders: {}, products: {} };
161
+ createPostgresDatabaseConnection(connStr, schema);
162
+
163
+ expect(mockDrizzle).toHaveBeenCalledWith(lastPool(), { schema });
164
+ });
165
+ });
166
+
167
+ describe("createDirectDatabaseConnection", () => {
168
+ const connStr = "postgresql://user:pass@localhost:5432/direct_db";
169
+
170
+ it("returns an object with db, pool, and connectionString", () => {
171
+ const result = createDirectDatabaseConnection(connStr);
172
+
173
+ expect(result).toHaveProperty("db");
174
+ expect(result).toHaveProperty("pool");
175
+ expect(result).toHaveProperty("connectionString", connStr);
176
+ });
177
+
178
+ it("uses a smaller default max of 5 for the direct pool", () => {
179
+ createDirectDatabaseConnection(connStr);
180
+ const cfg = lastPool().config;
181
+
182
+ expect(cfg.max).toBe(5);
183
+ });
184
+
185
+ it("allows overriding the default max on the direct pool", () => {
186
+ createDirectDatabaseConnection(connStr, undefined, { max: 3 });
187
+ const cfg = lastPool().config;
188
+
189
+ expect(cfg.max).toBe(3);
190
+ });
191
+
192
+ it("registers an error handler on the pool", () => {
193
+ createDirectDatabaseConnection(connStr);
194
+ const pool = lastPool();
195
+
196
+ expect(pool.on).toHaveBeenCalledWith("error", expect.any(Function));
197
+ expect(pool.handlers["error"]).toHaveLength(1);
198
+ });
199
+
200
+ it("error handler logs with [pg-direct-pool] prefix", () => {
201
+ createDirectDatabaseConnection(connStr);
202
+ const handler = lastPool().handlers["error"][0];
203
+
204
+ handler(new Error("direct pool error"));
205
+
206
+ expect(logger.error).toHaveBeenCalledWith(
207
+ "[pg-direct-pool] Unexpected pool error",
208
+ expect.objectContaining({ detail: "direct pool error" })
209
+ );
210
+ });
211
+
212
+ it("calls drizzle without schema when none is given", () => {
213
+ createDirectDatabaseConnection(connStr);
214
+
215
+ expect(mockDrizzle).toHaveBeenCalledWith(lastPool());
216
+ });
217
+
218
+ it("calls drizzle with schema when given", () => {
219
+ const schema = { sessions: {} };
220
+ createDirectDatabaseConnection(connStr, schema);
221
+
222
+ expect(mockDrizzle).toHaveBeenCalledWith(lastPool(), { schema });
223
+ });
224
+ });
225
+
226
+ describe("createReadReplicaConnection", () => {
227
+ const connStr = "postgresql://readonly:pass@replica:5432/replica_db";
228
+
229
+ it("returns an object with db, pool, and connectionString", () => {
230
+ const result = createReadReplicaConnection(connStr);
231
+
232
+ expect(result).toHaveProperty("db");
233
+ expect(result).toHaveProperty("pool");
234
+ expect(result).toHaveProperty("connectionString", connStr);
235
+ });
236
+
237
+ it("uses a default max of 10 for the replica pool", () => {
238
+ createReadReplicaConnection(connStr);
239
+ const cfg = lastPool().config;
240
+
241
+ expect(cfg.max).toBe(10);
242
+ });
243
+
244
+ it("allows overriding the default max on the replica pool", () => {
245
+ createReadReplicaConnection(connStr, undefined, { max: 25 });
246
+
247
+ expect(lastPool().config.max).toBe(25);
248
+ });
249
+
250
+ it("registers an error handler on the pool", () => {
251
+ createReadReplicaConnection(connStr);
252
+ const pool = lastPool();
253
+
254
+ expect(pool.on).toHaveBeenCalledWith("error", expect.any(Function));
255
+ expect(pool.handlers["error"]).toHaveLength(1);
256
+ });
257
+
258
+ it("error handler logs with [pg-replica-pool] prefix", () => {
259
+ createReadReplicaConnection(connStr);
260
+ const handler = lastPool().handlers["error"][0];
261
+
262
+ handler(new Error("replica error"));
263
+
264
+ expect(logger.error).toHaveBeenCalledWith(
265
+ "[pg-replica-pool] Unexpected pool error",
266
+ expect.objectContaining({ detail: "replica error" })
267
+ );
268
+ });
269
+
270
+ it("inherits remaining default pool settings", () => {
271
+ createReadReplicaConnection(connStr);
272
+ const cfg = lastPool().config;
273
+
274
+ expect(cfg.idleTimeoutMillis).toBe(30_000);
275
+ expect(cfg.connectionTimeoutMillis).toBe(10_000);
276
+ expect(cfg.keepAlive).toBe(true);
277
+ expect(cfg.keepAliveInitialDelayMillis).toBe(0);
278
+ });
279
+
280
+ it("calls drizzle without schema when none is given", () => {
281
+ createReadReplicaConnection(connStr);
282
+
283
+ expect(mockDrizzle).toHaveBeenCalledWith(lastPool());
284
+ });
285
+
286
+ it("calls drizzle with schema when given", () => {
287
+ const schema = { analytics: {} };
288
+ createReadReplicaConnection(connStr, schema);
289
+
290
+ expect(mockDrizzle).toHaveBeenCalledWith(lastPool(), { schema });
291
+ });
292
+ });
@@ -0,0 +1,289 @@
1
+ import { DatabasePoolManager } from "../src/databasePoolManager";
2
+
3
+ // ── Mock pg.Pool ──────────────────────────────────────────────────────────────
4
+ // Each `new Pool(...)` call returns a fresh mock with `.end()` and `.on()`.
5
+ const createMockPool = () => ({
6
+ end: jest.fn().mockResolvedValue(undefined),
7
+ on: jest.fn()
8
+ });
9
+
10
+ let mockPoolInstances: ReturnType<typeof createMockPool>[] = [];
11
+
12
+ jest.mock("pg", () => ({
13
+ Pool: jest.fn().mockImplementation(() => {
14
+ const pool = createMockPool();
15
+ mockPoolInstances.push(pool);
16
+ return pool;
17
+ })
18
+ }));
19
+
20
+ // ── Mock drizzle ──────────────────────────────────────────────────────────────
21
+ jest.mock("drizzle-orm/node-postgres", () => ({
22
+ drizzle: jest.fn().mockImplementation(() => ({
23
+ __drizzleMock: true
24
+ }))
25
+ }));
26
+
27
+ // ── Mock logger ───────────────────────────────────────────────────────────────
28
+ jest.mock("@rebasepro/server-core", () => ({
29
+ logger: {
30
+ info: jest.fn(),
31
+ warn: jest.fn(),
32
+ error: jest.fn()
33
+ }
34
+ }));
35
+
36
+ const VALID_CONNECTION_STRING = "postgresql://user:pass@localhost:5432/mydb";
37
+
38
+ describe("DatabasePoolManager", () => {
39
+ beforeEach(() => {
40
+ jest.clearAllMocks();
41
+ mockPoolInstances = [];
42
+ });
43
+
44
+ // ── Constructor ────────────────────────────────────────────────────────
45
+ describe("constructor", () => {
46
+ it("should extract the default database name from the connection string", () => {
47
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
48
+ expect(manager.defaultDatabaseName).toBe("mydb");
49
+ });
50
+
51
+ it("should handle connection strings with paths containing multiple segments", () => {
52
+ const manager = new DatabasePoolManager(
53
+ "postgresql://user:pass@localhost:5432/production_db"
54
+ );
55
+ expect(manager.defaultDatabaseName).toBe("production_db");
56
+ });
57
+
58
+ it("should throw on an invalid connection string", () => {
59
+ expect(() => new DatabasePoolManager("not-a-url")).toThrow(
60
+ /Invalid adminConnectionString/
61
+ );
62
+ });
63
+ });
64
+
65
+ // ── getPool – caching ──────────────────────────────────────────────────
66
+ describe("getPool", () => {
67
+ it("should return the same pool instance for the same database name", () => {
68
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
69
+
70
+ const pool1 = manager.getPool("testdb");
71
+ const pool2 = manager.getPool("testdb");
72
+
73
+ expect(pool1).toBe(pool2);
74
+ // Only one Pool should have been constructed
75
+ expect(mockPoolInstances).toHaveLength(1);
76
+ });
77
+
78
+ it("should create separate pool instances for different database names", () => {
79
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
80
+
81
+ const poolA = manager.getPool("db_alpha");
82
+ const poolB = manager.getPool("db_beta");
83
+
84
+ expect(poolA).not.toBe(poolB);
85
+ expect(mockPoolInstances).toHaveLength(2);
86
+ });
87
+
88
+ it("should register an error handler on newly created pools", () => {
89
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
90
+ manager.getPool("errdb");
91
+
92
+ const pool = mockPoolInstances[0];
93
+ expect(pool.on).toHaveBeenCalledWith("error", expect.any(Function));
94
+ });
95
+
96
+ it("should build the connection string using the requested database name", () => {
97
+ const { Pool: PoolCtor } = jest.requireMock("pg") as {
98
+ Pool: jest.Mock;
99
+ };
100
+
101
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
102
+ manager.getPool("custom_db");
103
+
104
+ const configArg = PoolCtor.mock.calls[0][0] as {
105
+ connectionString: string;
106
+ };
107
+ expect(configArg.connectionString).toContain("/custom_db");
108
+ });
109
+ });
110
+
111
+ // ── hasPool ────────────────────────────────────────────────────────────
112
+ describe("hasPool", () => {
113
+ it("should return false when no pool exists for the given name", () => {
114
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
115
+ expect(manager.hasPool("nonexistent")).toBe(false);
116
+ });
117
+
118
+ it("should return true after a pool has been created", () => {
119
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
120
+ manager.getPool("exists");
121
+ expect(manager.hasPool("exists")).toBe(true);
122
+ });
123
+ });
124
+
125
+ // ── getDrizzle ─────────────────────────────────────────────────────────
126
+ describe("getDrizzle", () => {
127
+ it("should return a drizzle instance for a given database name", () => {
128
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
129
+ const db = manager.getDrizzle("appdb");
130
+
131
+ expect(db).toBeDefined();
132
+ expect((db as unknown as { __drizzleMock: boolean }).__drizzleMock).toBe(true);
133
+ });
134
+
135
+ it("should cache and reuse the drizzle instance for the same database name", () => {
136
+ const { drizzle } = jest.requireMock("drizzle-orm/node-postgres") as {
137
+ drizzle: jest.Mock;
138
+ };
139
+
140
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
141
+ const db1 = manager.getDrizzle("cached");
142
+ const db2 = manager.getDrizzle("cached");
143
+
144
+ expect(db1).toBe(db2);
145
+ // drizzle() should only have been called once for this database
146
+ expect(drizzle).toHaveBeenCalledTimes(1);
147
+ });
148
+
149
+ it("should create different drizzle instances for different databases", () => {
150
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
151
+ const dbA = manager.getDrizzle("first");
152
+ const dbB = manager.getDrizzle("second");
153
+
154
+ expect(dbA).not.toBe(dbB);
155
+ });
156
+ });
157
+
158
+ // ── disconnectDatabase ─────────────────────────────────────────────────
159
+ describe("disconnectDatabase", () => {
160
+ it("should call pool.end() and remove the pool from cache", async () => {
161
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
162
+ manager.getPool("dropme");
163
+
164
+ expect(manager.hasPool("dropme")).toBe(true);
165
+ const pool = mockPoolInstances[0];
166
+
167
+ await manager.disconnectDatabase("dropme");
168
+
169
+ expect(pool.end).toHaveBeenCalledTimes(1);
170
+ expect(manager.hasPool("dropme")).toBe(false);
171
+ });
172
+
173
+ it("should also clear the cached drizzle instance", async () => {
174
+ const { drizzle } = jest.requireMock("drizzle-orm/node-postgres") as {
175
+ drizzle: jest.Mock;
176
+ };
177
+
178
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
179
+ manager.getDrizzle("dropme");
180
+ drizzle.mockClear();
181
+
182
+ await manager.disconnectDatabase("dropme");
183
+
184
+ // After disconnect, getDrizzle should create a fresh instance
185
+ manager.getDrizzle("dropme");
186
+ expect(drizzle).toHaveBeenCalledTimes(1);
187
+ });
188
+
189
+ it("should not throw when disconnecting a non-existent database", async () => {
190
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
191
+ // Should complete without error
192
+ await expect(
193
+ manager.disconnectDatabase("ghost")
194
+ ).resolves.toBeUndefined();
195
+ });
196
+ });
197
+
198
+ // ── shutdown ────────────────────────────────────────────────────────────
199
+ describe("shutdown", () => {
200
+ it("should call pool.end() on every cached pool", async () => {
201
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
202
+ manager.getPool("a");
203
+ manager.getPool("b");
204
+ manager.getPool("c");
205
+
206
+ await manager.shutdown();
207
+
208
+ for (const pool of mockPoolInstances) {
209
+ expect(pool.end).toHaveBeenCalledTimes(1);
210
+ }
211
+ });
212
+
213
+ it("should clear the pool cache after shutdown", async () => {
214
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
215
+ manager.getPool("x");
216
+ manager.getPool("y");
217
+
218
+ await manager.shutdown();
219
+
220
+ expect(manager.hasPool("x")).toBe(false);
221
+ expect(manager.hasPool("y")).toBe(false);
222
+ });
223
+
224
+ it("should clear drizzle instances after shutdown", async () => {
225
+ const { drizzle } = jest.requireMock("drizzle-orm/node-postgres") as {
226
+ drizzle: jest.Mock;
227
+ };
228
+
229
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
230
+ manager.getDrizzle("z");
231
+ drizzle.mockClear();
232
+
233
+ await manager.shutdown();
234
+
235
+ // A fresh drizzle call should be needed after shutdown
236
+ manager.getDrizzle("z");
237
+ expect(drizzle).toHaveBeenCalledTimes(1);
238
+ });
239
+
240
+ it("should not throw when called on a manager with no pools", async () => {
241
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
242
+ await expect(manager.shutdown()).resolves.toBeUndefined();
243
+ });
244
+
245
+ it("should not throw when called multiple times", async () => {
246
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
247
+ manager.getPool("once");
248
+
249
+ await manager.shutdown();
250
+ await expect(manager.shutdown()).resolves.toBeUndefined();
251
+ });
252
+
253
+ it("should handle a pool.end() rejection gracefully (Promise.all rejects)", async () => {
254
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
255
+ manager.getPool("bad");
256
+
257
+ const pool = mockPoolInstances[0];
258
+ pool.end.mockRejectedValue(new Error("connection already closed"));
259
+
260
+ await expect(manager.shutdown()).rejects.toThrow(
261
+ "connection already closed"
262
+ );
263
+ });
264
+ });
265
+
266
+ // ── Pool re-creation after disconnect ──────────────────────────────────
267
+ describe("pool re-creation after disconnect", () => {
268
+ it("should create a fresh pool after disconnecting a database", async () => {
269
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
270
+ const poolBefore = manager.getPool("recreate");
271
+
272
+ await manager.disconnectDatabase("recreate");
273
+ const poolAfter = manager.getPool("recreate");
274
+
275
+ expect(poolBefore).not.toBe(poolAfter);
276
+ expect(mockPoolInstances).toHaveLength(2);
277
+ });
278
+
279
+ it("should create fresh pools after shutdown", async () => {
280
+ const manager = new DatabasePoolManager(VALID_CONNECTION_STRING);
281
+ const poolBefore = manager.getPool("revive");
282
+
283
+ await manager.shutdown();
284
+ const poolAfter = manager.getPool("revive");
285
+
286
+ expect(poolBefore).not.toBe(poolAfter);
287
+ });
288
+ });
289
+ });