@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,451 @@
1
+ import { EntityService } from "../src/services/entityService";
2
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
+ import { EntityCollection } from "@rebasepro/types";
4
+ import { PostgresCollectionRegistry } from "../src/collections/PostgresCollectionRegistry";
5
+
6
+ const collectionRegistry = new PostgresCollectionRegistry();
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Mock Drizzle table definitions
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /**
13
+ * Table with two primary keys – used to expose the composite-key delete bug.
14
+ * The `primary: true` flag makes getPrimaryKeys() discover both columns as PKs.
15
+ */
16
+ const mockProjectUsersTable = {
17
+ project_id: { name: "project_id", primary: true },
18
+ user_id: { name: "user_id", primary: true },
19
+ role: { name: "role" },
20
+ _def: { tableName: "project_users" }
21
+ };
22
+
23
+ /** Single-PK table for count / pagination tests */
24
+ const mockItemsTable = {
25
+ id: { name: "id", dataType: "number" },
26
+ name: { name: "name" },
27
+ description: { name: "description" },
28
+ _def: { tableName: "items" }
29
+ };
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Collection definitions
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const projectUsersCollection: EntityCollection = {
36
+ slug: "project_users",
37
+ name: "Project Users",
38
+ table: "project_users",
39
+ properties: {
40
+ project_id: { type: "string" },
41
+ user_id: { type: "string" },
42
+ role: { type: "string" }
43
+ },
44
+ primaryKeys: ["project_id", "user_id"]
45
+ };
46
+
47
+ const itemsCollection: EntityCollection = {
48
+ slug: "items",
49
+ name: "Items",
50
+ table: "items",
51
+ properties: {
52
+ id: { type: "number" },
53
+ name: { type: "string" },
54
+ description: { type: "string" }
55
+ },
56
+ idField: "id"
57
+ };
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Helpers
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /** Build the standard chainable mock db used throughout the existing test suite. */
64
+ function createMockDb() {
65
+ const db = {
66
+ select: jest.fn().mockReturnThis(),
67
+ from: jest.fn().mockReturnThis(),
68
+ where: jest.fn().mockReturnThis(),
69
+ $dynamic: jest.fn().mockReturnThis(),
70
+ limit: jest.fn().mockReturnThis(),
71
+ orderBy: jest.fn().mockReturnThis(),
72
+ offset: jest.fn().mockReturnThis(),
73
+ innerJoin: jest.fn().mockReturnThis(),
74
+ insert: jest.fn().mockReturnThis(),
75
+ values: jest.fn().mockReturnThis(),
76
+ returning: jest.fn().mockResolvedValue([]),
77
+ update: jest.fn().mockReturnThis(),
78
+ set: jest.fn().mockReturnThis(),
79
+ delete: jest.fn().mockReturnThis(),
80
+ transaction: jest.fn((callback: (tx: unknown) => unknown) => callback(db))
81
+ } as unknown as jest.Mocked<NodePgDatabase>;
82
+
83
+ // Make terminal operations thenable so they resolve when awaited
84
+ (db as unknown as Record<string, jest.Mock>).then = jest.fn((resolve: (v: unknown[]) => void) => resolve([]));
85
+
86
+ return db;
87
+ }
88
+
89
+ function setupRegistryMocks() {
90
+ jest.spyOn(collectionRegistry, "getCollectionByPath").mockImplementation(path => {
91
+ if (path.startsWith("project_users")) return projectUsersCollection;
92
+ if (path.startsWith("items")) return itemsCollection;
93
+ return undefined;
94
+ });
95
+
96
+ jest.spyOn(collectionRegistry, "getTable").mockImplementation(tableName => {
97
+ if (tableName === "project_users") return mockProjectUsersTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
98
+ if (tableName === "items") return mockItemsTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
99
+ return undefined;
100
+ });
101
+
102
+ jest.spyOn(collectionRegistry, "getCollections").mockReturnValue([
103
+ projectUsersCollection,
104
+ itemsCollection
105
+ ]);
106
+ }
107
+
108
+ // ===========================================================================
109
+ // 1. COMPOSITE KEY DELETION BUG
110
+ // ===========================================================================
111
+
112
+ describe("EntityPersistService – composite key deleteEntity bug", () => {
113
+ let entityService: EntityService;
114
+ let db: jest.Mocked<NodePgDatabase>;
115
+
116
+ beforeEach(() => {
117
+ db = createMockDb();
118
+ setupRegistryMocks();
119
+ entityService = new EntityService(db, collectionRegistry);
120
+ });
121
+
122
+ afterEach(() => {
123
+ jest.restoreAllMocks();
124
+ });
125
+
126
+ it("should call db.delete for a composite-key entity", async () => {
127
+ await entityService.deleteEntity("project_users", "proj1:::user1");
128
+ expect(db.delete).toHaveBeenCalledWith(mockProjectUsersTable);
129
+ expect(db.where).toHaveBeenCalled();
130
+ });
131
+
132
+ it("REGRESSION: deleteEntity must use ALL primary keys in the WHERE clause", async () => {
133
+ /**
134
+ * There was a bug where `deleteEntity` only used `idInfoArray[0]`
135
+ * for the WHERE clause, meaning only `project_id` was constrained.
136
+ * This would delete ALL rows matching the first key instead of the
137
+ * specific composite-key row.
138
+ *
139
+ * The fix loops over all primary keys and combines them with `and()`,
140
+ * matching what `saveEntity` already does (lines 230-236).
141
+ *
142
+ * This test serves as a regression guard.
143
+ */
144
+
145
+ const whereSpy = db.where as jest.Mock;
146
+ whereSpy.mockClear();
147
+
148
+ await entityService.deleteEntity("project_users", "proj1:::user1");
149
+
150
+ expect(whereSpy).toHaveBeenCalledTimes(1);
151
+
152
+ // Inspect the SQL condition passed to .where().
153
+ // JSON.stringify exposes the Drizzle column references (e.g.
154
+ // { "name": "project_id" }) buried in queryChunks.
155
+ const whereArg = whereSpy.mock.calls[0][0];
156
+ const serialisedCondition = JSON.stringify(whereArg);
157
+
158
+ // Both primary key columns MUST appear in the WHERE clause
159
+ expect(serialisedCondition).toContain("project_id");
160
+ expect(serialisedCondition).toContain("user_id");
161
+
162
+ // The condition must also contain both parsed ID values
163
+ expect(serialisedCondition).toContain("proj1");
164
+ expect(serialisedCondition).toContain("user1");
165
+
166
+ // The condition should be an AND (compound) condition, not a
167
+ // simple eq() — verified by the presence of " and " in the
168
+ // serialised SQL fragments.
169
+ expect(serialisedCondition).toContain(" and ");
170
+ });
171
+
172
+ it("saveEntity correctly uses ALL primary keys in the WHERE clause (control test)", async () => {
173
+ /**
174
+ * This control test proves that `saveEntity` (update path) does
175
+ * build the WHERE clause with ALL composite keys – confirming the
176
+ * asymmetry with `deleteEntity`.
177
+ */
178
+ const mockWhere = jest.fn().mockResolvedValue([{
179
+ project_id: "proj1",
180
+ user_id: "user1",
181
+ role: "admin"
182
+ }]);
183
+ const mockSet = jest.fn().mockReturnValue({ where: mockWhere });
184
+ (db.update as jest.Mock).mockReturnValue({
185
+ set: mockSet
186
+ } as unknown as ReturnType<typeof db.update>);
187
+
188
+ // Mock the fetch-back after save
189
+ db.limit.mockResolvedValue([{
190
+ project_id: "proj1",
191
+ user_id: "user1",
192
+ role: "admin"
193
+ }] as unknown as never);
194
+
195
+ await entityService.saveEntity("project_users", { role: "admin" }, "proj1:::user1");
196
+
197
+ expect(mockWhere).toHaveBeenCalledTimes(1);
198
+
199
+ const saveWhereArg = mockWhere.mock.calls[0][0];
200
+ // JSON.stringify reveals the column name objects buried in
201
+ // queryChunks – e.g. {"name":"project_id"}
202
+ const serialisedSaveCondition = JSON.stringify(saveWhereArg);
203
+
204
+ // saveEntity correctly includes BOTH keys
205
+ const saveHasProjectId = serialisedSaveCondition.includes("project_id");
206
+ const saveHasUserId = serialisedSaveCondition.includes("user_id");
207
+
208
+ expect(saveHasProjectId).toBe(true);
209
+ expect(saveHasUserId).toBe(true);
210
+ });
211
+
212
+ it("deleteEntity should parse composite ID correctly before deletion", async () => {
213
+ // Verifies that calling deleteEntity with a composite ID doesn't
214
+ // throw a parsing error – the ID is split on ":::" and each part
215
+ // is assigned to its corresponding PK field.
216
+ await expect(
217
+ entityService.deleteEntity("project_users", "projA:::userB")
218
+ ).resolves.toBeUndefined();
219
+ });
220
+
221
+ it("deleteEntity should throw for malformed composite IDs", async () => {
222
+ // Only one part instead of two → parseIdValues should reject it
223
+ await expect(
224
+ entityService.deleteEntity("project_users", "only-one-part")
225
+ ).rejects.toThrow("Composite ID parts mismatch");
226
+ });
227
+ });
228
+
229
+ // ===========================================================================
230
+ // 2. CURSOR PAGINATION WITH FILTERS
231
+ // ===========================================================================
232
+
233
+ describe("EntityFetchService – cursor pagination combined with filters", () => {
234
+ let entityService: EntityService;
235
+ let db: jest.Mocked<NodePgDatabase>;
236
+
237
+ beforeEach(() => {
238
+ db = createMockDb();
239
+ setupRegistryMocks();
240
+ entityService = new EntityService(db, collectionRegistry);
241
+ });
242
+
243
+ afterEach(() => {
244
+ jest.restoreAllMocks();
245
+ });
246
+
247
+ it("should call where() when both startAfter cursor and filter are provided", async () => {
248
+ const mockResults = [
249
+ { id: 3, name: "Item C", description: "Third" }
250
+ ];
251
+ (db as unknown as Record<string, jest.Mock>).then = jest.fn((resolve: (v: unknown[]) => void) => resolve(mockResults));
252
+
253
+ await entityService.fetchCollection("items", {
254
+ filter: { name: ["==", "Item C"] },
255
+ startAfter: { id: 5, values: { name: "Item E" } },
256
+ orderBy: "name",
257
+ order: "asc",
258
+ limit: 10
259
+ });
260
+
261
+ // Filter + cursor both generate WHERE conditions that must be combined
262
+ expect(db.where).toHaveBeenCalled();
263
+ });
264
+
265
+ it("should apply cursor without orderBy (simple id < cursorId)", async () => {
266
+ (db as unknown as Record<string, jest.Mock>).then = jest.fn((resolve: (v: unknown[]) => void) => resolve([]));
267
+
268
+ await entityService.fetchCollection("items", {
269
+ startAfter: { id: 10 },
270
+ limit: 5
271
+ });
272
+
273
+ expect(db.where).toHaveBeenCalled();
274
+ });
275
+
276
+ it("should apply cursor with desc ordering", async () => {
277
+ (db as unknown as Record<string, jest.Mock>).then = jest.fn((resolve: (v: unknown[]) => void) => resolve([]));
278
+
279
+ await entityService.fetchCollection("items", {
280
+ startAfter: { id: 20, values: { name: "Alpha" } },
281
+ orderBy: "name",
282
+ order: "desc",
283
+ limit: 10
284
+ });
285
+
286
+ // The fallback path applies where() + orderBy()
287
+ expect(db.where).toHaveBeenCalled();
288
+ expect(db.orderBy).toHaveBeenCalled();
289
+ });
290
+
291
+ it("should return empty array when no results match cursor + filter", async () => {
292
+ (db as unknown as Record<string, jest.Mock>).then = jest.fn((resolve: (v: unknown[]) => void) => resolve([]));
293
+
294
+ const entities = await entityService.fetchCollection("items", {
295
+ filter: { name: ["==", "nonexistent"] },
296
+ startAfter: { id: 1 },
297
+ limit: 10
298
+ });
299
+
300
+ expect(entities).toEqual([]);
301
+ });
302
+ });
303
+
304
+ // ===========================================================================
305
+ // 3. COUNT ENTITIES
306
+ // ===========================================================================
307
+
308
+ describe("EntityFetchService – countEntities", () => {
309
+ let entityService: EntityService;
310
+ let db: jest.Mocked<NodePgDatabase>;
311
+
312
+ beforeEach(() => {
313
+ db = createMockDb();
314
+ setupRegistryMocks();
315
+ entityService = new EntityService(db, collectionRegistry);
316
+ });
317
+
318
+ afterEach(() => {
319
+ jest.restoreAllMocks();
320
+ });
321
+
322
+ it("should count entities with no filters", async () => {
323
+ // The count query resolves to [{ count: 42 }]
324
+ (db as unknown as Record<string, jest.Mock>).then = jest.fn(
325
+ (resolve: (v: unknown[]) => void) => resolve([{ count: 42 }])
326
+ );
327
+
328
+ const result = await entityService.countEntities("items");
329
+
330
+ expect(result).toBe(42);
331
+ expect(db.select).toHaveBeenCalled();
332
+ expect(db.from).toHaveBeenCalled();
333
+ });
334
+
335
+ it("should count entities with a filter", async () => {
336
+ (db as unknown as Record<string, jest.Mock>).then = jest.fn(
337
+ (resolve: (v: unknown[]) => void) => resolve([{ count: 7 }])
338
+ );
339
+
340
+ const result = await entityService.countEntities("items", {
341
+ filter: { name: ["==", "Widget"] }
342
+ });
343
+
344
+ expect(result).toBe(7);
345
+ expect(db.where).toHaveBeenCalled();
346
+ });
347
+
348
+ it("should return 0 when count result is empty", async () => {
349
+ (db as unknown as Record<string, jest.Mock>).then = jest.fn(
350
+ (resolve: (v: unknown[]) => void) => resolve([])
351
+ );
352
+
353
+ const result = await entityService.countEntities("items");
354
+
355
+ expect(result).toBe(0);
356
+ });
357
+
358
+ it("should pass searchString through to the query pipeline", async () => {
359
+ // With mock table columns that don't have a `columnType` property,
360
+ // buildSearchConditions generates ILIKE conditions (because of the
361
+ // fallback `!("columnType" in fieldColumn)` check). This means the
362
+ // search path proceeds normally rather than short-circuiting to 0.
363
+ (db as unknown as Record<string, jest.Mock>).then = jest.fn(
364
+ (resolve: (v: unknown[]) => void) => resolve([{ count: 3 }])
365
+ );
366
+
367
+ const result = await entityService.countEntities("items", {
368
+ searchString: "hello"
369
+ });
370
+
371
+ // The mock columns pass the ILIKE eligibility check, so the query
372
+ // proceeds and returns the mocked count.
373
+ expect(result).toBe(3);
374
+ expect(db.where).toHaveBeenCalled();
375
+ });
376
+
377
+ it("should handle search strings with ILIKE metacharacters (%, _)", async () => {
378
+ // Even if the searchString contains SQL wildcards, the service
379
+ // should not crash. The underlying ilike() call wraps the value
380
+ // with `%…%`, so metacharacters in user input become literal
381
+ // characters only if properly escaped by drizzle-orm.
382
+ // This test documents the pass-through behaviour.
383
+ (db as unknown as Record<string, jest.Mock>).then = jest.fn(
384
+ (resolve: (v: unknown[]) => void) => resolve([{ count: 1 }])
385
+ );
386
+
387
+ // Should not throw even with %, _, and backslash
388
+ await expect(
389
+ entityService.countEntities("items", { searchString: "100%_done\\!" })
390
+ ).resolves.toBeDefined();
391
+ });
392
+ });
393
+
394
+ // ===========================================================================
395
+ // 4. DELETE ALL – no before/afterDelete callbacks
396
+ // ===========================================================================
397
+
398
+ describe("EntityPersistService – deleteAll", () => {
399
+ let entityService: EntityService;
400
+ let db: jest.Mocked<NodePgDatabase>;
401
+
402
+ beforeEach(() => {
403
+ db = createMockDb();
404
+ setupRegistryMocks();
405
+ entityService = new EntityService(db, collectionRegistry);
406
+ });
407
+
408
+ afterEach(() => {
409
+ jest.restoreAllMocks();
410
+ });
411
+
412
+ it("should issue a bulk DELETE without WHERE clause", async () => {
413
+ await entityService.deleteAll("items");
414
+
415
+ expect(db.delete).toHaveBeenCalledWith(mockItemsTable);
416
+ // deleteAll must NOT call where() — it deletes everything
417
+ expect(db.where).not.toHaveBeenCalled();
418
+ });
419
+
420
+ it("should issue a bulk DELETE on composite-key tables", async () => {
421
+ await entityService.deleteAll("project_users");
422
+
423
+ expect(db.delete).toHaveBeenCalledWith(mockProjectUsersTable);
424
+ expect(db.where).not.toHaveBeenCalled();
425
+ });
426
+
427
+ it("should not invoke any per-entity callbacks (beforeDelete/afterDelete)", async () => {
428
+ // deleteAll directly calls db.delete(table) — it does NOT iterate
429
+ // over individual entities and therefore MUST NOT trigger any
430
+ // beforeDelete / afterDelete lifecycle hooks.
431
+ //
432
+ // We verify this by ensuring only a single db.delete call is made
433
+ // and no select/fetchEntity calls precede it.
434
+ const selectSpy = db.select as jest.Mock;
435
+ selectSpy.mockClear();
436
+
437
+ await entityService.deleteAll("items");
438
+
439
+ // Only one delete call, no selects to fetch individual entities
440
+ expect(db.delete).toHaveBeenCalledTimes(1);
441
+ expect(selectSpy).not.toHaveBeenCalled();
442
+ });
443
+
444
+ it("should throw for unknown collection", async () => {
445
+ jest.spyOn(collectionRegistry, "getCollectionByPath").mockReturnValue(undefined);
446
+
447
+ await expect(
448
+ entityService.deleteAll("nonexistent")
449
+ ).rejects.toThrow("Collection not found: nonexistent");
450
+ });
451
+ });