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

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 (136) hide show
  1. package/dist/common/src/collections/CollectionRegistry.d.ts +8 -0
  2. package/dist/common/src/util/entities.d.ts +22 -0
  3. package/dist/common/src/util/relations.d.ts +14 -4
  4. package/dist/common/src/util/resolutions.d.ts +1 -1
  5. package/dist/index.es.js +1254 -591
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/index.umd.js +1254 -591
  8. package/dist/index.umd.js.map +1 -1
  9. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +17 -29
  10. package/dist/server-postgresql/src/auth/services.d.ts +7 -3
  11. package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +1 -1
  12. package/dist/server-postgresql/src/connection.d.ts +34 -1
  13. package/dist/server-postgresql/src/data-transformer.d.ts +26 -4
  14. package/dist/server-postgresql/src/databasePoolManager.d.ts +2 -2
  15. package/dist/server-postgresql/src/schema/auth-schema.d.ts +139 -38
  16. package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
  17. package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
  18. package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +1 -1
  19. package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
  20. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +22 -8
  21. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +1 -1
  22. package/dist/server-postgresql/src/services/RelationService.d.ts +11 -5
  23. package/dist/server-postgresql/src/services/entity-helpers.d.ts +16 -2
  24. package/dist/server-postgresql/src/services/entityService.d.ts +8 -6
  25. package/dist/server-postgresql/src/services/realtimeService.d.ts +2 -0
  26. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +2 -2
  27. package/dist/types/src/controllers/auth.d.ts +2 -0
  28. package/dist/types/src/controllers/client.d.ts +119 -7
  29. package/dist/types/src/controllers/collection_registry.d.ts +4 -3
  30. package/dist/types/src/controllers/customization_controller.d.ts +7 -1
  31. package/dist/types/src/controllers/data.d.ts +34 -7
  32. package/dist/types/src/controllers/data_driver.d.ts +20 -28
  33. package/dist/types/src/controllers/database_admin.d.ts +2 -2
  34. package/dist/types/src/controllers/email.d.ts +34 -0
  35. package/dist/types/src/controllers/index.d.ts +1 -0
  36. package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
  37. package/dist/types/src/controllers/navigation.d.ts +5 -5
  38. package/dist/types/src/controllers/registry.d.ts +6 -3
  39. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
  40. package/dist/types/src/controllers/storage.d.ts +24 -26
  41. package/dist/types/src/rebase_context.d.ts +8 -4
  42. package/dist/types/src/types/backend.d.ts +4 -1
  43. package/dist/types/src/types/builders.d.ts +5 -4
  44. package/dist/types/src/types/chips.d.ts +1 -1
  45. package/dist/types/src/types/collections.d.ts +169 -125
  46. package/dist/types/src/types/cron.d.ts +102 -0
  47. package/dist/types/src/types/data_source.d.ts +1 -1
  48. package/dist/types/src/types/entity_actions.d.ts +8 -8
  49. package/dist/types/src/types/entity_callbacks.d.ts +15 -15
  50. package/dist/types/src/types/entity_link_builder.d.ts +1 -1
  51. package/dist/types/src/types/entity_overrides.d.ts +2 -1
  52. package/dist/types/src/types/entity_views.d.ts +8 -8
  53. package/dist/types/src/types/export_import.d.ts +3 -3
  54. package/dist/types/src/types/index.d.ts +1 -0
  55. package/dist/types/src/types/plugins.d.ts +72 -18
  56. package/dist/types/src/types/properties.d.ts +118 -33
  57. package/dist/types/src/types/relations.d.ts +1 -1
  58. package/dist/types/src/types/slots.d.ts +30 -6
  59. package/dist/types/src/types/translations.d.ts +44 -0
  60. package/dist/types/src/types/user_management_delegate.d.ts +1 -0
  61. package/drizzle-test/0000_woozy_junta.sql +6 -0
  62. package/drizzle-test/0001_youthful_arachne.sql +1 -0
  63. package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
  64. package/drizzle-test/0003_mean_king_cobra.sql +2 -0
  65. package/drizzle-test/meta/0000_snapshot.json +47 -0
  66. package/drizzle-test/meta/0001_snapshot.json +48 -0
  67. package/drizzle-test/meta/0002_snapshot.json +38 -0
  68. package/drizzle-test/meta/0003_snapshot.json +48 -0
  69. package/drizzle-test/meta/_journal.json +34 -0
  70. package/drizzle-test-out/0000_tan_trauma.sql +6 -0
  71. package/drizzle-test-out/0001_rapid_drax.sql +1 -0
  72. package/drizzle-test-out/meta/0000_snapshot.json +44 -0
  73. package/drizzle-test-out/meta/0001_snapshot.json +54 -0
  74. package/drizzle-test-out/meta/_journal.json +20 -0
  75. package/drizzle.test.config.ts +10 -0
  76. package/package.json +88 -89
  77. package/scratch.ts +41 -0
  78. package/src/PostgresBackendDriver.ts +63 -79
  79. package/src/PostgresBootstrapper.ts +7 -8
  80. package/src/auth/ensure-tables.ts +158 -86
  81. package/src/auth/services.ts +109 -50
  82. package/src/cli.ts +259 -16
  83. package/src/collections/PostgresCollectionRegistry.ts +6 -6
  84. package/src/connection.ts +70 -48
  85. package/src/data-transformer.ts +155 -116
  86. package/src/databasePoolManager.ts +6 -5
  87. package/src/history/HistoryService.ts +3 -12
  88. package/src/interfaces.ts +3 -3
  89. package/src/schema/auth-schema.ts +26 -3
  90. package/src/schema/doctor-cli.ts +47 -0
  91. package/src/schema/doctor.ts +595 -0
  92. package/src/schema/generate-drizzle-schema-logic.ts +204 -57
  93. package/src/schema/generate-drizzle-schema.ts +6 -6
  94. package/src/schema/test-schema.ts +11 -0
  95. package/src/services/BranchService.ts +5 -5
  96. package/src/services/EntityFetchService.ts +317 -188
  97. package/src/services/EntityPersistService.ts +15 -17
  98. package/src/services/RelationService.ts +299 -37
  99. package/src/services/entity-helpers.ts +39 -13
  100. package/src/services/entityService.ts +11 -9
  101. package/src/services/realtimeService.ts +58 -29
  102. package/src/utils/drizzle-conditions.ts +25 -24
  103. package/src/websocket.ts +52 -21
  104. package/test/auth-services.test.ts +131 -39
  105. package/test/batch-many-to-many-regression.test.ts +573 -0
  106. package/test/branchService.test.ts +22 -12
  107. package/test/data-transformer-hardening.test.ts +417 -0
  108. package/test/data-transformer.test.ts +175 -0
  109. package/test/doctor.test.ts +182 -0
  110. package/test/entityService.errors.test.ts +31 -16
  111. package/test/entityService.relations.test.ts +155 -59
  112. package/test/entityService.subcollection-search.test.ts +107 -57
  113. package/test/entityService.test.ts +105 -47
  114. package/test/generate-drizzle-schema.test.ts +262 -69
  115. package/test/historyService.test.ts +31 -16
  116. package/test/n-plus-one-regression.test.ts +314 -0
  117. package/test/postgresDataDriver.test.ts +260 -168
  118. package/test/realtimeService.test.ts +70 -39
  119. package/test/relation-pipeline-gaps.test.ts +637 -0
  120. package/test/relations.test.ts +492 -39
  121. package/test-drizzle-bug.ts +18 -0
  122. package/test-drizzle-out/0000_cultured_freak.sql +7 -0
  123. package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
  124. package/test-drizzle-out/meta/0000_snapshot.json +55 -0
  125. package/test-drizzle-out/meta/0001_snapshot.json +63 -0
  126. package/test-drizzle-out/meta/_journal.json +20 -0
  127. package/test-drizzle-prompt.sh +2 -0
  128. package/test-policy-prompt.sh +3 -0
  129. package/test-programmatic.ts +30 -0
  130. package/test-programmatic2.ts +59 -0
  131. package/test-schema-no-policies.ts +12 -0
  132. package/test_drizzle_mock.js +2 -2
  133. package/test_find_changed.mjs +3 -1
  134. package/test_hash.js +14 -0
  135. package/tsconfig.json +1 -1
  136. package/vite.config.ts +5 -5
