@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.
Files changed (75) hide show
  1. package/package.json +24 -18
  2. package/src/PostgresBackendDriver.ts +65 -44
  3. package/src/PostgresBootstrapper.ts +34 -2
  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/schema/auth-default-policies.ts +90 -0
  10. package/src/schema/auth-schema.ts +25 -2
  11. package/src/schema/doctor.ts +2 -4
  12. package/src/schema/generate-drizzle-schema-logic.ts +4 -3
  13. package/src/schema/generate-drizzle-schema.ts +3 -5
  14. package/src/schema/generate-postgres-ddl-logic.ts +524 -0
  15. package/src/schema/generate-postgres-ddl.ts +116 -0
  16. package/src/services/EntityPersistService.ts +10 -8
  17. package/src/services/entityService.ts +28 -3
  18. package/src/utils/pg-error-utils.ts +16 -0
  19. package/src/utils/table-classification.ts +16 -0
  20. package/src/websocket.ts +9 -0
  21. package/test/auth-default-policies.test.ts +89 -0
  22. package/test/cli-helpers-extended.test.ts +324 -0
  23. package/test/cli-helpers.test.ts +59 -0
  24. package/test/connection.test.ts +292 -0
  25. package/test/databasePoolManager.test.ts +289 -0
  26. package/test/doctor-extended.test.ts +443 -0
  27. package/test/e2e/db-e2e.test.ts +293 -0
  28. package/test/e2e/pg-setup.ts +79 -0
  29. package/test/entity-persist-composite-keys.test.ts +451 -0
  30. package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
  31. package/test/generate-postgres-ddl.test.ts +300 -0
  32. package/test/mfa-service.test.ts +544 -0
  33. package/test/pg-error-utils.test.ts +50 -1
  34. package/test/realtimeService-channels.test.ts +696 -0
  35. package/test/unmapped-tables-safety.test.ts +55 -342
  36. package/vitest.e2e.config.ts +10 -0
  37. package/build-errors.txt +0 -37
  38. package/dist/PostgresAdapter.d.ts +0 -6
  39. package/dist/PostgresBackendDriver.d.ts +0 -110
  40. package/dist/PostgresBootstrapper.d.ts +0 -46
  41. package/dist/auth/ensure-tables.d.ts +0 -10
  42. package/dist/auth/services.d.ts +0 -231
  43. package/dist/cli.d.ts +0 -1
  44. package/dist/collections/PostgresCollectionRegistry.d.ts +0 -47
  45. package/dist/connection.d.ts +0 -65
  46. package/dist/data-transformer.d.ts +0 -55
  47. package/dist/databasePoolManager.d.ts +0 -20
  48. package/dist/history/HistoryService.d.ts +0 -71
  49. package/dist/history/ensure-history-table.d.ts +0 -7
  50. package/dist/index.d.ts +0 -14
  51. package/dist/index.es.js +0 -10803
  52. package/dist/index.es.js.map +0 -1
  53. package/dist/interfaces.d.ts +0 -18
  54. package/dist/schema/auth-schema.d.ts +0 -2149
  55. package/dist/schema/doctor-cli.d.ts +0 -2
  56. package/dist/schema/doctor.d.ts +0 -52
  57. package/dist/schema/generate-drizzle-schema-logic.d.ts +0 -2
  58. package/dist/schema/generate-drizzle-schema.d.ts +0 -1
  59. package/dist/schema/introspect-db-inference.d.ts +0 -5
  60. package/dist/schema/introspect-db-logic.d.ts +0 -118
  61. package/dist/schema/introspect-db.d.ts +0 -1
  62. package/dist/schema/test-schema.d.ts +0 -24
  63. package/dist/services/BranchService.d.ts +0 -47
  64. package/dist/services/EntityFetchService.d.ts +0 -214
  65. package/dist/services/EntityPersistService.d.ts +0 -40
  66. package/dist/services/RelationService.d.ts +0 -98
  67. package/dist/services/entity-helpers.d.ts +0 -38
  68. package/dist/services/entityService.d.ts +0 -110
  69. package/dist/services/index.d.ts +0 -4
  70. package/dist/services/realtimeService.d.ts +0 -220
  71. package/dist/types.d.ts +0 -3
  72. package/dist/utils/drizzle-conditions.d.ts +0 -138
  73. package/dist/utils/pg-array-null-patch.d.ts +0 -16
  74. package/dist/utils/pg-error-utils.d.ts +0 -55
  75. package/dist/websocket.d.ts +0 -11
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgresql",
3
3
  "type": "module",
