@rebasepro/server-postgresql 0.6.1 → 0.8.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 (55) hide show
  1. package/dist/PostgresBackendDriver.d.ts +8 -0
  2. package/dist/auth/services.d.ts +21 -1
  3. package/dist/cli-errors.d.ts +29 -0
  4. package/dist/cli-helpers.d.ts +7 -0
  5. package/dist/collections/PostgresCollectionRegistry.d.ts +2 -2
  6. package/dist/index.es.js +2987 -230
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/schema/auth-default-policies.d.ts +12 -0
  9. package/dist/schema/auth-schema.d.ts +227 -0
  10. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
  11. package/dist/schema/generate-postgres-ddl.d.ts +1 -0
  12. package/dist/services/entityService.d.ts +1 -1
  13. package/dist/utils/pg-error-utils.d.ts +10 -0
  14. package/dist/utils/table-classification.d.ts +8 -0
  15. package/package.json +15 -9
  16. package/src/PostgresBackendDriver.ts +200 -68
  17. package/src/PostgresBootstrapper.ts +34 -2
  18. package/src/auth/ensure-tables.ts +56 -1
  19. package/src/auth/services.ts +94 -1
  20. package/src/cli-errors.ts +162 -0
  21. package/src/cli-helpers.ts +183 -0
  22. package/src/cli.ts +264 -245
  23. package/src/collections/PostgresCollectionRegistry.ts +6 -6
  24. package/src/data-transformer.ts +2 -2
  25. package/src/schema/auth-default-policies.ts +97 -0
  26. package/src/schema/auth-schema.ts +25 -2
  27. package/src/schema/doctor.ts +2 -4
  28. package/src/schema/generate-drizzle-schema-logic.ts +20 -82
  29. package/src/schema/generate-drizzle-schema.ts +3 -5
  30. package/src/schema/generate-postgres-ddl-logic.ts +487 -0
  31. package/src/schema/generate-postgres-ddl.ts +116 -0
  32. package/src/services/EntityPersistService.ts +10 -8
  33. package/src/services/entityService.ts +28 -3
  34. package/src/services/realtimeService.ts +26 -2
  35. package/src/utils/pg-error-utils.ts +16 -0
  36. package/src/utils/table-classification.ts +16 -0
  37. package/src/websocket.ts +9 -0
  38. package/test/auth-default-policies.test.ts +89 -0
  39. package/test/cli-helpers-extended.test.ts +324 -0
  40. package/test/cli-helpers.test.ts +59 -0
  41. package/test/connection.test.ts +292 -0
  42. package/test/databasePoolManager.test.ts +289 -0
  43. package/test/doctor-extended.test.ts +443 -0
  44. package/test/e2e/db-e2e.test.ts +293 -0
  45. package/test/e2e/pg-setup.ts +79 -0
  46. package/test/entity-callbacks-redaction.test.ts +86 -0
  47. package/test/entity-persist-composite-keys.test.ts +451 -0
  48. package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
  49. package/test/generate-postgres-ddl.test.ts +300 -0
  50. package/test/mfa-service.test.ts +544 -0
  51. package/test/pg-error-utils.test.ts +50 -1
  52. package/test/postgresDataDriver.test.ts +2 -1
  53. package/test/realtimeService-channels.test.ts +696 -0
  54. package/test/unmapped-tables-safety.test.ts +55 -342
  55. package/vitest.e2e.config.ts +10 -0
@@ -651,9 +651,10 @@ roles: activeAuth.roles })}, true)`);
651
651
  ...registryCollection } as EntityCollection : registryCollection as EntityCollection;
652
652
 
653
653
  const callbacks = resolvedCollection?.callbacks;
654
+ const globalCallbacks = this.registry?.getGlobalCallbacks();
654
655
  const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
655
656
 
656
- if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
657
+ if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
657
658
  const contextForCallback = {
658
659
  user: { uid: activeAuth.userId,
659
660
  roles: activeAuth.roles },
@@ -663,6 +664,16 @@ roles: activeAuth.roles },
663
664
 
664
665
  return await Promise.all(fetchedEntities.map(async (entity) => {
665
666
  let processedEntity = entity;
667
+ // 1. Global callbacks first
668
+ if (globalCallbacks?.afterRead) {
669
+ processedEntity = await globalCallbacks.afterRead({
670
+ collection: resolvedCollection,
671
+ path: notifyPath,
672
+ entity: processedEntity,
673
+ context: contextForCallback
674
+ }) ?? processedEntity;
675
+ }
676
+ // 2. Collection callbacks second
666
677
  if (callbacks?.afterRead) {
667
678
  processedEntity = await callbacks.afterRead({
668
679
  collection: resolvedCollection,
@@ -671,6 +682,7 @@ roles: activeAuth.roles },
671
682
  context: contextForCallback
672
683
  }) ?? processedEntity;
673
684
  }
685
+ // 3. Property callbacks third
674
686
  if (propertyCallbacks?.afterRead) {
675
687
  processedEntity = await propertyCallbacks.afterRead({
676
688
  collection: resolvedCollection,
@@ -797,9 +809,10 @@ roles: activeAuth.roles })}, true)`);
797
809
  ...registryCollection } as EntityCollection : registryCollection as EntityCollection;