@@ -9,21 +9,34 @@ const DEFAULT_ROLES = [
9
9
  id: "admin",
10
10
  name: "Admin",
11
11
  is_admin: true,
12
- default_permissions: { read: true, create: true, edit: true, delete: true },
13
- config: { createCollections: true, editCollections: "all", deleteCollections: "all" }
12
+ default_permissions: { read: true,
13
+ create: true,
14
+ edit: true,
15
+ delete: true },
16
+ config: { createCollections: true,
17
+ editCollections: "all",
18
+ deleteCollections: "all" }
14
19
  },
15
20
  {
16
21
  id: "editor",
17
22
  name: "Editor",
18
23
  is_admin: false,
19
- default_permissions: { read: true, create: true, edit: true, delete: true },
20
- config: { createCollections: true, editCollections: "own", deleteCollections: "own" }
24
+ default_permissions: { read: true,
25
+ create: true,
26
+ edit: true,
27
+ delete: true },
28
+ config: { createCollections: true,
29
+ editCollections: "own",
30
+ deleteCollections: "own" }
21
31
  },
22
32
  {
23
33
  id: "viewer",
24
34
  name: "Viewer",
25
35
  is_admin: false,
26
- default_permissions: { read: true, create: false, edit: false, delete: false },
36
+ default_permissions: { read: true,
37
+ create: false,
38
+ edit: false,
39
+ delete: false },
27
40
  config: null
28
41
  }
