@rebasepro/server-postgresql 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +24 -18
- package/src/PostgresBackendDriver.ts +65 -44
- package/src/PostgresBootstrapper.ts +34 -2
- 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/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 +10 -8
- package/src/services/entityService.ts +28 -3
- 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/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/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-error-utils.test.ts +50 -1
- package/test/realtimeService-channels.test.ts +696 -0
- package/test/unmapped-tables-safety.test.ts +55 -342
- 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 -10803
- package/dist/index.es.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-array-null-patch.d.ts +0 -16
- package/dist/utils/pg-error-utils.d.ts +0 -55
- package/dist/websocket.d.ts +0 -11
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { promises as fsPromises } from "fs";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
|
+
import chokidar from "chokidar";
|
|
6
|
+
import { generatePostgresDdl, generatePostgresPoliciesDdl } from "./generate-postgres-ddl-logic";
|
|
7
|
+
import { EntityCollection } from "@rebasepro/types";
|
|
8
|
+
import { logger } from "@rebasepro/server-core";
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
const runGeneration = async (collectionsFilePath?: string, outputPath?: string) => {
|
|
12
|
+
try {
|
|
13
|
+
if (!collectionsFilePath) {
|
|
14
|
+
logger.error("Error: No collections file path provided. Skipping schema generation.");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const resolvedPath = path.resolve(collectionsFilePath);
|
|
19
|
+
let collections: EntityCollection[] = [];
|
|
20
|
+
const stats = fs.statSync(resolvedPath);
|
|
21
|
+
|
|
22
|
+
if (stats.isDirectory()) {
|
|
23
|
+
const files = fs.readdirSync(resolvedPath);
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
if ((file.endsWith(".ts") || file.endsWith(".js")) &&
|
|
26
|
+
!file.includes(".test.") &&
|
|
27
|
+
!file.endsWith(".d.ts") &&
|
|
28
|
+
file !== "index.ts" && file !== "index.js") {
|
|
29
|
+
|
|
30
|
+
const filePath = path.join(resolvedPath, file);
|
|
31
|
+
try {
|
|
32
|
+
const fileUrl = pathToFileURL(filePath).href;
|
|
33
|
+
const module = await import(fileUrl);
|
|
34
|
+
if (module && module.default) {
|
|
35
|
+
collections.push(module.default);
|
|
36
|
+
}
|
|
37
|
+
} catch (err: unknown) {
|
|
38
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
39
|
+
logger.error(`Error loading ${file}`, { detail: message });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
const fileUrl = pathToFileURL(resolvedPath).href + `?t=${Date.now()}`;
|
|
45
|
+
const imported = await import(fileUrl);
|
|
46
|
+
collections = imported.backendCollections || imported.collections;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!collections || !Array.isArray(collections)) {
|
|
50
|
+
collections = [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Sort collections by slug alphabetically to ensure deterministic DDL generation
|
|
54
|
+
collections.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
55
|
+
|
|
56
|
+
const ddlContent = await generatePostgresDdl(collections, { includePolicies: false });
|
|
57
|
+
const policiesContent = generatePostgresPoliciesDdl(collections);
|
|
58
|
+
|
|
59
|
+
if (outputPath) {
|
|
60
|
+
const outputDir = path.dirname(outputPath);
|
|
61
|
+
await fsPromises.mkdir(outputDir, { recursive: true });
|
|
62
|
+
await fsPromises.writeFile(outputPath, ddlContent);
|
|
63
|
+
logger.info(`✅ PostgreSQL DDL generated successfully at ${outputPath}`);
|
|
64
|
+
|
|
65
|
+
const policiesPath = path.join(outputDir, "policies.sql");
|
|
66
|
+
await fsPromises.writeFile(policiesPath, policiesContent);
|
|
67
|
+
logger.info(`✅ PostgreSQL Policies DDL generated successfully at ${policiesPath}`);
|
|
68
|
+
} else {
|
|
69
|
+
logger.info("✅ PostgreSQL DDL generated successfully.");
|
|
70
|
+
logger.info(String(ddlContent));
|
|
71
|
+
logger.info("\n✅ PostgreSQL Policies DDL generated successfully.");
|
|
72
|
+
logger.info(String(policiesContent));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
} catch (error) {
|
|
76
|
+
logger.error("Error generating DDL schema", { error: error });
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const main = () => {
|
|
81
|
+
const collectionsFilePathArg = process.argv.find(arg => arg.startsWith("--collections="));
|
|
82
|
+
const collectionsFilePath = collectionsFilePathArg ? collectionsFilePathArg.split("=")[1] : process.argv[2];
|
|
83
|
+
|
|
84
|
+
const outputPathArg = process.argv.find(arg => arg.startsWith("--output="));
|
|
85
|
+
const outputPath = outputPathArg ? outputPathArg.split("=")[1] : undefined;
|
|
86
|
+
|
|
87
|
+
const watch = process.argv.includes("--watch");
|
|
88
|
+
|
|
89
|
+
if (!collectionsFilePath) {
|
|
90
|
+
logger.info("Usage: ts-node generate-postgres-ddl.ts <path-to-collections-file> [--output <path-to-output-file>] [--watch]");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const resolvedPath = path.resolve(process.cwd(), collectionsFilePath);
|
|
95
|
+
const resolvedOutputPath = outputPath ? path.resolve(process.cwd(), outputPath) : undefined;
|
|
96
|
+
|
|
97
|
+
if (watch) {
|
|
98
|
+
logger.info(`Watching for changes in ${resolvedPath}...`);
|
|
99
|
+
const watcher = chokidar.watch(resolvedPath, {
|
|
100
|
+
persistent: true,
|
|
101
|
+
ignoreInitial: false
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
watcher.on("all", (event, filePath) => {
|
|
105
|
+
logger.info(`[${event}] ${filePath}. Regenerating DDL schema...`);
|
|
106
|
+
runGeneration(resolvedPath, resolvedOutputPath);
|
|
107
|
+
});
|
|
108
|
+
} else {
|
|
109
|
+
runGeneration(resolvedPath, resolvedOutputPath);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// This check ensures the script only runs when executed directly
|
|
114
|
+
if (import.meta.url.endsWith(process.argv[1])) {
|
|
115
|
+
main();
|
|
116
|
+
}
|
|
@@ -40,19 +40,21 @@ export class EntityPersistService {
|
|
|
40
40
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
41
41
|
const table = getTableForCollection(collection, this.registry);
|
|
42
42
|
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
43
|
-
const idInfo = idInfoArray[0];
|
|
44
|
-
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
45
|
-
|
|
46
|
-
if (!idField) {
|
|
47
|
-
throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
48
|
-
}
|
|
49
43
|
|
|
50
44
|
const parsedIdObj = parseIdValues(entityId, idInfoArray);
|
|
51
|
-
|
|
45
|
+
|
|
46
|
+
const conditions = [];
|
|
47
|
+
for (const info of idInfoArray) {
|
|
48
|
+
const field = table[info.fieldName as keyof typeof table] as AnyPgColumn;
|
|
49
|
+
if (!field) {
|
|
50
|
+
throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
51
|
+
}
|
|
52
|
+
conditions.push(eq(field, parsedIdObj[info.fieldName]));
|
|
53
|
+
}
|
|
52
54
|
|
|
53
55
|
await this.db
|
|
54
56
|
.delete(table)
|
|
55
|
-
.where(
|
|
57
|
+
.where(and(...conditions));
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/**
|
|
@@ -180,12 +180,37 @@ export class EntityService implements EntityRepository {
|
|
|
180
180
|
/**
|
|
181
181
|
* Execute raw SQL
|
|
182
182
|
*/
|
|
183
|
-
async executeSql(sqlText: string): Promise<Record<string, unknown>[]> {
|
|
183
|
+
async executeSql(sqlText: string, params?: unknown[]): Promise<Record<string, unknown>[]> {
|
|
184
184
|
if (process.env.NODE_ENV !== "production") {
|
|
185
|
-
console.debug("Executing raw SQL:", sqlText);
|
|
185
|
+
console.debug("Executing raw SQL:", sqlText, params?.length ? `with ${params.length} params` : "");
|
|
186
186
|
}
|
|
187
187
|
const { sql } = await import("drizzle-orm");
|
|
188
|
-
|
|
188
|
+
|
|
189
|
+
let result;
|
|
190
|
+
if (params && params.length > 0) {
|
|
191
|
+
// Build a parameterized query using Drizzle's sql tagged template.
|
|
192
|
+
// Split the SQL text on $1, $2, … placeholders and interleave
|
|
193
|
+
// with sql.param() calls so the underlying pg driver binds them safely.
|
|
194
|
+
const parts = sqlText.split(/\$(\d+)/);
|
|
195
|
+
const chunks: ReturnType<typeof sql.raw | typeof sql.param>[] = [];
|
|
196
|
+
for (let i = 0; i < parts.length; i++) {
|
|
197
|
+
if (i % 2 === 0) {
|
|
198
|
+
// Literal SQL text fragment
|
|
199
|
+
if (parts[i].length > 0) {
|
|
200
|
+
chunks.push(sql.raw(parts[i]));
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
// Parameter reference — $N (1-indexed)
|
|
204
|
+
const paramIndex = Number(parts[i]) - 1;
|
|
205
|
+
chunks.push(sql.param(params[paramIndex]));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const query = sql.join(chunks, sql.raw(""));
|
|
209
|
+
result = await this.db.execute(query);
|
|
210
|
+
} else {
|
|
211
|
+
result = await this.db.execute(sql.raw(sqlText));
|
|
212
|
+
}
|
|
213
|
+
|
|
189
214
|
const rows = result.rows;
|
|
190
215
|
if (process.env.NODE_ENV !== "production") {
|
|
191
216
|
console.debug(`SQL executed successfully. Returned ${Array.isArray(rows) ? rows.length : "non-array"} rows.`);
|
|
@@ -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
|
+
});
|