798
810
 
799
811
  const callbacks = resolvedCollection?.callbacks;
812
+ const globalCallbacks = this.registry?.getGlobalCallbacks();
800
813
  const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
801
814
 
802
- if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
815
+ if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
803
816
  const contextForCallback = {
804
817
  user: { uid: activeAuth.userId,
805
818
  roles: activeAuth.roles },
@@ -807,6 +820,16 @@ roles: activeAuth.roles },
807
820
  data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
808
821
  } as unknown as RebaseCallContext;
809
822
 
823
+ // 1. Global callbacks first
824
+ if (globalCallbacks?.afterRead) {
825
+ processedEntity = await globalCallbacks.afterRead({
826
+ collection: resolvedCollection,
827
+ path: notifyPath,
828
+ entity: processedEntity,
829
+ context: contextForCallback
830
+ }) ?? processedEntity;
831
+ }
832
+ // 2. Collection callbacks second
810
833
  if (callbacks?.afterRead) {
811
834
  processedEntity = await callbacks.afterRead({
812
835
  collection: resolvedCollection,
@@ -815,6 +838,7 @@ roles: activeAuth.roles },
815
838
  context: contextForCallback
816
839
  }) ?? processedEntity;
817
840
  }
841
+ // 3. Property callbacks third
818
842
  if (propertyCallbacks?.afterRead) {
819
843
  processedEntity = await propertyCallbacks.afterRead({
820
844
  collection: resolvedCollection,
@@ -68,6 +68,22 @@ export function extractCauseMessage(error: unknown): string | null {
68
68
  return null;
69
69
  }
70
70
 
71
+ /**
72
+ * Detect whether an error is specifically a role-switching permission failure
73
+ * (e.g. "permission denied to set role" or "must be member of role"),
74
+ * as opposed to a table-level permission denial.
75
+ *
76
+ * This is used by the backend driver to auto-disable role switching when the
77
+ * connection user lacks SET ROLE privileges, rather than surfacing a confusing
78
+ * error to the Studio SQL Editor user.
79
+ */
80
+ export function isRoleSwitchingPermissionError(error: unknown): boolean {
81
+ const pgError = extractPgError(error);
82
+ if (!pgError || pgError.code !== "42501") return false;
83
+ const msg = pgError.message.toLowerCase();
84
+ return msg.includes("set role") || msg.includes("member of role");
85
+ }
86
+
71
87
  /**
72
88
  * Translate a raw PostgreSQL error into a user-friendly message.
73
89
  *
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Table Classification Utility
3
+ *
4
+ * Re-exports shared classification logic from @rebasepro/common.
5
+ * This module exists for backward compatibility — prefer importing directly
6
+ * from @rebasepro/common in new code.
7
+ */
8
+ export {
9
+ type TableCategory,
10
+ REBASE_INTERNAL_SCHEMAS,
11
+ REBASE_INTERNAL_PREFIXES,
12
+ classifyTable,
13
+ isRebaseInternalTable,
14
+ detectJunctionTables,
15
+ JUNCTION_TABLES_SQL,
16
+ } from "@rebasepro/common";
package/src/websocket.ts CHANGED
@@ -403,6 +403,15 @@ colors: true }));
403
403
  if (process.env.NODE_ENV !== "production") {
404
404
  wsDebug(`⚡ [WebSocket Server] SQL executed. Returned ${Array.isArray(result) ? result.length : "non-array"} rows.`);
405
405
  }
