@rebasepro/server-postgresql 0.6.0 → 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 (82) hide show
  1. package/package.json +24 -18
  2. package/src/PostgresBackendDriver.ts +65 -44
  3. package/src/PostgresBootstrapper.ts +49 -20
  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/data-transformer.ts +9 -1
  10. package/src/schema/auth-default-policies.ts +90 -0
  11. package/src/schema/auth-schema.ts +25 -2
  12. package/src/schema/doctor.ts +2 -4
  13. package/src/schema/generate-drizzle-schema-logic.ts +4 -3
  14. package/src/schema/generate-drizzle-schema.ts +3 -5
  15. package/src/schema/generate-postgres-ddl-logic.ts +524 -0
  16. package/src/schema/generate-postgres-ddl.ts +116 -0
  17. package/src/services/EntityPersistService.ts +18 -16
  18. package/src/services/entityService.ts +28 -3
  19. package/src/utils/pg-array-null-patch.ts +42 -0
  20. package/src/utils/pg-error-utils.ts +16 -0
  21. package/src/utils/table-classification.ts +16 -0
  22. package/src/websocket.ts +9 -0
  23. package/test/array-null-safety.test.ts +335 -0
  24. package/test/auth-default-policies.test.ts +89 -0
  25. package/test/cli-helpers-extended.test.ts +324 -0
  26. package/test/cli-helpers.test.ts +59 -0
  27. package/test/connection.test.ts +292 -0
  28. package/test/data-transformer.test.ts +53 -2
  29. package/test/databasePoolManager.test.ts +289 -0
  30. package/test/doctor-extended.test.ts +443 -0
  31. package/test/e2e/db-e2e.test.ts +293 -0
  32. package/test/e2e/pg-setup.ts +79 -0
  33. package/test/entity-persist-composite-keys.test.ts +451 -0
  34. package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
  35. package/test/generate-postgres-ddl.test.ts +300 -0
  36. package/test/mfa-service.test.ts +544 -0
  37. package/test/pg-array-null-patch.test.ts +65 -0
  38. package/test/pg-error-utils.test.ts +50 -1
  39. package/test/realtimeService-channels.test.ts +696 -0
  40. package/test/unmapped-tables-safety.test.ts +55 -342
  41. package/vite.config.ts +8 -6
  42. package/vitest.e2e.config.ts +10 -0
  43. package/build-errors.txt +0 -37
  44. package/dist/PostgresAdapter.d.ts +0 -6
  45. package/dist/PostgresBackendDriver.d.ts +0 -110
  46. package/dist/PostgresBootstrapper.d.ts +0 -46
  47. package/dist/auth/ensure-tables.d.ts +0 -10
  48. package/dist/auth/services.d.ts +0 -231
  49. package/dist/cli.d.ts +0 -1
  50. package/dist/collections/PostgresCollectionRegistry.d.ts +0 -47
  51. package/dist/connection.d.ts +0 -65
  52. package/dist/data-transformer.d.ts +0 -55
  53. package/dist/databasePoolManager.d.ts +0 -20
  54. package/dist/history/HistoryService.d.ts +0 -71
  55. package/dist/history/ensure-history-table.d.ts +0 -7
  56. package/dist/index.d.ts +0 -14
  57. package/dist/index.es.js +0 -10764
  58. package/dist/index.es.js.map +0 -1
  59. package/dist/index.umd.js +0 -11055
  60. package/dist/index.umd.js.map +0 -1
  61. package/dist/interfaces.d.ts +0 -18
  62. package/dist/schema/auth-schema.d.ts +0 -2149
  63. package/dist/schema/doctor-cli.d.ts +0 -2
  64. package/dist/schema/doctor.d.ts +0 -52
  65. package/dist/schema/generate-drizzle-schema-logic.d.ts +0 -2
  66. package/dist/schema/generate-drizzle-schema.d.ts +0 -1
  67. package/dist/schema/introspect-db-inference.d.ts +0 -5
  68. package/dist/schema/introspect-db-logic.d.ts +0 -118
  69. package/dist/schema/introspect-db.d.ts +0 -1
  70. package/dist/schema/test-schema.d.ts +0 -24
  71. package/dist/services/BranchService.d.ts +0 -47
  72. package/dist/services/EntityFetchService.d.ts +0 -214
  73. package/dist/services/EntityPersistService.d.ts +0 -40
  74. package/dist/services/RelationService.d.ts +0 -98
  75. package/dist/services/entity-helpers.d.ts +0 -38
  76. package/dist/services/entityService.d.ts +0 -110
  77. package/dist/services/index.d.ts +0 -4
  78. package/dist/services/realtimeService.d.ts +0 -220
  79. package/dist/types.d.ts +0 -3
  80. package/dist/utils/drizzle-conditions.d.ts +0 -138
  81. package/dist/utils/pg-error-utils.d.ts +0 -55
  82. package/dist/websocket.d.ts +0 -11