4
- "version": "0.6.1",
4
+ "version": "0.7.0",
5
5
  "description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -29,11 +29,23 @@
29
29
  "backend",
30
30
  "rebase"
31
31
  ],
32
+ "scripts": {
33
+ "watch": "vite build --watch",
34
+ "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
35
+ "test:lint": "eslint \"src/**\" --quiet",
36
+ "test": "jest --passWithNoTests",
37
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
38
+ "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
39
+ },
32
40
  "jest": {
33
41
  "transform": {
34
42
  "^.+\\.tsx?$": "ts-jest"
35
43
  },
36
44
  "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
45
+ "testPathIgnorePatterns": [
46
+ "/node_modules/",
47
+ "test/e2e/"
48
+ ],
37
49
  "moduleFileExtensions": [
38
50
  "ts",
39
51
  "tsx",
@@ -62,42 +74,36 @@
62
74
  "./package.json": "./package.json"
63
75
  },
64
76
  "dependencies": {
77
+ "@rebasepro/common": "workspace:*",
78
+ "@rebasepro/sdk-generator": "workspace:*",
79
+ "@rebasepro/server-core": "workspace:*",
80
+ "@rebasepro/types": "workspace:*",
81
+ "@rebasepro/utils": "workspace:*",
65
82
  "arg": "^5.0.2",
66
- "chalk": "^5.6.2",
83
+ "chalk": "^4.1.2",
67
84
  "chokidar": "5.0.0",
68
85
  "dotenv": "^17.4.2",
69
86
  "drizzle-orm": "^0.45.2",
70
87
  "execa": "^9.6.1",
71
88
  "hono": "^4.12.25",
72
89
  "pg": "^8.21.0",
73
- "ws": "^8.21.0",
74
- "@rebasepro/types": "0.6.1",
75
- "@rebasepro/server-core": "0.6.1",
76
- "@rebasepro/sdk-generator": "0.6.1",
77
- "@rebasepro/common": "0.6.1",
78
- "@rebasepro/utils": "0.6.1"
90
+ "ws": "^8.21.0"
79
91
  },
80
92
  "devDependencies": {
93
+ "@ariga/atlas": "^1.2.2",
81
94
  "@types/jest": "^30.0.0",
82
95
  "@types/node": "^25.9.3",
83
96
  "@types/pg": "^8.20.0",
84
97
  "@types/ws": "^8.18.1",
85
98
  "@vitejs/plugin-react": "^6.0.2",
86
- "drizzle-kit": "^0.31.10",
87
99
  "jest": "^30.4.2",
88
100
  "ts-jest": "^29.4.11",
89
101
  "typescript": "^6.0.3",
90
- "vite": "^8.0.16"
102
+ "vite": "^8.0.16",
103
+ "vitest": "^4.1.9"
91
104
  },
92
105
  "gitHead": "d935eefa5aa8d1009a2398cfac2c1e4ee9aeb6b6",
93
106
  "publishConfig": {
94
107
  "access": "public"
95
- },
96
- "scripts": {
97
- "watch": "vite build --watch",
98
- "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
99
- "test:lint": "eslint \"src/**\" --quiet",
100
- "test": "jest --passWithNoTests",
101
- "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
102
108
  }
103
- }
109
+ }
@@ -31,6 +31,8 @@ import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegi
31
31
  import { HistoryService } from "./history/HistoryService";
32
32
  import { mergeDeep } from "@rebasepro/utils";
33
33
  import { logger } from "@rebasepro/server-core";
34
+ import { isRoleSwitchingPermissionError } from "./utils/pg-error-utils";
35
+ import { classifyTable, detectJunctionTables } from "./utils/table-classification";
34
36
 
