@rebasepro/server-postgresql 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/package.json +24 -18
  2. package/src/PostgresBackendDriver.ts +65 -44
  3. package/src/PostgresBootstrapper.ts +49 -20
  4. package/src/auth/ensure-tables.ts +56 -1
  5. package/src/auth/services.ts +94 -1
  6. package/src/cli-errors.ts +162 -0
  7. package/src/cli-helpers.ts +183 -0
  8. package/src/cli.ts +198 -251
  9. package/src/data-transformer.ts +9 -1
  10. package/src/schema/auth-default-policies.ts +90 -0
  11. package/src/schema/auth-schema.ts +25 -2
  12. package/src/schema/doctor.ts +2 -4
  13. package/src/schema/generate-drizzle-schema-logic.ts +4 -3
  14. package/src/schema/generate-drizzle-schema.ts +3 -5
  15. package/src/schema/generate-postgres-ddl-logic.ts +524 -0
  16. package/src/schema/generate-postgres-ddl.ts +116 -0
  17. package/src/services/EntityPersistService.ts +18 -16
  18. package/src/services/entityService.ts +28 -3
  19. package/src/utils/pg-array-null-patch.ts +42 -0
  20. package/src/utils/pg-error-utils.ts +16 -0
  21. package/src/utils/table-classification.ts +16 -0
  22. package/src/websocket.ts +9 -0
  23. package/test/array-null-safety.test.ts +335 -0
  24. package/test/auth-default-policies.test.ts +89 -0
  25. package/test/cli-helpers-extended.test.ts +324 -0
  26. package/test/cli-helpers.test.ts +59 -0
  27. package/test/connection.test.ts +292 -0
  28. package/test/data-transformer.test.ts +53 -2
  29. package/test/databasePoolManager.test.ts +289 -0
  30. package/test/doctor-extended.test.ts +443 -0
  31. package/test/e2e/db-e2e.test.ts +293 -0
  32. package/test/e2e/pg-setup.ts +79 -0
  33. package/test/entity-persist-composite-keys.test.ts +451 -0
  34. package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
  35. package/test/generate-postgres-ddl.test.ts +300 -0
  36. package/test/mfa-service.test.ts +544 -0
  37. package/test/pg-array-null-patch.test.ts +65 -0
  38. package/test/pg-error-utils.test.ts +50 -1
  39. package/test/realtimeService-channels.test.ts +696 -0
  40. package/test/unmapped-tables-safety.test.ts +55 -342
  41. package/vite.config.ts +8 -6
  42. package/vitest.e2e.config.ts +10 -0
  43. package/build-errors.txt +0 -37
  44. package/dist/PostgresAdapter.d.ts +0 -6
  45. package/dist/PostgresBackendDriver.d.ts +0 -110
  46. package/dist/PostgresBootstrapper.d.ts +0 -46
  47. package/dist/auth/ensure-tables.d.ts +0 -10
  48. package/dist/auth/services.d.ts +0 -231
  49. package/dist/cli.d.ts +0 -1
  50. package/dist/collections/PostgresCollectionRegistry.d.ts +0 -47
  51. package/dist/connection.d.ts +0 -65
  52. package/dist/data-transformer.d.ts +0 -55
  53. package/dist/databasePoolManager.d.ts +0 -20
  54. package/dist/history/HistoryService.d.ts +0 -71
  55. package/dist/history/ensure-history-table.d.ts +0 -7
  56. package/dist/index.d.ts +0 -14
  57. package/dist/index.es.js +0 -10764
  58. package/dist/index.es.js.map +0 -1
  59. package/dist/index.umd.js +0 -11055
  60. package/dist/index.umd.js.map +0 -1
  61. package/dist/interfaces.d.ts +0 -18
  62. package/dist/schema/auth-schema.d.ts +0 -2149
  63. package/dist/schema/doctor-cli.d.ts +0 -2
  64. package/dist/schema/doctor.d.ts +0 -52
  65. package/dist/schema/generate-drizzle-schema-logic.d.ts +0 -2
  66. package/dist/schema/generate-drizzle-schema.d.ts +0 -1
  67. package/dist/schema/introspect-db-inference.d.ts +0 -5
  68. package/dist/schema/introspect-db-logic.d.ts +0 -118
  69. package/dist/schema/introspect-db.d.ts +0 -1
  70. package/dist/schema/test-schema.d.ts +0 -24
  71. package/dist/services/BranchService.d.ts +0 -47
  72. package/dist/services/EntityFetchService.d.ts +0 -214
  73. package/dist/services/EntityPersistService.d.ts +0 -40
  74. package/dist/services/RelationService.d.ts +0 -98
  75. package/dist/services/entity-helpers.d.ts +0 -38
  76. package/dist/services/entityService.d.ts +0 -110
  77. package/dist/services/index.d.ts +0 -4
  78. package/dist/services/realtimeService.d.ts +0 -220
  79. package/dist/types.d.ts +0 -3
  80. package/dist/utils/drizzle-conditions.d.ts +0 -138
  81. package/dist/utils/pg-error-utils.d.ts +0 -55
  82. package/dist/websocket.d.ts +0 -11
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgresql",
3
3
  "type": "module",