29
42
  ];
@@ -49,8 +62,6 @@ export async function ensureAuthTablesExist(db: NodePgDatabase): Promise<void> {
49
62
  password_hash TEXT,
50
63
  display_name TEXT,
51
64
  photo_url TEXT,
52
- provider TEXT DEFAULT 'email',
53
- google_id TEXT UNIQUE,
54
65
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
55
66
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
56
67
  )
@@ -62,12 +73,27 @@ export async function ensureAuthTablesExist(db: NodePgDatabase): Promise<void> {
62
73
  ON rebase.users(email)
63
74
  `);
64
75
 
65
- // Create index on google_id for OAuth lookups
76
+ // Create user_identities table
66
77
  await db.execute(sql`
67
- CREATE INDEX IF NOT EXISTS idx_users_google_id
68
- ON rebase.users(google_id)
78
+ CREATE TABLE IF NOT EXISTS rebase.user_identities (
79
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
80
+ user_id TEXT NOT NULL REFERENCES rebase.users(id) ON DELETE CASCADE,
81
+ provider TEXT NOT NULL,
82
+ provider_id TEXT NOT NULL,
83
+ profile_data JSONB,
84
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
85
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
86
+ UNIQUE(provider, provider_id)
87
+ )
69
88
  `);
70
89
 
90
+ // Create indexes on user_identities
91
+ await db.execute(sql`
92
+ CREATE INDEX IF NOT EXISTS idx_user_identities_user
93
+ ON rebase.user_identities(user_id)
94
+ `);
95
+
96
+
71
97
  // Create roles table
72
98
  await db.execute(sql`
73
99
  CREATE TABLE IF NOT EXISTS rebase.roles (
@@ -81,12 +107,6 @@ export async function ensureAuthTablesExist(db: NodePgDatabase): Promise<void> {
81
107
  )
82
108
  `);
83
109
 
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
110
  // Create user_roles junction table
91
111
  await db.execute(sql`
92
112
  CREATE TABLE IF NOT EXISTS rebase.user_roles (
@@ -128,60 +148,6 @@ export async function ensureAuthTablesExist(db: NodePgDatabase): Promise<void> {
128
148
  ON rebase.refresh_tokens(user_id)
129
149
  `);
130
150
 
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
151
  // Create password reset tokens table
186
152
  await db.execute(sql`
187
153
  CREATE TABLE IF NOT EXISTS rebase.password_reset_tokens (
@@ -215,21 +181,8 @@ export async function ensureAuthTablesExist(db: NodePgDatabase): Promise<void> {
215
181
  )
216
182
  `);
217
183
 
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
- `);
184
+ // Apply any schema alterations for existing databases
185
+ await applyInternalMigrations(db);
233
186
 
234
187
  // Create the `auth` schema with Supabase-style helper functions for RLS.
235
188
  // auth.uid() → returns the current user's ID (reads app.user_id)
@@ -239,7 +192,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase): Promise<void> {
239
192
  await db.execute(sql`CREATE SCHEMA IF NOT EXISTS auth`);
240
193
 
241
194
  // Use an advisory transaction lock to serialize function recreation during HMR
242
- // This prevents the "tuple concurrently updated" race condition when multiple Node
195
+ // This prevents the "tuple concurrently updated" race condition when multiple Node
243
196
  // workers or rapid restarts attempt to CREATE OR REPLACE FUNCTION simultaneously.
244
197
  await db.transaction(async (tx) => {
245
198
  await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
@@ -307,3 +260,122 @@ async function seedDefaultRoles(db: NodePgDatabase): Promise<void> {
307
260
 
308
261
  console.log("✅ Default roles created: admin, editor, viewer");
309
262
  }
263
+
264
+ /**
265
+ * Apply idempotent alterations for internal Rebase tables.
266
+ * This runs after CREATE TABLE IF NOT EXISTS to ensure existing
267
+ * databases get new columns without needing external Drizzle migrations.
268
+ */
269
+ async function applyInternalMigrations(db: NodePgDatabase): Promise<void> {
270
+ try {
271
+ // Users Table Migrations
272
+ await db.execute(sql`
273
+ ALTER TABLE rebase.users
274
+ ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE,
275
+ ADD COLUMN IF NOT EXISTS email_verification_token TEXT,
276
+ ADD COLUMN IF NOT EXISTS email_verification_sent_at TIMESTAMP WITH TIME ZONE
277
+ `);
278
+
279
+ // Migrate Old OAuth Data to user_identities table
280
+
281
+ // 1. Check if legacy columns exist
282
+ const columnsCheck = await db.execute(sql`
283
+ SELECT column_name
284
+ FROM information_schema.columns
285
+ WHERE table_schema='rebase' AND table_name='users' AND column_name IN ('google_id', 'linkedin_id', 'provider')
286
+ `);
287
+ const existingColumns = columnsCheck.rows.map(r => r.column_name);
288
+
289
+ if (existingColumns.includes("google_id")) {
290
+ // Migrate google users
291
+ await db.execute(sql`
292
+ INSERT INTO rebase.user_identities (user_id, provider, provider_id)
293
+ SELECT id, 'google', google_id
294
+ FROM rebase.users
295
+ WHERE google_id IS NOT NULL
296
+ ON CONFLICT (provider, provider_id) DO NOTHING
297
+ `);
298
+ }
299
+
300
+ if (existingColumns.includes("linkedin_id")) {
301
+ // Migrate linkedin users
302
+ await db.execute(sql`
303
+ INSERT INTO rebase.user_identities (user_id, provider, provider_id)
304
+ SELECT id, 'linkedin', linkedin_id
305
+ FROM rebase.users
306
+ WHERE linkedin_id IS NOT NULL
307
+ ON CONFLICT (provider, provider_id) DO NOTHING
308
+ `);
309
+ }
310
+
311
+ // Now drop legacy columns safely if they exist
312
+ if (existingColumns.length > 0) {
313
+ await db.execute(sql`
314
+ ALTER TABLE rebase.users
315
+ DROP COLUMN IF EXISTS provider,
316
+ DROP COLUMN IF EXISTS google_id,
317
+ DROP COLUMN IF EXISTS linkedin_id
318
+ `);
319
+
320
+ // Drop legacy indexes
321
+ await db.execute(sql`DROP INDEX IF EXISTS rebase.idx_users_google_id`);
322
+ await db.execute(sql`DROP INDEX IF EXISTS rebase.idx_users_linkedin_id`);
323
+
324
+ console.log("✅ Migrated to user_identities and dropped legacy columns.");
325
+ }
326
+
327
+ // Roles Table Migrations
328
+ await db.execute(sql`
329
+ ALTER TABLE rebase.roles
330
+ ADD COLUMN IF NOT EXISTS collection_permissions JSONB
331
+ `);
332
+
333
+ // Refresh Tokens Table Migrations
334
+ await db.execute(sql`
335
+ ALTER TABLE rebase.refresh_tokens
336
+ ADD COLUMN IF NOT EXISTS user_agent TEXT,
337
+ ADD COLUMN IF NOT EXISTS ip_address TEXT
338
+ `);
339
+
340
+ const constraintCheck = await db.execute(sql`
341
+ SELECT 1 FROM information_schema.table_constraints
342
+ WHERE constraint_name = 'unique_device_session'
343
+ AND table_schema = 'rebase'
344
+ AND table_name = 'refresh_tokens'
345
+ `);
346
+
347
+ if (constraintCheck.rows.length === 0) {
348
+ try {
349
+ await db.execute(sql`
350
+ ALTER TABLE rebase.refresh_tokens
351
+ ADD CONSTRAINT unique_device_session UNIQUE (user_id, user_agent, ip_address)
352
+ `);
353
+ console.log("✅ Added unique_device_session constraint");
354
+ } catch (e: unknown) {
355
+ const errorMessage = e instanceof Error ? e.message : String(e);
356
+ if (errorMessage.includes("could not create unique index")) {
357
+ console.warn("⚠️ Duplicate sessions found, cleaning up before adding constraint...");
358
+ await db.execute(sql`
359
+ DELETE FROM rebase.refresh_tokens a
360
+ USING rebase.refresh_tokens b
361
+ WHERE a.user_id = b.user_id
362
+ AND COALESCE(a.user_agent, '') = COALESCE(b.user_agent, '')
363
+ AND COALESCE(a.ip_address, '') = COALESCE(b.ip_address, '')
364
+ AND a.created_at < b.created_at
365
+ `);
366
+ await db.execute(sql`
367
+ ALTER TABLE rebase.refresh_tokens
368
+ ADD CONSTRAINT unique_device_session UNIQUE (user_id, user_agent, ip_address)
369
+ `).catch((retryErr: unknown) => {
370
+ const retryMessage = retryErr instanceof Error ? retryErr.message : String(retryErr);
371
+ console.error("Failed to add unique_device_session constraint after cleanup:", retryMessage);
372
+ });
373
+ } else {
374
+ console.error("Constraint migration issue:", errorMessage);
375
+ }
376
+ }
377
+ }
378
+ } catch (error) {
379
+ console.error("❌ Failed to run internal migrations:", error);
380
+ }
381
+ }
@@ -1,6 +1,6 @@
1
1
  import { eq, sql } from "drizzle-orm";
2
2
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
- import { users, refreshTokens, passwordResetTokens, User, NewUser } from "../schema/auth-schema";
3
+ import { users, userIdentities, refreshTokens, passwordResetTokens, User, NewUser } from "../schema/auth-schema";
4
4
  import {
5
5
  UserRepository,
6
6
  RoleRepository,
@@ -12,6 +12,7 @@ import {
12
12
  CreateRoleData,
13
13
  RefreshTokenInfo,
14
14
  PasswordResetTokenInfo,
15
+ UserIdentityData,
15
16
  ListUsersOptions,
16
17
  PaginatedUsersResult,
17
18
  RoleData as Role
@@ -42,15 +43,64 @@ export class UserService implements UserRepository {
42
43
  return user || null;
43
44
  }
44
45
 
45
- async getUserByGoogleId(googleId: string): Promise<User | null> {
46
- const [user] = await this.db.select().from(users).where(eq(users.googleId, googleId));
47
- return user || null;
46
+ async getUserByIdentity(provider: string, providerId: string): Promise<User | null> {
47
+ const result = await this.db.execute(sql`
48
+ SELECT u.*
49
+ FROM rebase.users u
50
+ INNER JOIN rebase.user_identities ui ON u.id = ui.user_id
51
+ WHERE ui.provider = ${provider} AND ui.provider_id = ${providerId}
52
+ LIMIT 1
53
+ `);
54
+
55
+ if (result.rows.length === 0) return null;
56
+
57
+ const row = result.rows[0] as Record<string, unknown>;
58
+ return {
59
+ id: row.id as string,
60
+ email: row.email as string,
61
+ passwordHash: (row.password_hash as string | null) ?? null,
62
+ displayName: (row.display_name as string | null) ?? null,
63
+ photoUrl: (row.photo_url as string | null) ?? null,
64
+ emailVerified: (row.email_verified as boolean | undefined) ?? false,
65
+ emailVerificationToken: (row.email_verification_token as string | null) ?? null,
66
+ emailVerificationSentAt: (row.email_verification_sent_at as Date | null) ?? null,
67
+ createdAt: row.created_at as Date,
68
+ updatedAt: row.updated_at as Date
69
+ } as User;
70
+ }
71
+
72
+ async getUserIdentities(userId: string): Promise<UserIdentityData[]> {
73
+ const result = await this.db.execute(sql`
74
+ SELECT id, user_id, provider, provider_id, profile_data, created_at, updated_at
75
+ FROM rebase.user_identities
76
+ WHERE user_id = ${userId}
77
+ `);
78
+
79
+ return result.rows.map((row: Record<string, unknown>) => ({
80
+ id: row.id as string,
81
+ userId: row.user_id as string,
82
+ provider: row.provider as string,
83
+ providerId: row.provider_id as string,
84
+ profileData: (row.profile_data as Record<string, unknown> | null) ?? null,
85
+ createdAt: row.created_at as Date,
86
+ updatedAt: row.updated_at as Date
87
+ }));
88
+ }
89
+
90
+ async linkUserIdentity(userId: string, provider: string, providerId: string, profileData?: Record<string, unknown>): Promise<void> {
91
+ await this.db.insert(userIdentities).values({
92
+ userId,
93
+ provider,
94
+ providerId,
95
+ profileData: profileData || null
96
+ }).onConflictDoNothing({ target: [userIdentities.provider, userIdentities.providerId] });
48
97
  }
49
98
 
50
99
  async updateUser(id: string, data: Partial<Omit<NewUser, "id">>): Promise<User | null> {
51
100
  const [user] = await this.db
52
101
  .update(users)
53
- .set({ ...data, updatedAt: new Date() })
102
+ .set({ ...data,
103
+ updatedAt: new Date() })
54
104
  .where(eq(users.id, id))
55
105
  .returning();
56
106
  return user || null;
@@ -70,6 +120,7 @@ export class UserService implements UserRepository {
70
120
  const search = options?.search?.trim() || "";
71
121
  const orderBy = options?.orderBy || "createdAt";
72
122
  const orderDir = options?.orderDir || "desc";
123
+ const roleId = options?.roleId;
73
124
 
74
125
  // Map camelCase field names to snake_case column names
75
126
  const columnMap: Record<string, string> = {
@@ -82,56 +133,54 @@ export class UserService implements UserRepository {
82
133
  const orderColumn = columnMap[orderBy] || "created_at";
83
134
  const direction = orderDir === "asc" ? sql`ASC` : sql`DESC`;
84
135
 
85
- let rows: User[];
86
- let total: number;
87
-
136
+ const conditions = [];
137
+ if (roleId) {
138
+ conditions.push(sql`EXISTS (SELECT 1 FROM rebase.user_roles ur WHERE ur.user_id = users.id AND ur.role_id = ${roleId})`);
139
+ }
88
140
  if (search) {
89
141
  const pattern = `%${search}%`;
142
+ conditions.push(sql`(email ILIKE ${pattern} OR display_name ILIKE ${pattern})`);
143
+ }
90
144
 
91
- const countResult = await this.db.execute(sql`
92
- SELECT count(*)::int as total FROM rebase.users
93
- WHERE email ILIKE ${pattern} OR display_name ILIKE ${pattern}
94
- `);
95
- total = (countResult.rows[0] as { total: number }).total;
145
+ const whereClause = conditions.length > 0 ? sql`WHERE ${sql.join(conditions, sql` AND `)}` : sql``;
96
146
 
97
- const dataResult = await this.db.execute(sql`
98
- SELECT * FROM rebase.users
99
- WHERE email ILIKE ${pattern} OR display_name ILIKE ${pattern}
100
- ORDER BY ${sql.raw(orderColumn)} ${direction}
101
- LIMIT ${limit} OFFSET ${offset}
102
- `);
103
- rows = dataResult.rows as User[];
104
- } else {
105
- const countResult = await this.db.execute(sql`
106
- SELECT count(*)::int as total FROM rebase.users
107
- `);
108
- total = (countResult.rows[0] as { total: number }).total;
147
+ // Sorting: users with roles first if no role filter, then by requested order
148
+ const orderByClause = roleId
149
+ ? sql`ORDER BY ${sql.raw(orderColumn)} ${direction}`
150
+ : sql`ORDER BY (SELECT count(*) FROM rebase.user_roles ur WHERE ur.user_id = users.id) DESC, ${sql.raw(orderColumn)} ${direction}`;
109
151
 
110
- const dataResult = await this.db.execute(sql`
111
- SELECT * FROM rebase.users
112
- ORDER BY ${sql.raw(orderColumn)} ${direction}
113
- LIMIT ${limit} OFFSET ${offset}
114
- `);
115
- rows = dataResult.rows as User[];
116
- }
152
+ const countResult = await this.db.execute(sql`
153
+ SELECT count(*)::int as total FROM rebase.users
154
+ ${whereClause}
155
+ `);
156
+ const total = (countResult.rows[0] as { total: number }).total;
157
+
158
+ const dataResult = await this.db.execute(sql`
159
+ SELECT * FROM rebase.users
160
+ ${whereClause}
161
+ ${orderByClause}
162
+ LIMIT ${limit} OFFSET ${offset}
163
+ `);
164
+ const rows = dataResult.rows as User[];
117
165
 
118
166
  // Map snake_case rows to camelCase UserData
119
- const mappedUsers: User[] = rows.map((row: Record<string, any>) => ({
120
- id: row.id,
121
- email: row.email,
122
- passwordHash: row.password_hash ?? row.passwordHash ?? null,
123
- displayName: row.display_name ?? row.displayName ?? null,
124
- photoUrl: row.photo_url ?? row.photoUrl ?? null,
125
- provider: row.provider,
126
- googleId: row.google_id ?? row.googleId ?? null,
127
- emailVerified: row.email_verified ?? row.emailVerified ?? false,
128
- emailVerificationToken: row.email_verification_token ?? row.emailVerificationToken ?? null,
129
- emailVerificationSentAt: row.email_verification_sent_at ?? row.emailVerificationSentAt ?? null,
130
- createdAt: row.created_at ?? row.createdAt,
131
- updatedAt: row.updated_at ?? row.updatedAt
167
+ const mappedUsers: User[] = rows.map((row: Record<string, unknown>) => ({
168
+ id: row.id as string,
169
+ email: row.email as string,
170
+ passwordHash: ((row.password_hash ?? row.passwordHash) as string | null) ?? null,
171
+ displayName: ((row.display_name ?? row.displayName) as string | null) ?? null,
172
+ photoUrl: ((row.photo_url ?? row.photoUrl) as string | null) ?? null,
173
+ emailVerified: ((row.email_verified ?? row.emailVerified) as boolean | undefined) ?? false,
174
+ emailVerificationToken: ((row.email_verification_token ?? row.emailVerificationToken) as string | null) ?? null,
175
+ emailVerificationSentAt: ((row.email_verification_sent_at ?? row.emailVerificationSentAt) as Date | null) ?? null,
176
+ createdAt: (row.created_at ?? row.createdAt) as Date,
177
+ updatedAt: (row.updated_at ?? row.updatedAt) as Date
132
178
  })) as User[];
133
179
 
134
- return { users: mappedUsers, total, limit, offset };
180
+ return { users: mappedUsers,
181
+ total,
182
+ limit,
183
+ offset };
135
184
  }
136
185
 
137
186
  /**
@@ -140,7 +189,8 @@ export class UserService implements UserRepository {
140
189
  async updatePassword(id: string, passwordHash: string): Promise<void> {
141
190
  await this.db
142
191
  .update(users)
143
- .set({ passwordHash, updatedAt: new Date() })
192
+ .set({ passwordHash,
193
+ updatedAt: new Date() })
144
194
  .where(eq(users.id, id));
145
195
  }
146
196
 
@@ -248,7 +298,8 @@ export class UserService implements UserRepository {
248
298
  if (!user) return null;
249
299
 
250
300
  const roles = await this.getUserRoles(userId);
251
- return { user, roles };
301
+ return { user,
302
+ roles };
252
303
  }
253
304
  }
254
305
 
@@ -597,8 +648,16 @@ export class PostgresAuthRepository implements AuthRepository {
597
648
  return this.userService.getUserByEmail(email) as Promise<UserData | null>;
598
649
  }
599
650
 
600
- async getUserByGoogleId(googleId: string): Promise<UserData | null> {
601
- return this.userService.getUserByGoogleId(googleId) as Promise<UserData | null>;
651
+ async getUserByIdentity(provider: string, providerId: string): Promise<UserData | null> {
652
+ return this.userService.getUserByIdentity(provider, providerId) as Promise<UserData | null>;
653
+ }
654
+
655
+ async getUserIdentities(userId: string): Promise<UserIdentityData[]> {
656
+ return this.userService.getUserIdentities(userId);
657
+ }
658
+
659
+ async linkUserIdentity(userId: string, provider: string, providerId: string, profileData?: Record<string, unknown>): Promise<void> {
660
+ return this.userService.linkUserIdentity(userId, provider, providerId, profileData);
602
661
  }
603
662
 
604
663
  async updateUser(id: string, data: Partial<Omit<CreateUserData, "id">>): Promise<UserData | null> {