35
37
  export class PostgresBackendDriver implements DataDriver {
36
38
  key = "postgres";
@@ -44,6 +46,14 @@ export class PostgresBackendDriver implements DataDriver {
44
46
  public data: RebaseData;
45
47
  public client?: RebaseClient;
46
48
 
49
+ /**
50
+ * Auto-set to `true` when a SET LOCAL ROLE fails with insufficient
51
+ * privileges, so subsequent queries skip the doomed attempt.
52
+ * Mirrors the static `DISABLE_DB_ROLE_SWITCHING` env var but is
53
+ * learned at runtime.
54
+ */
55
+ private _roleSwitchingDisabled = false;
56
+
47
57
  /**
48
58
  * When true, realtime notifications are deferred until after the
49
59
  * wrapping transaction commits. Set by `withAuth` → `withTransaction`.
@@ -684,10 +694,11 @@ export class PostgresBackendDriver implements DataDriver {
684
694
 
685
695
  async executeSql(sqlText: string, options?: {
686
696
  database?: string,
687
- role?: string
697
+ role?: string,
698
+ params?: unknown[]
688
699
  }): Promise<Record<string, unknown>[]> {
689
700
  if (!options?.database && !options?.role) {
690
- return this.entityService.executeSql(sqlText);
701
+ return this.entityService.executeSql(sqlText, options?.params);
691
702
  }
692
703
 
693
704
  const targetDb = this.getTargetDb(options?.database);
@@ -698,7 +709,7 @@ export class PostgresBackendDriver implements DataDriver {
698
709
  // as it's a no-op that can fail on managed Postgres setups where the connection
699
710
  // user doesn't have permission to SET ROLE.
700
711
  let needsRoleSwitch = false;
701
- if (options?.role && process.env.DISABLE_DB_ROLE_SWITCHING !== "true") {
712
+ if (options?.role && process.env.DISABLE_DB_ROLE_SWITCHING !== "true" && !this._roleSwitchingDisabled) {
702
713
  try {
703
714
  const currentRoleResult = await targetDb.execute(drizzleSql.raw("SELECT current_user AS role"));
704
715
  const currentRole = (currentRoleResult.rows?.[0] as Record<string, unknown>)?.role as string | undefined;
@@ -711,14 +722,57 @@ export class PostgresBackendDriver implements DataDriver {
711
722
 
712
723
  if (needsRoleSwitch && options?.role) {
713
724
  const safeRole = options.role.replace(/"/g, "\"\"");
714
- return await targetDb.transaction(async (tx) => {
715
- await tx.execute(drizzleSql.raw(`SET LOCAL ROLE "${safeRole}"`));
716
- const result = await tx.execute(drizzleSql.raw(sqlText));
717
- return result.rows as Record<string, unknown>[];
718
- });
725
+ try {
726
+ return await targetDb.transaction(async (tx) => {
727
+ await tx.execute(drizzleSql.raw(`SET LOCAL ROLE "${safeRole}"`));
728
+ let result;
729
+ if (options?.params && options.params.length > 0) {
730
+ const parts = sqlText.split(/\$(\d+)/);
731
+ const chunks: ReturnType<typeof drizzleSql.raw | typeof drizzleSql.param>[] = [];
732
+ for (let i = 0; i < parts.length; i++) {
733
+ if (i % 2 === 0) {
734
+ if (parts[i].length > 0) chunks.push(drizzleSql.raw(parts[i]));
735
+ } else {
736
+ chunks.push(drizzleSql.param(options.params[Number(parts[i]) - 1]));
737
+ }
738
+ }
739
+ result = await tx.execute(drizzleSql.join(chunks, drizzleSql.raw("")));
740
+ } else {
741
+ result = await tx.execute(drizzleSql.raw(sqlText));
742
+ }
743
+ return result.rows as Record<string, unknown>[];
744
+ });
745
+ } catch (roleError: unknown) {
746
+ if (isRoleSwitchingPermissionError(roleError)) {
747
+ logger.warn(
748
+ `[PostgresBackendDriver] SET LOCAL ROLE "${safeRole}" failed — ` +
749
+ `the connection user lacks permission. Falling back to executing ` +
750
+ `without role switching. To suppress this warning, set ` +
751
+ `DISABLE_DB_ROLE_SWITCHING=true in your .env file.`
752
+ );
753
+ this._roleSwitchingDisabled = true;
754
+ // Fall through to execute without role switching below
755
+ } else {
756
+ throw roleError;
757
+ }
758
+ }
719
759
  }
720
760
 
721
- const result = await targetDb.execute(drizzleSql.raw(sqlText));
761
+ let result;
762
+ if (options?.params && options.params.length > 0) {
763
+ const parts = sqlText.split(/\$(\d+)/);
764
+ const chunks: ReturnType<typeof drizzleSql.raw | typeof drizzleSql.param>[] = [];
765
+ for (let i = 0; i < parts.length; i++) {
766
+ if (i % 2 === 0) {
767
+ if (parts[i].length > 0) chunks.push(drizzleSql.raw(parts[i]));
768
+ } else {
769
+ chunks.push(drizzleSql.param(options.params[Number(parts[i]) - 1]));
770
+ }
771
+ }
772
+ result = await targetDb.execute(drizzleSql.join(chunks, drizzleSql.raw("")));
773
+ } else {
774
+ result = await targetDb.execute(drizzleSql.raw(sqlText));
775
+ }
722
776
  return result.rows as Record<string, unknown>[];
723
777
  } catch (error: unknown) {
724
778
  const msg = error instanceof Error ? error.message : String(error);
@@ -780,48 +834,15 @@ export class PostgresBackendDriver implements DataDriver {
780
834
  ORDER BY table_name;
781
835
  `);
782
836
 
783
- const internalPrefixes = ["_rebase_", "_auth_"];
784
- const internalExact = [
785
- "users", "roles", "user_roles", "refresh_tokens",
786
- "password_reset_tokens", "email_verification_tokens"
787
- ];
788
-
789
837
  const allTables = result
790
838
  .map((r: Record<string, unknown>) => r.table_name as string)
791
- .filter((name: string) => {
792
- if (internalPrefixes.some(prefix => name.startsWith(prefix))) return false;
793
- if (internalExact.includes(name)) return false;
794
- return true;
795
- });
839
+ .filter((name: string) => classifyTable(name, "public") !== "rebase-internal");
796
840
 
797
841
  // Detect junction tables: tables where every column is part of a foreign key.
798
842
  // These are typically many-to-many connection tables and shouldn't be suggested.
799
843
  let junctionTables = new Set<string>();
800
844
  try {
801
- const junctionResult = await this.executeSql(`
802
- SELECT t.table_name
803
- FROM information_schema.tables t
804
- WHERE t.table_schema = 'public'
805
- AND t.table_type = 'BASE TABLE'
806
- AND NOT EXISTS (
807
- -- Find columns that are NOT part of any foreign key
808
- SELECT 1
809
- FROM information_schema.columns c
810
- WHERE c.table_schema = t.table_schema
811
- AND c.table_name = t.table_name
812
- AND c.column_name NOT IN (
813
- SELECT kcu.column_name
814
- FROM information_schema.key_column_usage kcu
815
- JOIN information_schema.table_constraints tc
816
- ON tc.constraint_name = kcu.constraint_name
817
- AND tc.table_schema = kcu.table_schema
818
- WHERE tc.constraint_type = 'FOREIGN KEY'
819
- AND kcu.table_schema = t.table_schema
820
- AND kcu.table_name = t.table_name
821
- )
822
- );
823
- `);
824
- junctionTables = new Set(junctionResult.map((r: Record<string, unknown>) => r.table_name as string));
845
+ junctionTables = await detectJunctionTables(this.executeSql.bind(this));
825
846
  } catch (e) {
826
847
  logger.warn("Could not detect junction tables", { error: e });
827
848
  }
@@ -55,6 +55,9 @@ export interface PostgresDriverInternals {
55
55
  poolManager?: DatabasePoolManager;
56
56
  }
57
57
 
58
+ // Re-export from shared CLI error utilities
59
+ import { isEconnrefused } from "./cli-errors";
60
+
58
61
  /**
59
62
  * Default PostgreSQL bootstrapper.
60
63
  *
@@ -117,10 +120,39 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
117
120
  : connection) as import("pg").Pool;
118
121
  const schemaAwareDb = createDrizzle(rawClient, { schema: mergedSchema });
119
122
 
120
- // Verify connection
123
+ // Verify connection — fail fast if the database is unreachable
121
124
  try {
122
125
  await schemaAwareDb.execute(sql`SELECT 1`);
123
- } catch (err) {
126
+ } catch (err: unknown) {
127
+ const isConnectionRefused = isEconnrefused(err);
128
+ if (isConnectionRefused) {
129
+ // Parse host/port from connection string for a helpful message
130
+ let hostInfo = pgConfig.connectionString || "unknown";
131
+ try {
132
+ const parsed = new URL(pgConfig.connectionString || "");
133
+ hostInfo = `${parsed.hostname}:${parsed.port || 5432}`;
134
+ } catch { /* use raw string */ }
135
+
136
+ const message =
137
+ `\n` +
138
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
139
+ ` ❌ Cannot connect to PostgreSQL at ${hostInfo}\n` +
140
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
141
+ `\n` +
142
+ ` The database server is not running or is not accepting\n` +
143
+ ` connections. Common fixes:\n` +
144
+ `\n` +
145
+ ` • brew services start postgresql@18\n` +
146
+ ` • docker compose up -d postgres\n` +
147
+ ` • Verify DATABASE_URL in your .env file\n` +
148
+ `\n` +
149
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
150
+ logger.error(message);
151
+ throw new Error(`Cannot connect to PostgreSQL at ${hostInfo}: connection refused. Is the database running?`);
152
+ }
153
+
154
+ // For other errors (timeouts, auth failures, etc.) warn but continue —
155
+ // the pool may recover on subsequent attempts.
124
156
  logger.error("❌ Failed to connect to PostgreSQL", { error: err });
