@rebasepro/server-postgresql 0.0.1-canary.4d4fb3e

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 (147) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +106 -0
  3. package/build-errors.txt +37 -0
  4. package/dist/common/src/collections/CollectionRegistry.d.ts +48 -0
  5. package/dist/common/src/collections/index.d.ts +1 -0
  6. package/dist/common/src/data/buildRebaseData.d.ts +14 -0
  7. package/dist/common/src/index.d.ts +3 -0
  8. package/dist/common/src/util/builders.d.ts +57 -0
  9. package/dist/common/src/util/callbacks.d.ts +6 -0
  10. package/dist/common/src/util/collections.d.ts +11 -0
  11. package/dist/common/src/util/common.d.ts +2 -0
  12. package/dist/common/src/util/conditions.d.ts +26 -0
  13. package/dist/common/src/util/entities.d.ts +36 -0
  14. package/dist/common/src/util/enums.d.ts +3 -0
  15. package/dist/common/src/util/index.d.ts +16 -0
  16. package/dist/common/src/util/navigation_from_path.d.ts +34 -0
  17. package/dist/common/src/util/navigation_utils.d.ts +20 -0
  18. package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
  19. package/dist/common/src/util/paths.d.ts +14 -0
  20. package/dist/common/src/util/permissions.d.ts +5 -0
  21. package/dist/common/src/util/references.d.ts +2 -0
  22. package/dist/common/src/util/relations.d.ts +12 -0
  23. package/dist/common/src/util/resolutions.d.ts +72 -0
  24. package/dist/common/src/util/storage.d.ts +24 -0
  25. package/dist/index.es.js +10635 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/index.umd.js +10643 -0
  28. package/dist/index.umd.js.map +1 -0
  29. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +112 -0
  30. package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
  31. package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
  32. package/dist/server-postgresql/src/auth/services.d.ts +188 -0
  33. package/dist/server-postgresql/src/cli.d.ts +1 -0
  34. package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
  35. package/dist/server-postgresql/src/connection.d.ts +7 -0
  36. package/dist/server-postgresql/src/data-transformer.d.ts +36 -0
  37. package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
  38. package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
  39. package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
  40. package/dist/server-postgresql/src/index.d.ts +13 -0
  41. package/dist/server-postgresql/src/interfaces.d.ts +18 -0
  42. package/dist/server-postgresql/src/schema/auth-schema.d.ts +767 -0
  43. package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
  44. package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
  45. package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
  46. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +195 -0
  47. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
  48. package/dist/server-postgresql/src/services/RelationService.d.ts +92 -0
  49. package/dist/server-postgresql/src/services/entity-helpers.d.ts +24 -0
  50. package/dist/server-postgresql/src/services/entityService.d.ts +102 -0
  51. package/dist/server-postgresql/src/services/index.d.ts +4 -0
  52. package/dist/server-postgresql/src/services/realtimeService.d.ts +186 -0
  53. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
  54. package/dist/server-postgresql/src/websocket.d.ts +5 -0
  55. package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
  56. package/dist/types/src/controllers/auth.d.ts +117 -0
  57. package/dist/types/src/controllers/client.d.ts +58 -0
  58. package/dist/types/src/controllers/collection_registry.d.ts +44 -0
  59. package/dist/types/src/controllers/customization_controller.d.ts +54 -0
  60. package/dist/types/src/controllers/data.d.ts +141 -0
  61. package/dist/types/src/controllers/data_driver.d.ts +168 -0
  62. package/dist/types/src/controllers/database_admin.d.ts +11 -0
  63. package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
  64. package/dist/types/src/controllers/effective_role.d.ts +4 -0
  65. package/dist/types/src/controllers/index.d.ts +17 -0
  66. package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
  67. package/dist/types/src/controllers/navigation.d.ts +213 -0
  68. package/dist/types/src/controllers/registry.d.ts +51 -0
  69. package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
  70. package/dist/types/src/controllers/side_entity_controller.d.ts +89 -0
  71. package/dist/types/src/controllers/snackbar.d.ts +24 -0
  72. package/dist/types/src/controllers/storage.d.ts +173 -0
  73. package/dist/types/src/index.d.ts +4 -0
  74. package/dist/types/src/rebase_context.d.ts +101 -0
  75. package/dist/types/src/types/backend.d.ts +533 -0
  76. package/dist/types/src/types/builders.d.ts +14 -0
  77. package/dist/types/src/types/chips.d.ts +5 -0
  78. package/dist/types/src/types/collections.d.ts +812 -0
  79. package/dist/types/src/types/data_source.d.ts +64 -0
  80. package/dist/types/src/types/entities.d.ts +145 -0
  81. package/dist/types/src/types/entity_actions.d.ts +98 -0
  82. package/dist/types/src/types/entity_callbacks.d.ts +173 -0
  83. package/dist/types/src/types/entity_link_builder.d.ts +7 -0
  84. package/dist/types/src/types/entity_overrides.d.ts +9 -0
  85. package/dist/types/src/types/entity_views.d.ts +61 -0
  86. package/dist/types/src/types/export_import.d.ts +21 -0
  87. package/dist/types/src/types/index.d.ts +22 -0
  88. package/dist/types/src/types/locales.d.ts +4 -0
  89. package/dist/types/src/types/modify_collections.d.ts +5 -0
  90. package/dist/types/src/types/plugins.d.ts +225 -0
  91. package/dist/types/src/types/properties.d.ts +1091 -0
  92. package/dist/types/src/types/property_config.d.ts +70 -0
  93. package/dist/types/src/types/relations.d.ts +336 -0
  94. package/dist/types/src/types/slots.d.ts +228 -0
  95. package/dist/types/src/types/translations.d.ts +826 -0
  96. package/dist/types/src/types/user_management_delegate.d.ts +120 -0
  97. package/dist/types/src/types/websockets.d.ts +78 -0
  98. package/dist/types/src/users/index.d.ts +2 -0
  99. package/dist/types/src/users/roles.d.ts +22 -0
  100. package/dist/types/src/users/user.d.ts +46 -0
  101. package/jest-all.log +3128 -0
  102. package/jest.log +49 -0
  103. package/package.json +93 -0
  104. package/src/PostgresBackendDriver.ts +1024 -0
  105. package/src/PostgresBootstrapper.ts +232 -0
  106. package/src/auth/ensure-tables.ts +309 -0
  107. package/src/auth/services.ts +740 -0
  108. package/src/cli.ts +347 -0
  109. package/src/collections/PostgresCollectionRegistry.ts +96 -0
  110. package/src/connection.ts +62 -0
  111. package/src/data-transformer.ts +569 -0
  112. package/src/databasePoolManager.ts +84 -0
  113. package/src/history/HistoryService.ts +257 -0
  114. package/src/history/ensure-history-table.ts +45 -0
  115. package/src/index.ts +13 -0
  116. package/src/interfaces.ts +60 -0
  117. package/src/schema/auth-schema.ts +146 -0
  118. package/src/schema/generate-drizzle-schema-logic.ts +618 -0
  119. package/src/schema/generate-drizzle-schema.ts +151 -0
  120. package/src/services/BranchService.ts +237 -0
  121. package/src/services/EntityFetchService.ts +1447 -0
  122. package/src/services/EntityPersistService.ts +351 -0
  123. package/src/services/RelationService.ts +1012 -0
  124. package/src/services/entity-helpers.ts +121 -0
  125. package/src/services/entityService.ts +209 -0
  126. package/src/services/index.ts +13 -0
  127. package/src/services/realtimeService.ts +1005 -0
  128. package/src/utils/drizzle-conditions.ts +999 -0
  129. package/src/websocket.ts +487 -0
  130. package/test/auth-services.test.ts +569 -0
  131. package/test/branchService.test.ts +357 -0
  132. package/test/drizzle-conditions.test.ts +895 -0
  133. package/test/entityService.errors.test.ts +352 -0
  134. package/test/entityService.relations.test.ts +912 -0
  135. package/test/entityService.subcollection-search.test.ts +516 -0
  136. package/test/entityService.test.ts +977 -0
  137. package/test/generate-drizzle-schema.test.ts +795 -0
  138. package/test/historyService.test.ts +126 -0
  139. package/test/postgresDataDriver.test.ts +556 -0
  140. package/test/realtimeService.test.ts +276 -0
  141. package/test/relations.test.ts +662 -0
  142. package/test_drizzle_mock.js +3 -0
  143. package/test_find_changed.mjs +30 -0
  144. package/test_output.txt +3145 -0
  145. package/tsconfig.json +49 -0
  146. package/tsconfig.prod.json +20 -0
  147. package/vite.config.ts +82 -0
