@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.
- package/package.json +24 -18
- package/src/PostgresBackendDriver.ts +65 -44
- package/src/PostgresBootstrapper.ts +49 -20
- package/src/auth/ensure-tables.ts +56 -1
- package/src/auth/services.ts +94 -1
- package/src/cli-errors.ts +162 -0
- package/src/cli-helpers.ts +183 -0
- package/src/cli.ts +198 -251
- package/src/data-transformer.ts +9 -1
- package/src/schema/auth-default-policies.ts +90 -0
- package/src/schema/auth-schema.ts +25 -2
- package/src/schema/doctor.ts +2 -4
- package/src/schema/generate-drizzle-schema-logic.ts +4 -3
- package/src/schema/generate-drizzle-schema.ts +3 -5
- package/src/schema/generate-postgres-ddl-logic.ts +524 -0
- package/src/schema/generate-postgres-ddl.ts +116 -0
- package/src/services/EntityPersistService.ts +18 -16
- package/src/services/entityService.ts +28 -3
- package/src/utils/pg-array-null-patch.ts +42 -0
- package/src/utils/pg-error-utils.ts +16 -0
- package/src/utils/table-classification.ts +16 -0
- package/src/websocket.ts +9 -0
- package/test/array-null-safety.test.ts +335 -0
- package/test/auth-default-policies.test.ts +89 -0
- package/test/cli-helpers-extended.test.ts +324 -0
- package/test/cli-helpers.test.ts +59 -0
- package/test/connection.test.ts +292 -0
- package/test/data-transformer.test.ts +53 -2
- package/test/databasePoolManager.test.ts +289 -0
- package/test/doctor-extended.test.ts +443 -0
- package/test/e2e/db-e2e.test.ts +293 -0
- package/test/e2e/pg-setup.ts +79 -0
- package/test/entity-persist-composite-keys.test.ts +451 -0
- package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
- package/test/generate-postgres-ddl.test.ts +300 -0
- package/test/mfa-service.test.ts +544 -0
- package/test/pg-array-null-patch.test.ts +65 -0
- package/test/pg-error-utils.test.ts +50 -1
- package/test/realtimeService-channels.test.ts +696 -0
- package/test/unmapped-tables-safety.test.ts +55 -342
- package/vite.config.ts +8 -6
- package/vitest.e2e.config.ts +10 -0
- package/build-errors.txt +0 -37
- package/dist/PostgresAdapter.d.ts +0 -6
- package/dist/PostgresBackendDriver.d.ts +0 -110
- package/dist/PostgresBootstrapper.d.ts +0 -46
- package/dist/auth/ensure-tables.d.ts +0 -10
- package/dist/auth/services.d.ts +0 -231
- package/dist/cli.d.ts +0 -1
- package/dist/collections/PostgresCollectionRegistry.d.ts +0 -47
- package/dist/connection.d.ts +0 -65
- package/dist/data-transformer.d.ts +0 -55
- package/dist/databasePoolManager.d.ts +0 -20
- package/dist/history/HistoryService.d.ts +0 -71
- package/dist/history/ensure-history-table.d.ts +0 -7
- package/dist/index.d.ts +0 -14
- package/dist/index.es.js +0 -10764
- package/dist/index.es.js.map +0 -1
- package/dist/index.umd.js +0 -11055
- package/dist/index.umd.js.map +0 -1
- package/dist/interfaces.d.ts +0 -18
- package/dist/schema/auth-schema.d.ts +0 -2149
- package/dist/schema/doctor-cli.d.ts +0 -2
- package/dist/schema/doctor.d.ts +0 -52
- package/dist/schema/generate-drizzle-schema-logic.d.ts +0 -2
- package/dist/schema/generate-drizzle-schema.d.ts +0 -1
- package/dist/schema/introspect-db-inference.d.ts +0 -5
- package/dist/schema/introspect-db-logic.d.ts +0 -118
- package/dist/schema/introspect-db.d.ts +0 -1
- package/dist/schema/test-schema.d.ts +0 -24
- package/dist/services/BranchService.d.ts +0 -47
- package/dist/services/EntityFetchService.d.ts +0 -214
- package/dist/services/EntityPersistService.d.ts +0 -40
- package/dist/services/RelationService.d.ts +0 -98
- package/dist/services/entity-helpers.d.ts +0 -38
- package/dist/services/entityService.d.ts +0 -110
- package/dist/services/index.d.ts +0 -4
- package/dist/services/realtimeService.d.ts +0 -220
- package/dist/types.d.ts +0 -3
- package/dist/utils/drizzle-conditions.d.ts +0 -138
- package/dist/utils/pg-error-utils.d.ts +0 -55
- 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
|
+
});
|