125
157
  logger.warn("⚠️ Continuing without initial database verification. Drizzle/PG will attempt to connect on subsequent queries.");
126
158
  }
@@ -82,7 +82,37 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Ent
82
82
  const passwordResetTokensTableName = `"${authSchema}"."password_reset_tokens"`;
83
83
  const appConfigTableName = `"${authSchema}"."app_config"`;
84
84
 
85
- // ── Create tables (idempotent) ──────────────────────────────────
85
+ // ── Create users table (idempotent) ─────────────────────────────
86
+ // The users table MUST be created before any dependent auth tables
87
+ // (user_identities, refresh_tokens, etc.) because they all hold
88
+ // foreign keys referencing users(id). When a developer runs
89
+ // `pnpm dev` for the first time without `db:migrate`, this ensures
90
+ // the server can self-bootstrap.
91
+ const idDefault = userIdType === "UUID"
92
+ ? "DEFAULT gen_random_uuid()"
93
+ : userIdType === "INTEGER"
94
+ ? "GENERATED ALWAYS AS IDENTITY"
95
+ : "DEFAULT gen_random_uuid()::text";
96
+
97
+ await db.execute(sql`
98
+ CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
99
+ id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},
100
+ email VARCHAR(255) UNIQUE NOT NULL,
101
+ display_name VARCHAR(255),
102
+ photo_url VARCHAR(500),
103
+ roles TEXT[] DEFAULT '{}' NOT NULL,
104
+ password_hash VARCHAR(255),
105
+ email_verified BOOLEAN DEFAULT FALSE NOT NULL,
106
+ email_verification_token VARCHAR(255),
107
+ email_verification_sent_at TIMESTAMP WITH TIME ZONE,
108
+ is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
109
+ metadata JSONB DEFAULT '{}' NOT NULL,
110
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
111
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
112
+ )
113
+ `);
114
+
115
+ // ── Create dependent auth tables (idempotent) ───────────────────
86
116
 