@@ -0,0 +1,232 @@
1
+ /**
2
+ * PostgresBootstrapper
3
+ *
4
+ * Implements the `BackendBootstrapper` interface for PostgreSQL.
5
+ * Encapsulates all Postgres-specific initialization logic that was previously
6
+ * hardcoded inside `initializeRebaseBackend()`.
7
+ *
8
+ * Third-party drivers (MongoDB, MySQL, etc.) can implement their own
9
+ * bootstrapper following this pattern and pass it to the coordinator.
10
+ */
11
+
12
+ import { getTableName, isTable, Relations, sql } from "drizzle-orm";
13
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
14
+ import { PgEnum, PgTable } from "drizzle-orm/pg-core";
15
+ import {
16
+ BackendBootstrapper,
17
+ InitializedDriver,
18
+ BootstrappedAuth,
19
+ DatabaseAdmin,
20
+ RealtimeProvider,
21
+ type DataDriver,
22
+ EntityCollection
23
+ } from "@rebasepro/types";
24
+ import { PostgresBackendDriver } from "./PostgresBackendDriver";
25
+ import { RealtimeService } from "./services/realtimeService";
26
+ import { DatabasePoolManager } from "./databasePoolManager";
27
+ import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
28
+ import {
29
+ configureGoogleOAuth,
30
+ createAuthRoutes,
31
+ createAdminRoutes,
32
+ requireAuth,
33
+ requireAdmin
34
+ // @ts-ignore
35
+ } from "@rebasepro/server-core";
36
+ import { ensureAuthTablesExist } from "./auth/ensure-tables";
37
+ import { RoleService, UserService, PostgresAuthRepository } from "./auth/services";
38
+
39
+ // @ts-ignore
40
+ import { createEmailService, type EmailConfig, type EmailService } from "@rebasepro/server-core";
41
+ // @ts-ignore
42
+ import { createHistoryRoutes } from "@rebasepro/server-core";
43
+ import { HistoryService } from "./history/HistoryService";
44
+ import { ensureHistoryTableExists } from "./history/ensure-history-table";
45
+ // @ts-ignore
46
+ import type { AuthConfig, PostgresDriverConfig, HistoryConfig } from "@rebasepro/server-core";
47
+ import type { Hono } from "hono";
48
+ // @ts-ignore
49
+ import type { HonoEnv } from "@rebasepro/server-core";
50
+
51
+ /**
52
+ * Opaque internals bag that PostgresBootstrapper stores during `initializeDriver()`
53
+ * and re-uses in subsequent lifecycle hooks.
54
+ */
55
+ export interface PostgresDriverInternals {
56
+ db: NodePgDatabase<any>;
57
+ registry: PostgresCollectionRegistry;
58
+ realtimeService: RealtimeService;
59
+ driver: PostgresBackendDriver;
60
+ poolManager?: DatabasePoolManager;
61
+ }
62
+
63
+ /**
64
+ * Default PostgreSQL bootstrapper.
65
+ *
66
+ * Use it to register Postgres with `initializeRebaseBackend()`:
67
+ * ```typescript
68
+ * initializeRebaseBackend({
69
+ * ...config,
70
+ * bootstrappers: [postgresBootstrapper()]
71
+ * });
72
+ * ```
73
+ */
74
+ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): BackendBootstrapper {
75
+ return {
76
+ type: "postgres",
77
+
78
+ async initializeDriver(config: unknown): Promise<InitializedDriver> {
79
+ // config is passed from coordinator, we merge it with our internal pgConfig if needed
80
+ // Currently config from init.ts is `{ collections, collectionRegistry }`
81
+ const { collections, collectionRegistry } = config as {
82
+ collections?: EntityCollection[];
83
+ collectionRegistry?: unknown;
84
+ };
85
+
86
+ // Create a fresh registry for this driver
87
+ const registry = new PostgresCollectionRegistry();
88
+ if (collections) {
89
+ registry.registerMultiple(collections);
90
+ }
91
+
92
+ // Register tables
93
+ if (pgConfig.schema?.tables) {
94
+ Object.values(pgConfig.schema.tables).forEach((table) => {
95
+ if (isTable(table)) {
96
+ const tableName = getTableName(table);
97
+ registry.registerTable(table as PgTable, tableName);
98
+ }
99
+ });
100
+ }
101
+
102
+ if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums as Record<string, PgEnum<any>>);
103
+ if (pgConfig.schema?.relations) registry.registerRelations(pgConfig.schema.relations as Record<string, Relations>);
104
+
105
+ // Build schema-aware Drizzle connection
106
+ const mergedSchema: Record<string, unknown> = {
107
+ ...pgConfig.schema?.tables,
108
+ ...(pgConfig.schema?.relations || {})
109
+ };
110
+ const { drizzle: createDrizzle } = await import("drizzle-orm/node-postgres");
111
+ const rawClient = ("$client" in pgConfig.connection
112
+ ? (pgConfig.connection as Record<string, unknown>).$client
113
+ : pgConfig.connection) as import("pg").Pool;
114
+ const schemaAwareDb = createDrizzle(rawClient, { schema: mergedSchema });
115
+
116
+ // Verify connection
117
+ try {
118
+ await schemaAwareDb.execute(sql`SELECT 1`);
119
+ } catch (err) {
120
+ console.error("❌ Failed to connect to PostgreSQL:", err);
121
+ console.warn("⚠️ Continuing without initial database verification. Drizzle/PG will attempt to connect on subsequent queries.");
122
+ }
123
+
124
+ // Create services
125
+ const realtimeService = new RealtimeService(schemaAwareDb, registry);
126
+ const poolManager = pgConfig.adminConnectionString
127
+ ? new DatabasePoolManager(pgConfig.adminConnectionString)
128
+ : undefined;
129
+ const driver = new PostgresBackendDriver(schemaAwareDb, realtimeService, registry, undefined, poolManager);
130
+ realtimeService.setDataDriver(driver);
131
+
132
+ // Ensure branch metadata table exists when branching is available
133
+ if (driver.branchService) {
134
+ try {
135
+ await driver.branchService.ensureBranchMetadataTable();
136
+ } catch (err) {
137
+ console.warn("⚠️ Could not initialize branch metadata table:", err);
138
+ }
139
+ }
140
+
141
+ // Enable cross-instance realtime (opt-in)
142
+ if (pgConfig.connectionString) {
143
+ try {
144
+ await realtimeService.startListening(pgConfig.connectionString);
145
+ } catch (err) {
146
+ console.warn("⚠️ Cross-instance realtime could not be started:", err);
147
+ }
148
+ }
149
+
150
+ const internals: PostgresDriverInternals = {
151
+ db: schemaAwareDb,
152
+ registry,
153
+ realtimeService,
154
+ driver,
155
+ poolManager
156
+ };
157
+
158
+ return {
159
+ driver,
160
+ realtimeProvider: realtimeService,
161
+ collectionRegistry: registry,
162
+ internals,
163
+ };
164
+ },
165
+
166
+ async initializeAuth(config: unknown, driverResult: InitializedDriver): Promise<BootstrappedAuth | undefined> {
167
+ const authConfig = config as AuthConfig | undefined;
168
+ if (!authConfig) return undefined;
169
+
170
+ const internals = driverResult.internals as PostgresDriverInternals;
171
+ const db = internals.db;
172
+
173
+ await ensureAuthTablesExist(db);
174
+
175
+ if (authConfig.google?.clientId) {
176
+ configureGoogleOAuth(authConfig.google.clientId);
177
+ }
178
+
179
+ let emailService: EmailService | undefined;
180
+ if (authConfig.email) {
181
+ emailService = createEmailService(authConfig.email);
182
+ }
183
+
184
+ const userService = new UserService(db);
185
+ const roleService = new RoleService(db);
186
+ const authRepository = new PostgresAuthRepository(db);
187
+
188
+ return { userService, roleService, emailService, authRepository };
189
+ },
190
+
191
+ async initializeHistory(config: unknown, driverResult: InitializedDriver): Promise<{ historyService: HistoryService } | undefined> {
192
+ const historyConfig = config as HistoryConfig | boolean | undefined;
193
+ if (!historyConfig) return undefined;
194
+
195
+ const internals = driverResult.internals as PostgresDriverInternals;
196
+ const db = internals.db;
197
+
198
+ await ensureHistoryTableExists(db);
199
+
200
+ const retention = typeof historyConfig === "object" && historyConfig !== null ? (historyConfig as { retention?: number }).retention : undefined;
201
+ const historyService = new HistoryService(db, retention ? { ttlDays: retention } : undefined);
202
+
203
+ return { historyService };
204
+ },
205
+
206
+ async initializeRealtime(_config: unknown, driverResult: InitializedDriver): Promise<RealtimeProvider | undefined> {
207
+ const internals = driverResult.internals as PostgresDriverInternals;
208
+ return internals.realtimeService;
209
+ },
210
+
211
+ getAdmin(driverResult: InitializedDriver): DatabaseAdmin | undefined {
212
+ const internals = driverResult.internals as PostgresDriverInternals;
213
+ return internals.driver.admin;
214
+ },
215
+
216
+ mountRoutes(app: unknown, basePath: string, driverResult: InitializedDriver): void {
217
+ // The coordinator handles auth/storage/data routes.
218
+ // This hook is for driver-specific extensions only.
219
+ // Currently Postgres doesn't need additional routes beyond what the coordinator mounts.
220
+ },
221
+
222
+ async initializeWebsockets(server: unknown, realtimeService: RealtimeProvider, driver: DataDriver, config?: unknown): Promise<void> {
223
+ const { createPostgresWebSocket } = await import("./websocket");
224
+ createPostgresWebSocket(
225
+ server as import("http").Server,
226
+ realtimeService as RealtimeService,
227
+ driver as PostgresBackendDriver,
228
+ config as AuthConfig
229
+ );
230
+ }
231
+ };
232
+ }
@@ -0,0 +1,309 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
+
4
+ /**
5
+ * Default roles to seed on first run
6
+ */
7
+ const DEFAULT_ROLES = [
8
+ {
9
+ id: "admin",
10
+ name: "Admin",
11
+ is_admin: true,
12
+ default_permissions: { read: true, create: true, edit: true, delete: true },
13
+ config: { createCollections: true, editCollections: "all", deleteCollections: "all" }
14
+ },
15
+ {
16
+ id: "editor",
17
+ name: "Editor",
18
+ is_admin: false,
19
+ default_permissions: { read: true, create: true, edit: true, delete: true },
20
+ config: { createCollections: true, editCollections: "own", deleteCollections: "own" }
21
+ },
22
+ {
23
+ id: "viewer",
24
+ name: "Viewer",
25
+ is_admin: false,
26
+ default_permissions: { read: true, create: false, edit: false, delete: false },
27
+ config: null
28
+ }
29
+ ];
30
+
31
+ /**
32
+ * Auto-create auth tables if they don't exist
33
+ * This runs on startup to ensure the database is ready for auth
34
+ */
35
+ export async function ensureAuthTablesExist(db: NodePgDatabase): Promise<void> {
36
+ console.log("🔍 Checking auth tables...");
37
+
38
+ try {
39
+ // ── Create the rebase schema ────────────────────────────────────
40
+ await db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
41
+
42
+ // ── Create tables (idempotent) ──────────────────────────────────
43
+
44
+ // Create users table
45
+ await db.execute(sql`
46
+ CREATE TABLE IF NOT EXISTS rebase.users (
47
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
48
+ email TEXT NOT NULL UNIQUE,
49
+ password_hash TEXT,
50
+ display_name TEXT,
51
+ photo_url TEXT,
52
+ provider TEXT DEFAULT 'email',
53
+ google_id TEXT UNIQUE,
54
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
55
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
56
+ )
57
+ `);
58
+
59
+ // Create index on email for faster lookups
60
+ await db.execute(sql`
61
+ CREATE INDEX IF NOT EXISTS idx_users_email
62
+ ON rebase.users(email)
63
+ `);
64
+
65
+ // Create index on google_id for OAuth lookups
66
+ await db.execute(sql`
67
+ CREATE INDEX IF NOT EXISTS idx_users_google_id
68
+ ON rebase.users(google_id)
69
+ `);
70
+
71
+ // Create roles table
72
+ await db.execute(sql`
73
+ CREATE TABLE IF NOT EXISTS rebase.roles (
74
+ id TEXT PRIMARY KEY,
75
+ name TEXT NOT NULL,
76
+ is_admin BOOLEAN DEFAULT FALSE,
77
+ default_permissions JSONB,
78
+ collection_permissions JSONB,
79
+ config JSONB,
80
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
81
+ )
82
+ `);
83
+
84
+ // Migration: Add collection_permissions column if it doesn't exist (for existing databases)
85
+ await db.execute(sql`
86
+ ALTER TABLE rebase.roles
87
+ ADD COLUMN IF NOT EXISTS collection_permissions JSONB
88
+ `);
89
+
90
+ // Create user_roles junction table
91
+ await db.execute(sql`
92
+ CREATE TABLE IF NOT EXISTS rebase.user_roles (
93
+ user_id TEXT NOT NULL REFERENCES rebase.users(id) ON DELETE CASCADE,
94
+ role_id TEXT NOT NULL REFERENCES rebase.roles(id) ON DELETE CASCADE,
95
+ PRIMARY KEY (user_id, role_id)
96
+ )
97
+ `);
98
+
99
+ // Create index on user_id for faster lookups
100
+ await db.execute(sql`
101
+ CREATE INDEX IF NOT EXISTS idx_user_roles_user
102
+ ON rebase.user_roles(user_id)
103
+ `);
104
+
105
+ // Create refresh tokens table (includes user_agent, ip_address, and unique constraint)
106
+ await db.execute(sql`
107
+ CREATE TABLE IF NOT EXISTS rebase.refresh_tokens (
108
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
109
+ user_id TEXT NOT NULL REFERENCES rebase.users(id) ON DELETE CASCADE,
110
+ token_hash TEXT NOT NULL UNIQUE,
111
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
112
+ user_agent TEXT,
113
+ ip_address TEXT,
114
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
115
+ CONSTRAINT unique_device_session UNIQUE (user_id, user_agent, ip_address)
116
+ )
117
+ `);
118
+
119
+ // Create index on token_hash for faster lookups
120
+ await db.execute(sql`
121
+ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash
122
+ ON rebase.refresh_tokens(token_hash)
123
+ `);
124
+
125
+ // Create index on user_id for cleanup operations
126
+ await db.execute(sql`
127
+ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user
128
+ ON rebase.refresh_tokens(user_id)
129
+ `);
130
+
131
+ // Migration: Add user_agent and ip_address to refresh tokens (for tables created before these columns existed)
132
+ await db.execute(sql`
133
+ ALTER TABLE rebase.refresh_tokens
134
+ ADD COLUMN IF NOT EXISTS user_agent TEXT
135
+ `);
136
+
137
+ await db.execute(sql`
138
+ ALTER TABLE rebase.refresh_tokens
139
+ ADD COLUMN IF NOT EXISTS ip_address TEXT
140
+ `);
141
+
142
+ // Migration: Ensure unique_device_session constraint exists (for tables created before it was in CREATE TABLE)
143
+ // Check if constraint already exists before attempting to add it
144
+ const constraintCheck = await db.execute(sql`
145
+ SELECT 1 FROM information_schema.table_constraints
146
+ WHERE constraint_name = 'unique_device_session'
147
+ AND table_schema = 'rebase'
148
+ AND table_name = 'refresh_tokens'
149
+ `);
150
+ if (constraintCheck.rows.length === 0) {
151
+ try {
152
+ await db.execute(sql`
153
+ ALTER TABLE rebase.refresh_tokens
154
+ ADD CONSTRAINT unique_device_session UNIQUE (user_id, user_agent, ip_address)
155
+ `);
156
+ console.log("✅ Added unique_device_session constraint");
157
+ } catch (e: unknown) {
158
+ const errorMessage = e instanceof Error ? e.message : String(e);
159
+ // If there's duplicate data preventing the constraint, clean up first
160
+ if (errorMessage.includes('could not create unique index')) {
161
+ console.warn("⚠️ Duplicate sessions found, cleaning up before adding constraint...");
162
+ // Keep only the most recent token per user/device combo
163
+ await db.execute(sql`
164
+ DELETE FROM rebase.refresh_tokens a
165
+ USING rebase.refresh_tokens b
166
+ WHERE a.user_id = b.user_id
167
+ AND COALESCE(a.user_agent, '') = COALESCE(b.user_agent, '')
168
+ AND COALESCE(a.ip_address, '') = COALESCE(b.ip_address, '')
169
+ AND a.created_at < b.created_at
170
+ `);
171
+ // Retry constraint creation
172
+ await db.execute(sql`
173
+ ALTER TABLE rebase.refresh_tokens
174
+ ADD CONSTRAINT unique_device_session UNIQUE (user_id, user_agent, ip_address)
175
+ `).catch((retryErr: unknown) => {
176
+ const retryMessage = retryErr instanceof Error ? retryErr.message : String(retryErr);
177
+ console.error("Failed to add unique_device_session constraint after cleanup:", retryMessage);
178
+ });
179
+ } else {
180
+ console.error("Constraint migration issue:", errorMessage);
181
+ }
182
+ }
183
+ }
184
+
185
+ // Create password reset tokens table
186
+ await db.execute(sql`
187
+ CREATE TABLE IF NOT EXISTS rebase.password_reset_tokens (
188
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
189
+ user_id TEXT NOT NULL REFERENCES rebase.users(id) ON DELETE CASCADE,
190
+ token_hash TEXT NOT NULL UNIQUE,
191
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
192
+ used_at TIMESTAMP WITH TIME ZONE,
193
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
194
+ )
195
+ `);
196
+
197
+ // Create index on token_hash for password reset lookups
198
+ await db.execute(sql`
199
+ CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash
200
+ ON rebase.password_reset_tokens(token_hash)
201
+ `);
202
+
203
+ // Create index on user_id for password reset cleanup
204
+ await db.execute(sql`
205
+ CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user
206
+ ON rebase.password_reset_tokens(user_id)
207
+ `);
208
+
209
+ // Create app config table
210
+ await db.execute(sql`
211
+ CREATE TABLE IF NOT EXISTS rebase.app_config (
212
+ key TEXT PRIMARY KEY,
213
+ value JSONB NOT NULL,
214
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
215
+ )
216
+ `);
217
+
218
+ // Migration: Add email verification columns to users if they don't exist
219
+ await db.execute(sql`
220
+ ALTER TABLE rebase.users
221
+ ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE
222
+ `);
223
+
224
+ await db.execute(sql`
225
+ ALTER TABLE rebase.users
226
+ ADD COLUMN IF NOT EXISTS email_verification_token TEXT
227
+ `);
228
+
229
+ await db.execute(sql`
230
+ ALTER TABLE rebase.users
231
+ ADD COLUMN IF NOT EXISTS email_verification_sent_at TIMESTAMP WITH TIME ZONE
232
+ `);
233
+
234
+ // Create the `auth` schema with Supabase-style helper functions for RLS.
235
+ // auth.uid() → returns the current user's ID (reads app.user_id)
236
+ // auth.jwt() → returns the full JWT claims as JSONB (reads app.jwt)
237
+ // auth.roles() → returns comma-separated role IDs (reads app.user_roles)
238
+ // These read from session-local config vars set per-transaction by withAuth().
239
+ await db.execute(sql`CREATE SCHEMA IF NOT EXISTS auth`);
240
+
241
+ // Use an advisory transaction lock to serialize function recreation during HMR
242
+ // This prevents the "tuple concurrently updated" race condition when multiple Node
243
+ // workers or rapid restarts attempt to CREATE OR REPLACE FUNCTION simultaneously.
244
+ await db.transaction(async (tx) => {
245
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
246
+
247
+ await tx.execute(sql`
248
+ CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
249
+ SELECT NULLIF(current_setting('app.user_id', true), '');
250
+ $$ LANGUAGE sql STABLE
251
+ `);
252
+
253
+ await tx.execute(sql`
254
+ CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb AS $$
255
+ SELECT COALESCE(
256
+ NULLIF(current_setting('app.jwt', true), ''),
257
+ '{}'
258
+ )::jsonb;
259
+ $$ LANGUAGE sql STABLE
260
+ `);
261
+
262
+ await tx.execute(sql`
263
+ CREATE OR REPLACE FUNCTION auth.roles() RETURNS text AS $$
264
+ SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');
265
+ $$ LANGUAGE sql STABLE
266
+ `);
267
+ });
268
+
269
+ // Seed default roles if none exist
270
+ await seedDefaultRoles(db);
271
+
272
+ console.log("✅ Auth tables ready");
273
+ } catch (error) {
274
+ console.error("❌ Failed to create auth tables:", error);
275
+ console.warn("⚠️ Continuing without creating auth tables.");
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Seed default roles if the roles table is empty
281
+ */
282
+ async function seedDefaultRoles(db: NodePgDatabase): Promise<void> {
283
+ // Check if any roles exist
284
+ const result = await db.execute(sql`SELECT COUNT(*) as count FROM rebase.roles`);
285
+ const count = parseInt((result.rows[0] as unknown as Record<string, string | number>)?.count as string || "0", 10);
286
+
287
+ if (count > 0) {
288
+ console.log(`📋 Found ${count} existing roles`);
289
+ return;
290
+ }
291
+
292
+ console.log("🌱 Seeding default roles...");
293
+
294
+ for (const role of DEFAULT_ROLES) {
295
+ await db.execute(sql`
296
+ INSERT INTO rebase.roles (id, name, is_admin, default_permissions, config)
297
+ VALUES (
298
+ ${role.id},
299
+ ${role.name},
300
+ ${role.is_admin},
301
+ ${JSON.stringify(role.default_permissions)}::jsonb,
302
+ ${role.config ? JSON.stringify(role.config) : null}::jsonb
303
+ )
304
+ ON CONFLICT (id) DO NOTHING
305
+ `);
306
+ }
307
+
308
+ console.log("✅ Default roles created: admin, editor, viewer");
309
+ }