406
+ const auditSession = clientSessions.get(clientId);
407
+ console.log("[SQL Audit] WebSocket SQL execution", JSON.stringify({
408
+ sql: typeof sql === "string" ? sql.substring(0, 500) : sql,
409
+ options,
410
+ resultRows: Array.isArray(result) ? result.length : "unknown",
411
+ userId: auditSession?.user?.userId ?? "unknown",
412
+ roles: auditSession?.user?.roles ?? [],
413
+ isAdmin: auditSession?.user?.isAdmin ?? false,
414
+ }));
406
415
  const response = {
407
416
  type: "EXECUTE_SQL_SUCCESS",
408
417
  payload: { result },
@@ -0,0 +1,89 @@
1
+ import { EntityCollection, PostgresCollection } from "@rebasepro/types";
2
+ import { generatePostgresPoliciesDdl } from "../src/schema/generate-postgres-ddl-logic";
3
+ import { getEffectiveSecurityRules } from "../src/schema/auth-default-policies";
4
+
5
+ describe("auth collection default RLS policies", () => {
6
+ const adminWrite = "string_to_array(auth.roles(), ',') && ARRAY['admin']";
7
+
8
+ it("injects admin write policies (permissive grant + restrictive gate) when an auth collection has no security rules", () => {
9
+ const collection: PostgresCollection = {
10
+ slug: "users",
11
+ table: "users",
12
+ name: "Users",
13
+ auth: true,
14
+ properties: {
15
+ id: { type: "string", isId: "uuid" },
16
+ roles: { type: "array", columnType: "text[]", of: { type: "string" } }
17
+ }
18
+ };
19
+
20
+ const ddl = generatePostgresPoliciesDdl([collection]);
21
+
22
+ expect(ddl).toContain("FORCE ROW LEVEL SECURITY");
23
+ // Restrictive gate exists for every write op.
24
+ expect(ddl).toContain("AS RESTRICTIVE FOR INSERT");
25
+ expect(ddl).toContain("AS RESTRICTIVE FOR UPDATE");
26
+ expect(ddl).toContain("AS RESTRICTIVE FOR DELETE");
27
+ expect(ddl).toContain(adminWrite);
28
+ });
29
+
30
+ it("keeps an author's permissive owner write rule but the restrictive gate still blocks non-admins", () => {
31
+ const collection: PostgresCollection = {
32
+ slug: "users",
33
+ table: "users",
34
+ name: "Users",
35
+ auth: true,
36
+ properties: {
37
+ id: { type: "string", isId: "uuid" },
38
+ roles: { type: "array", columnType: "text[]", of: { type: "string" } }
39
+ },
40
+ // The dangerous case: author lets users edit their own row.
41
+ securityRules: [
42
+ { name: "edit_own", operation: "update", ownerField: "id" }
43
+ ]
44
+ };
45
+
46
+ const rules = getEffectiveSecurityRules(collection);
47
+ const gate = rules.find(r => r.name === "users_require_admin_write");
48
+ const grant = rules.find(r => r.name === "users_default_admin_write");
49
+
50
+ // Author rule preserved.
51
+ expect(rules.find(r => r.name === "edit_own")).toBeDefined();
52
+ // Restrictive gate added covering all write ops.
53
+ expect(gate).toBeDefined();
54
+ expect(gate?.mode).toBe("restrictive");
55
+ expect(gate?.operations).toEqual(["insert", "update", "delete"]);
56
+ // Permissive grant added.
57
+ expect(grant).toBeDefined();
58
+ expect(grant?.mode).toBeUndefined();
59
+
60
+ // The generated SQL AND's the restrictive gate, so a self-update by a
61
+ // non-admin cannot pass — only admins/server can write.
62
+ const ddl = generatePostgresPoliciesDdl([collection]);
63
+ expect(ddl).toContain("AS RESTRICTIVE FOR UPDATE");
64
+ });
65
+
66
+ it("can be opted out with disableDefaultAuthPolicies", () => {
67
+ const collection: PostgresCollection = {
68
+ slug: "users",
69
+ table: "users",
70
+ name: "Users",
71
+ auth: true,
72
+ disableDefaultAuthPolicies: true,
73
+ properties: { id: { type: "string", isId: "uuid" } }
74
+ };
75
+
76
+ expect(getEffectiveSecurityRules(collection)).toHaveLength(0);
77
+ });
78
+
79
+ it("leaves non-auth collections untouched", () => {
80
+ const collection: EntityCollection = {
81
+ slug: "products",
82
+ table: "products",
83
+ name: "Products",
84
+ properties: { name: { type: "string" } }
85
+ };
86
+
87
+ expect(getEffectiveSecurityRules(collection)).toHaveLength(0);
88
+ });
89
+ });
@@ -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
+ });