87
117
  // Create user_identities table
88
118
  await db.execute(sql`
@@ -155,6 +185,31 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Ent
155
185
  ON ${sql.raw(passwordResetTokensTableName)}(user_id)
156
186
  `);
157
187
 
188
+ // Create magic link tokens table
189
+ const magicLinkTokensTableName = `"${authSchema}"."magic_link_tokens"`;
190
+ await db.execute(sql`
191
+ CREATE TABLE IF NOT EXISTS ${sql.raw(magicLinkTokensTableName)} (
192
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
193
+ user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
194
+ token_hash TEXT NOT NULL UNIQUE,
195
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
196
+ used_at TIMESTAMP WITH TIME ZONE,
197
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
198
+ )
199
+ `);
200
+
201
+ // Create index on token_hash for magic link lookups
202
+ await db.execute(sql`
203
+ CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_hash
204
+ ON ${sql.raw(magicLinkTokensTableName)}(token_hash)
205
+ `);
206
+
207
+ // Create index on user_id for magic link cleanup
208
+ await db.execute(sql`
209
+ CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user
210
+ ON ${sql.raw(magicLinkTokensTableName)}(user_id)
211
+ `);
212
+
158
213
  // Create app config table
159
214
  await db.execute(sql`
160
215
  CREATE TABLE IF NOT EXISTS ${sql.raw(appConfigTableName)} (
@@ -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
+ }