4
- "version": "0.6.0",
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/common": "0.6.0",
75
- "@rebasepro/types": "0.6.0",
76
- "@rebasepro/server-core": "0.6.0",
77
- "@rebasepro/sdk-generator": "0.6.0",
78
- "@rebasepro/utils": "0.6.0"
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
  }
@@ -4,41 +4,31 @@
4
4
  * Implements the `BackendBootstrapper` interface for PostgreSQL.
5
5
  */
6
6
 
7
- import { getTableName, isTable, Relations, sql, Table } from "drizzle-orm";
7
+ import { getTableName, isTable, Relations, sql } from "drizzle-orm";
8
8
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
9
- import { PgEnum, PgTable, getTableConfig } from "drizzle-orm/pg-core";
9
+ import { PgEnum, PgTable } from "drizzle-orm/pg-core";
10
10
  import type { RebasePgTable } from "./types";
11
11
  import {
12
+ type AuthAdapter,
12
13
  BackendBootstrapper,
13
- InitializedDriver,
14
14
  BootstrappedAuth,
15
15
  DatabaseAdmin,
16
- RealtimeProvider,
17
16
  type DataDriver,
18
- type AuthAdapter,
19
17
  EntityCollection,
20
- PostgresCollection
18
+ InitializedDriver,
19
+ RealtimeProvider
21
20
  } from "@rebasepro/types";
22
21
  import { PostgresBackendDriver } from "./PostgresBackendDriver";
23
22
  import { RealtimeService } from "./services/realtimeService";
24
23
  import { DatabasePoolManager } from "./databasePoolManager";
25
24
  import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
26
- import {
27
- createAuthRoutes,
28
- requireAuth,
29
- requireAdmin,
30
- logger
31
- } from "@rebasepro/server-core";
25
+ import { createEmailService, type EmailConfig, type EmailService, logger } from "@rebasepro/server-core";
32
26
  import { ensureAuthTablesExist } from "./auth/ensure-tables";
33
- import { UserService, PostgresAuthRepository, AuthSchemaTables } from "./auth/services";
27
+ import { AuthSchemaTables, PostgresAuthRepository, UserService } from "./auth/services";
34
28
  import { createAuthSchema } from "./schema/auth-schema";
35
-
36
- import { createEmailService, type EmailConfig, type EmailService } from "@rebasepro/server-core";
37
- import { createHistoryRoutes } from "@rebasepro/server-core";
38
29
  import { HistoryService } from "./history/HistoryService";
39
30
  import { ensureHistoryTableExists } from "./history/ensure-history-table";
40
- import type { Hono } from "hono";
41
- import type { HonoEnv } from "@rebasepro/server-core";
31
+ import { patchPgArrayNullSafety } from "./utils/pg-array-null-patch";
42
32
 
43
33
  export interface PostgresDriverConfig {
44
34
  connectionString?: string;
@@ -65,6 +55,9 @@ export interface PostgresDriverInternals {
65
55
  poolManager?: DatabasePoolManager;
66
56
  }
67
57
 
58
+ // Re-export from shared CLI error utilities
59
+ import { isEconnrefused } from "./cli-errors";
60
+
68
61
  /**
69
62
  * Default PostgreSQL bootstrapper.
70
63
  *
@@ -108,6 +101,13 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
108
101
  if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums as Record<string, PgEnum<[string, ...string[]]>>);
109
102
  if (pgConfig.schema?.relations) registry.registerRelations(pgConfig.schema.relations as Record<string, Relations>);
110
103
 
104
+ // Patch Drizzle's PgArray columns to handle NULL values safely.
105
+ // Drizzle's mapFromDriverValue crashes with "value.map is not a function"
106
+ // when a native array column (text[], integer[], etc.) contains NULL.
107
+ if (pgConfig.schema?.tables) {
108
+ patchPgArrayNullSafety(pgConfig.schema.tables as Record<string, unknown>);
109
+ }
110
+
111
111
  // Build schema-aware Drizzle connection
112
112
  const mergedSchema: Record<string, unknown> = {
113
113
  ...pgConfig.schema?.tables,
@@ -120,10 +120,39 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
120
120
  : connection) as import("pg").Pool;
121
121
  const schemaAwareDb = createDrizzle(rawClient, { schema: mergedSchema });
122
122
 
123
- // Verify connection
123
+ // Verify connection — fail fast if the database is unreachable
124
124
  try {
125
125
  await schemaAwareDb.execute(sql`SELECT 1`);
126
- } 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.
127
156
  logger.error("❌ Failed to connect to PostgreSQL", { error: err });
128
157
  logger.warn("⚠️ Continuing without initial database verification. Drizzle/PG will attempt to connect on subsequent queries.");
129
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;