@@ -0,0 +1,324 @@
1
+ import { getDevDatabaseUrl, getTableIncludesFromCollections } from "../src/cli-helpers";
2
+ import { EntityCollection } from "@rebasepro/types";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { execSync } from "child_process";
6
+
7
+ // ── Mock pg globally so dynamic import("pg") resolves to our mock ────────
8
+
9
+ const mockQuery = jest.fn();
10
+ const mockConnect = jest.fn();
11
+ const mockEnd = jest.fn();
12
+
13
+ jest.mock("pg", () => ({
14
+ Client: jest.fn().mockImplementation(() => ({
15
+ connect: mockConnect,
16
+ query: mockQuery,
17
+ end: mockEnd,
18
+ })),
19
+ }));
20
+
21
+ describe("CLI Helpers — Extended", () => {
22
+
23
+ beforeEach(() => {
24
+ jest.clearAllMocks();
25
+ });
26
+
27
+ // ── getDevDatabaseUrl ────────────────────────────────────────────────
28
+
29
+ describe("getDevDatabaseUrl", () => {
30
+
31
+ it("should handle password with special characters (@ in password)", () => {
32
+ // The URL class percent-encodes special chars, so the output may differ slightly
33
+ const dbUrl = "postgresql://user:p%40ss@localhost:5432/mydb";
34
+ const result = getDevDatabaseUrl(dbUrl);
35
+ expect(result).toContain("mydb_dev_diff");
36
+ expect(result).toContain("p%40ss");
37
+ expect(result).toContain("localhost:5432");
38
+ });
39
+
40
+ it("should handle password with encoded special characters", () => {
41
+ const dbUrl = "postgresql://admin:p%23word%21@db.host.com:5432/production";
42
+ const result = getDevDatabaseUrl(dbUrl);
43
+ expect(result).toContain("production_dev_diff");
44
+ expect(result).toContain("p%23word%21");
45
+ });
46
+
47
+ it("should handle URL with multiple query parameters", () => {
48
+ const dbUrl = "postgresql://user:pass@localhost:5432/testdb?sslmode=require&connect_timeout=10";
49
+ const result = getDevDatabaseUrl(dbUrl);
50
+ expect(result).toBe("postgresql://user:pass@localhost:5432/testdb_dev_diff?sslmode=require&connect_timeout=10");
51
+ });
52
+
53
+ it("should handle URL without port", () => {
54
+ const dbUrl = "postgresql://user:pass@localhost/mydb";
55
+ const result = getDevDatabaseUrl(dbUrl);
56
+ expect(result).toContain("mydb_dev_diff");
57
+ });
58
+
59
+ it("should handle URL with path containing only the database name", () => {
60
+ const dbUrl = "postgres://u:p@host:5432/db_name";
61
+ const result = getDevDatabaseUrl(dbUrl);
62
+ expect(result).toContain("db_name_dev_diff");
63
+ });
64
+
65
+ it("should fall back to concatenation for totally invalid URLs", () => {
66
+ expect(getDevDatabaseUrl("")).toBe("_dev_diff");
67
+ expect(getDevDatabaseUrl("just-some-text")).toBe("just-some-text_dev_diff");
68
+ });
69
+ });
70
+
71
+ // ── resolveLocalBin ─────────────────────────────────────────────────
72
+
73
+ describe("resolveLocalBin", () => {
74
+ // We test this function by using real fs — we just pick a binary
75
+ // that is known to exist (like "jest") vs. one that doesn't.
76
+
77
+ it("should find a binary that exists in node_modules/.bin", () => {
78
+ // "jest" should be installed in the monorepo node_modules
79
+ const { resolveLocalBin } = require("../src/cli-helpers");
80
+ const result = resolveLocalBin("jest");
81
+ // Should either find it or return null (depending on install structure)
82
+ // We just check the contract: string | null
83
+ if (result !== null) {
84
+ expect(typeof result).toBe("string");
85
+ expect(result).toContain("jest");
86
+ }
87
+ });
88
+
89
+ it("should return null for a binary that definitely does not exist", () => {
90
+ const { resolveLocalBin } = require("../src/cli-helpers");
91
+ const result = resolveLocalBin("__nonexistent_binary_xyz_12345__");
92
+ expect(result).toBeNull();
93
+ });
94
+ });
95
+
96
+ // ── ensureDevDatabaseExists ─────────────────────────────────────────
97
+
98
+ describe("ensureDevDatabaseExists", () => {
99
+
100
+ it("should not create database when it already exists", async () => {
101
+ mockConnect.mockResolvedValue(undefined);
102
+ mockEnd.mockResolvedValue(undefined);
103
+ mockQuery.mockResolvedValue({ rowCount: 1, rows: [{ "?column?": 1 }] });
104
+
105
+ const { ensureDevDatabaseExists } = require("../src/cli-helpers");
106
+ await ensureDevDatabaseExists(
107
+ "postgresql://user:pass@localhost:5432/mydb",
108
+ "postgresql://user:pass@localhost:5432/mydb_dev_diff"
109
+ );
110
+
111
+ expect(mockConnect).toHaveBeenCalledTimes(1);
112
+ // Should query pg_database
113
+ expect(mockQuery).toHaveBeenCalledWith(
114
+ "SELECT 1 FROM pg_database WHERE datname = $1",
115
+ ["mydb_dev_diff"]
116
+ );
117
+ // Should NOT issue CREATE DATABASE since rowCount is 1
118
+ expect(mockQuery).toHaveBeenCalledTimes(1);
119
+ expect(mockEnd).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ it("should create database when it does not exist", async () => {
123
+ mockConnect.mockResolvedValue(undefined);
124
+ mockEnd.mockResolvedValue(undefined);
125
+ mockQuery
126
+ .mockResolvedValueOnce({ rowCount: 0, rows: [] }) // SELECT 1 FROM pg_database
127
+ .mockResolvedValueOnce(undefined); // CREATE DATABASE
128
+
129
+ const { ensureDevDatabaseExists } = require("../src/cli-helpers");
130
+ await ensureDevDatabaseExists(
131
+ "postgresql://user:pass@localhost:5432/mydb",
132
+ "postgresql://user:pass@localhost:5432/mydb_dev_diff"
133
+ );
134
+
135
+ expect(mockQuery).toHaveBeenCalledTimes(2);
136
+ expect(mockQuery).toHaveBeenNthCalledWith(1,
137
+ "SELECT 1 FROM pg_database WHERE datname = $1",
138
+ ["mydb_dev_diff"]
139
+ );
140
+ expect(mockQuery).toHaveBeenNthCalledWith(2,
141
+ 'CREATE DATABASE "mydb_dev_diff"'
142
+ );
143
+ });
144
+
145
+ it("should silently catch connection errors", async () => {
146
+ mockConnect.mockRejectedValue(new Error("connection refused"));
147
+
148
+ const { ensureDevDatabaseExists } = require("../src/cli-helpers");
149
+ // Should not throw
150
+ await expect(
151
+ ensureDevDatabaseExists(
152
+ "postgresql://user:pass@localhost:5432/mydb",
153
+ "postgresql://user:pass@localhost:5432/mydb_dev_diff"
154
+ )
155
+ ).resolves.toBeUndefined();
156
+ });
157
+ });
158
+
159
+ // ── getTableExcludes ────────────────────────────────────────────────
160
+
161
+ describe("getTableExcludes (via getTableIncludesFromCollections path)", () => {
162
+
163
+ it("should always include atlas_schema_revisions.* in excludes", async () => {
164
+ // We test the logic indirectly: getTableExcludes calls getTableIncludes
165
+ // then queries the DB. We mock pg to return known tables.
166
+
167
+ mockConnect.mockResolvedValue(undefined);
168
+ mockEnd.mockResolvedValue(undefined);
169
+ mockQuery.mockResolvedValue({
170
+ rows: [
171
+ { full_name: "public.users" },
172
+ { full_name: "public.sessions" },
173
+ { full_name: "public.migrations" },
174
+ ],
175
+ });
176
+
177
+ // We can't easily mock getTableIncludes (it does dynamic imports of collection files)
178
+ // So we test the subcomponent: getTableIncludesFromCollections
179
+ const collections: EntityCollection[] = [
180
+ {
181
+ slug: "users",
182
+ table: "users",
183
+ name: "Users",
184
+ properties: { name: { type: "string" } },
185
+ },
186
+ ];
187
+
188
+ const includes = await getTableIncludesFromCollections(collections);
189
+ expect(includes).toContain("public.users");
190
+ expect(includes).not.toContain("public.sessions");
191
+ });
192
+ });
193
+
194
+ // ── getTableIncludesFromCollections ──────────────────────────────────
195
+
196
+ describe("getTableIncludesFromCollections", () => {
197
+
198
+ it("should return empty array for empty collections", async () => {
199
+ const includes = await getTableIncludesFromCollections([]);
200
+ expect(includes).toEqual([]);
201
+ });
202
+
203
+ it("should handle collection with custom schema", async () => {
204
+ const collections: EntityCollection[] = [
205
+ {
206
+ slug: "orders",
207
+ table: "orders",
208
+ name: "Orders",
209
+ schema: "sales",
210
+ properties: { total: { type: "number" } },
211
+ },
212
+ ];
213
+
214
+ const includes = await getTableIncludesFromCollections(collections);
215
+ expect(includes).toContain("sales.orders");
216
+ });
217
+
218
+ it("should deduplicate table names", async () => {
219
+ const collections: EntityCollection[] = [
220
+ {
221
+ slug: "users",
222
+ table: "users",
223
+ name: "Users",
224
+ properties: { name: { type: "string" } },
225
+ },
226
+ {
227
+ slug: "users_alias",
228
+ table: "users", // same table
229
+ name: "Users Alias",
230
+ properties: { email: { type: "string" } },
231
+ },
232
+ ];
233
+
234
+ const includes = await getTableIncludesFromCollections(collections);
235
+ // "public.users" should appear only once
236
+ const userEntries = includes.filter(i => i === "public.users");
237
+ expect(userEntries).toHaveLength(1);
238
+ });
239
+
240
+ it("should include junction tables from many-to-many relations", async () => {
241
+ const tagsCollection: EntityCollection = {
242
+ slug: "tags",
243
+ table: "tags",
244
+ name: "Tags",
245
+ properties: { name: { type: "string" } },
246
+ };
247
+
248
+ const postsCollection: EntityCollection = {
249
+ slug: "posts",
250
+ table: "posts",
251
+ name: "Posts",
252
+ properties: { title: { type: "string" } },
253
+ relations: [
254
+ {
255
+ relationName: "tags",
256
+ target: () => tagsCollection,
257
+ cardinality: "many",
258
+ direction: "owning",
259
+ through: {
260
+ table: "posts_to_tags",
261
+ sourceColumn: "post_id",
262
+ targetColumn: "tag_id",
263
+ },
264
+ },
265
+ ],
266
+ };
267
+
268
+ const includes = await getTableIncludesFromCollections([postsCollection, tagsCollection]);
269
+ expect(includes).toContain("public.posts");
270
+ expect(includes).toContain("public.tags");
271
+ expect(includes).toContain("public.posts_to_tags");
272
+ });
273
+
274
+ it("should include junction tables with custom schema from target collection", async () => {
275
+ const categoriesCollection: EntityCollection = {
276
+ slug: "categories",
277
+ table: "categories",
278
+ name: "Categories",
279
+ schema: "catalog",
280
+ properties: { name: { type: "string" } },
281
+ };
282
+
283
+ const productsCollection: EntityCollection = {
284
+ slug: "products",
285
+ table: "products",
286
+ name: "Products",
287
+ properties: { title: { type: "string" } },
288
+ relations: [
289
+ {
290
+ relationName: "categories",
291
+ target: () => categoriesCollection,
292
+ cardinality: "many",
293
+ direction: "owning",
294
+ through: {
295
+ table: "products_categories",
296
+ sourceColumn: "product_id",
297
+ targetColumn: "category_id",
298
+ },
299
+ },
300
+ ],
301
+ };
302
+
303
+ const includes = await getTableIncludesFromCollections([productsCollection, categoriesCollection]);
304
+ expect(includes).toContain("public.products");
305
+ expect(includes).toContain("catalog.categories");
306
+ // Junction table uses the target collection's schema
307
+ expect(includes).toContain("catalog.products_categories");
308
+ });
309
+
310
+ it("should handle collection without explicit table (uses slug)", async () => {
311
+ const collections: EntityCollection[] = [
312
+ {
313
+ slug: "customers",
314
+ name: "Customers",
315
+ properties: { name: { type: "string" } },
316
+ },
317
+ ];
318
+
319
+ const includes = await getTableIncludesFromCollections(collections);
320
+ // getTableName falls back to slug → "customers"
321
+ expect(includes).toContain("public.customers");
322
+ });
323
+ });
324
+ });
@@ -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
+ });