@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.
- package/dist/PostgresBackendDriver.d.ts +8 -0
- package/dist/auth/services.d.ts +21 -1
- package/dist/cli-errors.d.ts +29 -0
- package/dist/cli-helpers.d.ts +7 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +2 -2
- package/dist/index.es.js +2987 -230
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-default-policies.d.ts +12 -0
- package/dist/schema/auth-schema.d.ts +227 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -0
- package/dist/services/entityService.d.ts +1 -1
- package/dist/utils/pg-error-utils.d.ts +10 -0
- package/dist/utils/table-classification.d.ts +8 -0
- package/package.json +15 -9
- package/src/PostgresBackendDriver.ts +200 -68
- 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 +264 -245
- package/src/collections/PostgresCollectionRegistry.ts +6 -6
- package/src/data-transformer.ts +2 -2
- package/src/schema/auth-default-policies.ts +97 -0
- package/src/schema/auth-schema.ts +25 -2
- package/src/schema/doctor.ts +2 -4
- package/src/schema/generate-drizzle-schema-logic.ts +20 -82
- package/src/schema/generate-drizzle-schema.ts +3 -5
- package/src/schema/generate-postgres-ddl-logic.ts +487 -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/services/realtimeService.ts +26 -2
- 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-callbacks-redaction.test.ts +86 -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/postgresDataDriver.test.ts +2 -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/src/auth/services.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { eq, getTableName, sql } from "drizzle-orm";
|
|
|
2
2
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
3
|
import { getTableConfig } from "drizzle-orm/pg-core";
|
|
4
4
|
import type { RebasePgTable } from "../types";
|
|
5
|
-
import { users, refreshTokens, passwordResetTokens, userIdentities } from "../schema/auth-schema";
|
|
5
|
+
import { users, refreshTokens, passwordResetTokens, userIdentities, magicLinkTokens } from "../schema/auth-schema";
|
|
6
6
|
import {
|
|
7
7
|
UserRepository,
|
|
8
8
|
RoleRepository,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
CreateRoleData,
|
|
16
16
|
RefreshTokenInfo,
|
|
17
17
|
PasswordResetTokenInfo,
|
|
18
|
+
MagicLinkTokenInfo,
|
|
18
19
|
UserIdentityData,
|
|
19
20
|
ListUsersOptions,
|
|
20
21
|
PaginatedUsersResult,
|
|
@@ -693,6 +694,68 @@ export class PasswordResetTokenService {
|
|
|
693
694
|
}
|
|
694
695
|
}
|
|
695
696
|
|
|
697
|
+
/**
|
|
698
|
+
* Magic link token service.
|
|
699
|
+
* Handles magic link token storage for passwordless email login.
|
|
700
|
+
*/
|
|
701
|
+
export class MagicLinkTokenService {
|
|
702
|
+
private magicLinkTokensTable: RebasePgTable;
|
|
703
|
+
|
|
704
|
+
constructor(
|
|
705
|
+
private db: NodePgDatabase,
|
|
706
|
+
tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>
|
|
707
|
+
) {
|
|
708
|
+
this.magicLinkTokensTable = (magicLinkTokens as unknown as RebasePgTable);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private getQualifiedTableName(): string {
|
|
712
|
+
const name = getTableName(this.magicLinkTokensTable);
|
|
713
|
+
const schema = getTableConfig(this.magicLinkTokensTable).schema || "public";
|
|
714
|
+
return `"${schema}"."${name}"`;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async createToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
|
|
718
|
+
// Delete any existing unused tokens for this user
|
|
719
|
+
const tableName = this.getQualifiedTableName();
|
|
720
|
+
await this.db.execute(sql`
|
|
721
|
+
DELETE FROM ${sql.raw(tableName)}
|
|
722
|
+
WHERE user_id = ${userId} AND used_at IS NULL
|
|
723
|
+
`);
|
|
724
|
+
|
|
725
|
+
await this.db.insert(this.magicLinkTokensTable).values({
|
|
726
|
+
userId,
|
|
727
|
+
tokenHash,
|
|
728
|
+
expiresAt
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async findValidByHash(tokenHash: string): Promise<MagicLinkTokenInfo | null> {
|
|
733
|
+
const tableName = this.getQualifiedTableName();
|
|
734
|
+
const result = await this.db.execute(sql`
|
|
735
|
+
SELECT user_id, expires_at
|
|
736
|
+
FROM ${sql.raw(tableName)}
|
|
737
|
+
WHERE token_hash = ${tokenHash}
|
|
738
|
+
AND used_at IS NULL
|
|
739
|
+
AND expires_at > NOW()
|
|
740
|
+
`);
|
|
741
|
+
|
|
742
|
+
if (result.rows.length === 0) return null;
|
|
743
|
+
|
|
744
|
+
const row = result.rows[0] as { user_id: string; expires_at: string | number | Date };
|
|
745
|
+
return {
|
|
746
|
+
userId: row.user_id,
|
|
747
|
+
expiresAt: new Date(row.expires_at)
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async markAsUsed(tokenHash: string): Promise<void> {
|
|
752
|
+
await this.db
|
|
753
|
+
.update(this.magicLinkTokensTable)
|
|
754
|
+
.set({ usedAt: new Date() })
|
|
755
|
+
.where(eq(this.magicLinkTokensTable.tokenHash, tokenHash));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
696
759
|
/**
|
|
697
760
|
* PostgreSQL implementation of TokenRepository.
|
|
698
761
|
* Combines refresh token and password reset token operations.
|
|
@@ -700,6 +763,7 @@ export class PasswordResetTokenService {
|
|
|
700
763
|
export class PostgresTokenRepository implements TokenRepository {
|
|
701
764
|
private refreshTokenService: RefreshTokenService;
|
|
702
765
|
private passwordResetTokenService: PasswordResetTokenService;
|
|
766
|
+
private magicLinkTokenService: MagicLinkTokenService;
|
|
703
767
|
|
|
704
768
|
constructor(
|
|
705
769
|
private db: NodePgDatabase,
|
|
@@ -707,6 +771,7 @@ export class PostgresTokenRepository implements TokenRepository {
|
|
|
707
771
|
) {
|
|
708
772
|
this.refreshTokenService = new RefreshTokenService(db, tableOrTables);
|
|
709
773
|
this.passwordResetTokenService = new PasswordResetTokenService(db, tableOrTables);
|
|
774
|
+
this.magicLinkTokenService = new MagicLinkTokenService(db, tableOrTables);
|
|
710
775
|
}
|
|
711
776
|
|
|
712
777
|
// Refresh token operations
|
|
@@ -756,6 +821,20 @@ export class PostgresTokenRepository implements TokenRepository {
|
|
|
756
821
|
async deleteExpiredTokens(): Promise<void> {
|
|
757
822
|
await this.passwordResetTokenService.deleteExpired();
|
|
758
823
|
}
|
|
824
|
+
|
|
825
|
+
// Magic link token operations
|
|
826
|
+
|
|
827
|
+
async createMagicLinkToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
|
|
828
|
+
await this.magicLinkTokenService.createToken(userId, tokenHash, expiresAt);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async findValidMagicLinkToken(tokenHash: string): Promise<MagicLinkTokenInfo | null> {
|
|
832
|
+
return this.magicLinkTokenService.findValidByHash(tokenHash);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async markMagicLinkTokenUsed(tokenHash: string): Promise<void> {
|
|
836
|
+
await this.magicLinkTokenService.markAsUsed(tokenHash);
|
|
837
|
+
}
|
|
759
838
|
}
|
|
760
839
|
|
|
761
840
|
/**
|
|
@@ -955,6 +1034,20 @@ collectionPermissions: null }
|
|
|
955
1034
|
await this.tokenRepository.deleteExpiredTokens();
|
|
956
1035
|
}
|
|
957
1036
|
|
|
1037
|
+
// Magic link token operations
|
|
1038
|
+
|
|
1039
|
+
async createMagicLinkToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
|
|
1040
|
+
await this.tokenRepository.createMagicLinkToken(userId, tokenHash, expiresAt);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
async findValidMagicLinkToken(tokenHash: string): Promise<MagicLinkTokenInfo | null> {
|
|
1044
|
+
return this.tokenRepository.findValidMagicLinkToken(tokenHash);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
async markMagicLinkTokenUsed(tokenHash: string): Promise<void> {
|
|
1048
|
+
await this.tokenRepository.markMagicLinkTokenUsed(tokenHash);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
958
1051
|
// MFA operations (delegate to MfaService)
|
|
959
1052
|
|
|
960
1053
|
private _mfaService: MfaService | null = null;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { logger } from "@rebasepro/server-core";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detect whether an error (or AggregateError wrapping multiple attempts)
|
|
6
|
+
* represents an ECONNREFUSED — i.e. the database is simply not running.
|
|
7
|
+
*
|
|
8
|
+
* Handles:
|
|
9
|
+
* - Direct `{ code: "ECONNREFUSED" }` errors from Node `net`
|
|
10
|
+
* - `AggregateError` from dual-stack IPv4+IPv6 connection attempts
|
|
11
|
+
* - Drizzle's `cause`-wrapped pg errors
|
|
12
|
+
*/
|
|
13
|
+
export function isEconnrefused(err: unknown): boolean {
|
|
14
|
+
if (!err || typeof err !== "object") return false;
|
|
15
|
+
const e = err as { code?: string; cause?: unknown; errors?: unknown[] };
|
|
16
|
+
if (e.code === "ECONNREFUSED") return true;
|
|
17
|
+
// AggregateError from Node net (dual-stack IPv4 + IPv6)
|
|
18
|
+
if (Array.isArray(e.errors)) {
|
|
19
|
+
return e.errors.some(inner =>
|
|
20
|
+
inner && typeof inner === "object" && (inner as { code?: string }).code === "ECONNREFUSED"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
// Drizzle wraps the pg error in `cause`
|
|
24
|
+
if (e.cause && typeof e.cause === "object") {
|
|
25
|
+
return isEconnrefused(e.cause);
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Detect PostgreSQL authentication failures.
|
|
32
|
+
* PG error codes: 28P01 (invalid_password), 28000 (invalid_authorization_specification)
|
|
33
|
+
*/
|
|
34
|
+
export function isAuthFailure(err: unknown): boolean {
|
|
35
|
+
if (!err || typeof err !== "object") return false;
|
|
36
|
+
const e = err as { code?: string; cause?: unknown };
|
|
37
|
+
if (e.code === "28P01" || e.code === "28000") return true;
|
|
38
|
+
if (e.cause && typeof e.cause === "object") {
|
|
39
|
+
return isAuthFailure(e.cause);
|
|
40
|
+
}
|
|
41
|
+
// Also check the message for common pg auth failure text
|
|
42
|
+
if ("message" in e && typeof (e as { message?: string }).message === "string") {
|
|
43
|
+
const msg = (e as { message: string }).message.toLowerCase();
|
|
44
|
+
if (msg.includes("password authentication failed") || msg.includes("no pg_hba.conf entry")) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse host:port from a DATABASE_URL for display purposes.
|
|
53
|
+
*/
|
|
54
|
+
function parseHostInfo(databaseUrl: string): string {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = new URL(databaseUrl);
|
|
57
|
+
return `${parsed.hostname}:${parsed.port || 5432}`;
|
|
58
|
+
} catch {
|
|
59
|
+
return "unknown";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Format a diagnostic banner for ECONNREFUSED errors.
|
|
65
|
+
*/
|
|
66
|
+
function formatConnectionRefusedBanner(databaseUrl: string): string {
|
|
67
|
+
const hostInfo = parseHostInfo(databaseUrl);
|
|
68
|
+
return (
|
|
69
|
+
`\n` +
|
|
70
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
|
|
71
|
+
` ❌ Cannot connect to PostgreSQL at ${hostInfo}\n` +
|
|
72
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
|
|
73
|
+
`\n` +
|
|
74
|
+
` The database server is not running or is not accepting\n` +
|
|
75
|
+
` connections. Common fixes:\n` +
|
|
76
|
+
`\n` +
|
|
77
|
+
` • brew services start postgresql@18\n` +
|
|
78
|
+
` • docker compose up -d postgres\n` +
|
|
79
|
+
` • Verify DATABASE_URL in your .env file\n` +
|
|
80
|
+
`\n` +
|
|
81
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format a diagnostic banner for authentication failures.
|
|
87
|
+
*/
|
|
88
|
+
function formatAuthFailureBanner(databaseUrl: string): string {
|
|
89
|
+
const hostInfo = parseHostInfo(databaseUrl);
|
|
90
|
+
let username = "unknown";
|
|
91
|
+
try {
|
|
92
|
+
username = new URL(databaseUrl).username || "unknown";
|
|
93
|
+
} catch { /* ignore */ }
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
`\n` +
|
|
97
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
|
|
98
|
+
` ❌ Authentication failed for user "${username}" at ${hostInfo}\n` +
|
|
99
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
|
|
100
|
+
`\n` +
|
|
101
|
+
` PostgreSQL rejected the credentials. Common fixes:\n` +
|
|
102
|
+
`\n` +
|
|
103
|
+
` • Check the username and password in DATABASE_URL\n` +
|
|
104
|
+
` • Verify the user exists: psql -c "\\du"\n` +
|
|
105
|
+
` • Reset the password: ALTER USER ${username} PASSWORD 'new_password';\n` +
|
|
106
|
+
`\n` +
|
|
107
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Pre-flight check: verify that the database is reachable before running
|
|
113
|
+
* a heavy subprocess (Atlas, migrations, etc.).
|
|
114
|
+
*
|
|
115
|
+
* Exits with code 1 and a friendly banner on known failure modes.
|
|
116
|
+
* On unknown errors, logs a warning and allows the caller to proceed.
|
|
117
|
+
*/
|
|
118
|
+
export async function checkDatabaseConnectivity(databaseUrl: string): Promise<void> {
|
|
119
|
+
let client: import("pg").Client | undefined;
|
|
120
|
+
try {
|
|
121
|
+
const { Client } = await import("pg");
|
|
122
|
+
client = new Client({
|
|
123
|
+
connectionString: databaseUrl,
|
|
124
|
+
connectionTimeoutMillis: 5000
|
|
125
|
+
});
|
|
126
|
+
await client.connect();
|
|
127
|
+
await client.query("SELECT 1");
|
|
128
|
+
} catch (err: unknown) {
|
|
129
|
+
if (isEconnrefused(err)) {
|
|
130
|
+
logger.error(formatConnectionRefusedBanner(databaseUrl));
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
if (isAuthFailure(err)) {
|
|
134
|
+
logger.error(formatAuthFailureBanner(databaseUrl));
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
// Unknown error — warn but don't block; let the downstream tool surface details
|
|
138
|
+
logger.warn(chalk.yellow(` ⚠ Could not verify database connectivity: ${err instanceof Error ? err.message : String(err)}`));
|
|
139
|
+
logger.warn(chalk.gray(" Proceeding anyway — the command may fail if the database is unreachable."));
|
|
140
|
+
} finally {
|
|
141
|
+
try {
|
|
142
|
+
await client?.end();
|
|
143
|
+
} catch {
|
|
144
|
+
// ignore cleanup errors
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Post-hoc error diagnosis for direct database operations (e.g. applyPolicies).
|
|
151
|
+
* Returns a formatted diagnostic string if the error matches a known pattern,
|
|
152
|
+
* or null if unrecognized.
|
|
153
|
+
*/
|
|
154
|
+
export function diagnoseDbError(err: unknown, databaseUrl?: string): string | null {
|
|
155
|
+
if (isEconnrefused(err)) {
|
|
156
|
+
return formatConnectionRefusedBanner(databaseUrl || "");
|
|
157
|
+
}
|
|
158
|
+
if (isAuthFailure(err)) {
|
|
159
|
+
return formatAuthFailureBanner(databaseUrl || "");
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import { logger } from "@rebasepro/server-core";
|
|
7
|
+
import type { EntityCollection, Relation } from "@rebasepro/types";
|
|
8
|
+
|
|
9
|
+
const getHelpersDirname = () => {
|
|
10
|
+
try {
|
|
11
|
+
return eval("__dirname");
|
|
12
|
+
} catch {
|
|
13
|
+
const err = new Error();
|
|
14
|
+
const stackLine = err.stack?.split("\n")[1] || "";
|
|
15
|
+
const match = stackLine.match(/\((.*?):\d+:\d+\)/) || stackLine.match(/at (.*?):\d+:\d+/);
|
|
16
|
+
if (match && match[1]) {
|
|
17
|
+
let cleanPath = match[1];
|
|
18
|
+
if (cleanPath.startsWith("file://")) {
|
|
19
|
+
cleanPath = fileURLToPath(cleanPath);
|
|
20
|
+
}
|
|
21
|
+
return path.dirname(cleanPath);
|
|
22
|
+
}
|
|
23
|
+
return process.cwd();
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const __helpersDirname = getHelpersDirname();
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
export function resolveLocalBin(binName: string): string | null {
|
|
31
|
+
// Try to find node_modules/.bin upwards from __helpersDirname first (package-relative)
|
|
32
|
+
let dir = __helpersDirname;
|
|
33
|
+
while (true) {
|
|
34
|
+
const candidate = path.join(dir, "node_modules", ".bin", binName);
|
|
35
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
36
|
+
const parent = path.dirname(dir);
|
|
37
|
+
if (parent === dir) break;
|
|
38
|
+
dir = parent;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let cwd = process.cwd();
|
|
42
|
+
// Try to find node_modules/.bin upwards from process.cwd()
|
|
43
|
+
while (true) {
|
|
44
|
+
const candidate = path.join(cwd, "node_modules", ".bin", binName);
|
|
45
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
46
|
+
const parent = path.dirname(cwd);
|
|
47
|
+
if (parent === cwd) break;
|
|
48
|
+
cwd = parent;
|
|
49
|
+
}
|
|
50
|
+
// Fall back to globally installed binary via which/where
|
|
51
|
+
try {
|
|
52
|
+
const cmd = process.platform === "win32" ? `where ${binName}` : `which ${binName}`;
|
|
53
|
+
const globalPath = execSync(cmd, { encoding: "utf-8" }).trim().split("\n")[0].trim();
|
|
54
|
+
if (globalPath && fs.existsSync(globalPath)) return globalPath;
|
|
55
|
+
} catch {
|
|
56
|
+
// not found globally
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function getTableIncludesFromCollections(collections: EntityCollection[]): Promise<string[]> {
|
|
62
|
+
const { getTableName, resolveCollectionRelations } = await import("@rebasepro/common");
|
|
63
|
+
const { isPostgresCollection } = await import("@rebasepro/types");
|
|
64
|
+
|
|
65
|
+
const includes: string[] = [];
|
|
66
|
+
for (const col of collections) {
|
|
67
|
+
const tableName = getTableName(col);
|
|
68
|
+
const schema = isPostgresCollection(col) && col.schema ? col.schema : "public";
|
|
69
|
+
if (tableName) {
|
|
70
|
+
includes.push(`${schema}.${tableName}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const resolvedRelations = resolveCollectionRelations(col);
|
|
74
|
+
for (const relation of Object.values(resolvedRelations) as Relation[]) {
|
|
75
|
+
if (relation.through) {
|
|
76
|
+
const junctionTableName = relation.through.table;
|
|
77
|
+
const targetCollection = relation.target();
|
|
78
|
+
const targetSchema = isPostgresCollection(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
79
|
+
includes.push(`${targetSchema}.${junctionTableName}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return Array.from(new Set(includes));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function getTableIncludes(collectionsPath: string): Promise<string[]> {
|
|
88
|
+
const resolvedPath = path.resolve(collectionsPath);
|
|
89
|
+
const collections: EntityCollection[] = [];
|
|
90
|
+
if (fs.existsSync(resolvedPath)) {
|
|
91
|
+
const stats = fs.statSync(resolvedPath);
|
|
92
|
+
if (stats.isDirectory()) {
|
|
93
|
+
const files = fs.readdirSync(resolvedPath);
|
|
94
|
+
for (const file of files) {
|
|
95
|
+
if ((file.endsWith(".ts") || file.endsWith(".js")) &&
|
|
96
|
+
!file.includes(".test.") &&
|
|
97
|
+
!file.endsWith(".d.ts") &&
|
|
98
|
+
file !== "index.ts" && file !== "index.js") {
|
|
99
|
+
try {
|
|
100
|
+
const filePath = path.join(resolvedPath, file);
|
|
101
|
+
const fileUrl = pathToFileURL(filePath).href;
|
|
102
|
+
const module = await import(fileUrl);
|
|
103
|
+
if (module && module.default) {
|
|
104
|
+
collections.push(module.default);
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// ignore
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return getTableIncludesFromCollections(collections);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getDevDatabaseUrl(databaseUrl: string): string {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = new URL(databaseUrl);
|
|
120
|
+
const dbName = parsed.pathname.slice(1);
|
|
121
|
+
parsed.pathname = `/${dbName}_dev_diff`;
|
|
122
|
+
return parsed.toString();
|
|
123
|
+
} catch {
|
|
124
|
+
return databaseUrl + "_dev_diff";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function ensureDevDatabaseExists(databaseUrl: string, devDatabaseUrl: string) {
|
|
129
|
+
try {
|
|
130
|
+
const { Client } = await import("pg");
|
|
131
|
+
const parsed = new URL(databaseUrl);
|
|
132
|
+
const devDbName = new URL(devDatabaseUrl).pathname.slice(1);
|
|
133
|
+
|
|
134
|
+
parsed.pathname = "/postgres";
|
|
135
|
+
const client = new Client({ connectionString: parsed.toString() });
|
|
136
|
+
await client.connect();
|
|
137
|
+
try {
|
|
138
|
+
const res = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [devDbName]);
|
|
139
|
+
if (res.rowCount === 0) {
|
|
140
|
+
await client.query(`CREATE DATABASE "${devDbName}"`);
|
|
141
|
+
logger.info(chalk.gray(` ✓ Created validation database "${devDbName}"`));
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
await client.end();
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// Ignore, let Atlas handle connection failures
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function getTableExcludes(databaseUrl: string, collectionsPath: string): Promise<string[]> {
|
|
152
|
+
const includes = await getTableIncludes(collectionsPath);
|
|
153
|
+
const excludes: string[] = ["atlas_schema_revisions.*", "auth.*"];
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const { Client } = await import("pg");
|
|
157
|
+
const client = new Client({ connectionString: databaseUrl });
|
|
158
|
+
await client.connect();
|
|
159
|
+
try {
|
|
160
|
+
const res = await client.query(`
|
|
161
|
+
SELECT table_schema || '.' || table_name AS full_name
|
|
162
|
+
FROM information_schema.tables
|
|
163
|
+
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
164
|
+
AND table_type IN ('BASE TABLE', 'VIEW');
|
|
165
|
+
`);
|
|
166
|
+
|
|
167
|
+
const existingTables = res.rows.map((row: { full_name: string }) => row.full_name);
|
|
168
|
+
|
|
169
|
+
for (const table of existingTables) {
|
|
170
|
+
if (!includes.includes(table)) {
|
|
171
|
+
excludes.push(table);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
await client.end();
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
logger.warn(chalk.yellow(` ⚠️ Failed to query database for unmapped tables: ${err instanceof Error ? err.message : String(err)}`));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return excludes;
|
|
182
|
+
}
|
|
183
|
+
|