@rebasepro/server-postgresql 0.0.1-canary.dbf160a โ†’ 0.0.1-canary.e17585f

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 (74) hide show
  1. package/dist/index.es.js +683 -1362
  2. package/dist/index.es.js.map +1 -1
  3. package/dist/index.umd.js +614 -1293
  4. package/dist/index.umd.js.map +1 -1
  5. package/dist/server-postgresql/src/PostgresAdapter.d.ts +6 -0
  6. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +8 -1
  7. package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +0 -5
  8. package/dist/server-postgresql/src/index.d.ts +1 -0
  9. package/dist/server-postgresql/src/schema/introspect-db-inference.d.ts +5 -0
  10. package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +44 -9
  11. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +9 -0
  12. package/dist/server-postgresql/src/services/realtimeService.d.ts +12 -0
  13. package/dist/server-postgresql/src/websocket.d.ts +2 -1
  14. package/dist/types/src/controllers/auth.d.ts +8 -2
  15. package/dist/types/src/controllers/client.d.ts +13 -0
  16. package/dist/types/src/controllers/collection_registry.d.ts +2 -1
  17. package/dist/types/src/controllers/data_driver.d.ts +36 -1
  18. package/dist/types/src/controllers/navigation.d.ts +18 -6
  19. package/dist/types/src/controllers/registry.d.ts +9 -1
  20. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -0
  21. package/dist/types/src/rebase_context.d.ts +17 -0
  22. package/dist/types/src/types/auth_adapter.d.ts +354 -0
  23. package/dist/types/src/types/backend_hooks.d.ts +187 -0
  24. package/dist/types/src/types/collections.d.ts +75 -11
  25. package/dist/types/src/types/component_ref.d.ts +47 -0
  26. package/dist/types/src/types/cron.d.ts +1 -1
  27. package/dist/types/src/types/database_adapter.d.ts +90 -0
  28. package/dist/types/src/types/entity_views.d.ts +6 -7
  29. package/dist/types/src/types/formex.d.ts +40 -0
  30. package/dist/types/src/types/index.d.ts +5 -0
  31. package/dist/types/src/types/plugins.d.ts +6 -3
  32. package/dist/types/src/types/properties.d.ts +72 -88
  33. package/dist/types/src/types/slots.d.ts +20 -10
  34. package/dist/types/src/types/translations.d.ts +12 -0
  35. package/package.json +5 -5
  36. package/src/PostgresAdapter.ts +52 -0
  37. package/src/PostgresBackendDriver.ts +49 -7
  38. package/src/PostgresBootstrapper.ts +4 -7
  39. package/src/auth/ensure-tables.ts +3 -121
  40. package/src/cli.ts +10 -2
  41. package/src/data-transformer.ts +84 -1
  42. package/src/index.ts +1 -0
  43. package/src/schema/doctor.ts +14 -2
  44. package/src/schema/generate-drizzle-schema-logic.ts +59 -30
  45. package/src/schema/introspect-db-inference.ts +238 -0
  46. package/src/schema/introspect-db-logic.ts +365 -61
  47. package/src/schema/introspect-db.ts +66 -23
  48. package/src/services/EntityFetchService.ts +16 -0
  49. package/src/services/EntityPersistService.ts +95 -13
  50. package/src/services/realtimeService.ts +35 -0
  51. package/src/utils/drizzle-conditions.ts +6 -0
  52. package/src/websocket.ts +60 -11
  53. package/test/generate-drizzle-schema.test.ts +342 -0
  54. package/test/introspect-db-generation.test.ts +32 -10
  55. package/test/property-ordering.test.ts +395 -0
  56. package/test/relations.test.ts +4 -4
  57. package/jest-all.log +0 -3128
  58. package/jest.log +0 -49
  59. package/scratch.ts +0 -41
  60. package/test-drizzle-bug.ts +0 -18
  61. package/test-drizzle-out/0000_cultured_freak.sql +0 -7
  62. package/test-drizzle-out/0001_tiresome_professor_monster.sql +0 -1
  63. package/test-drizzle-out/meta/0000_snapshot.json +0 -55
  64. package/test-drizzle-out/meta/0001_snapshot.json +0 -63
  65. package/test-drizzle-out/meta/_journal.json +0 -20
  66. package/test-drizzle-prompt.sh +0 -2
  67. package/test-policy-prompt.sh +0 -3
  68. package/test-programmatic.ts +0 -30
  69. package/test-programmatic2.ts +0 -59
  70. package/test-schema-no-policies.ts +0 -12
  71. package/test_drizzle_mock.js +0 -3
  72. package/test_find_changed.mjs +0 -32
  73. package/test_hash.js +0 -14
  74. package/test_output.txt +0 -3145
@@ -4,6 +4,7 @@ import path from "path";
4
4
  import pg from "pg";
5
5
  import arg from "arg";
6
6
  import * as dotenv from "dotenv";
7
+ import readline from "readline";
7
8
 
8
9
  import {
9
10
  TableRow,
@@ -24,15 +25,24 @@ async function main() {
24
25
  const args = arg(
25
26
  {
26
27
  "--output": String,
28
+ "--collections": String,
27
29
  "--force": Boolean,
28
30
  "--schema": String,
31
+ "--data-inference": Boolean,
29
32
  "-o": "--output",
33
+ "-c": "--collections",
30
34
  "-f": "--force",
31
35
  },
32
36
  { permissive: true }
33
37
  );
34
38
 
35
- const outDir = args["--output"] || path.resolve(process.cwd(), "config", "collections");
39
+ const cwd = process.cwd();
40
+ const isBackendDir = path.basename(cwd) === "backend";
41
+ const defaultOutDir = isBackendDir
42
+ ? path.resolve(cwd, "..", "config", "collections")
43
+ : path.resolve(cwd, "config", "collections");
44
+
45
+ const outDir = args["--output"] || args["--collections"] || defaultOutDir;
36
46
  const force = args["--force"] || false;
37
47
  const pgSchema = args["--schema"] || "public";
38
48
 
@@ -143,32 +153,65 @@ async function main() {
143
153
 
144
154
  console.log(chalk.blue(`Found ${tablesMap.size} tables (including ${joinTables.size} detected join tables).`));
145
155
 
156
+ let runDataInference = false;
157
+ if (args["--data-inference"] !== undefined) {
158
+ runDataInference = args["--data-inference"];
159
+ } else {
160
+ const rl = readline.createInterface({
161
+ input: process.stdin,
162
+ output: process.stdout
163
+ });
164
+ const answer = await new Promise<string>((resolve) => rl.question(chalk.yellow("? Do you want to run comprehensive data inference on sampled rows to auto-detect types, formats, constraints, and UI configurations? (y/N) "), resolve));
165
+ runDataInference = answer.trim().toLowerCase() === 'y';
166
+ rl.close();
167
+ }
168
+
169
+ if (runDataInference) {
170
+ console.log(chalk.gray(`Sampling database rows for data inference...`));
171
+ }
172
+
146
173
  // Generate Collections
147
174
  const generatedFiles: string[] = [];
148
175
  const skippedFiles: string[] = [];
149
176
 
150
- for (const [tableName, meta] of tablesMap.entries()) {
151
- if (joinTables.has(tableName)) continue; // We don't generate base collections for pure join tables
152
-
153
- // โ”€โ”€ File overwrite protection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
154
- const filePath = path.join(outDir, `${tableName}.ts`);
155
- if (fs.existsSync(filePath) && !force) {
156
- skippedFiles.push(tableName);
157
- continue;
158
- }
159
-
160
- const fileContent = generateCollectionFile(
161
- tableName,
162
- meta,
163
- fks,
164
- joinTables,
165
- tablesMap,
166
- enumMap,
167
- );
168
-
169
- fs.writeFileSync(filePath, fileContent, "utf-8");
170
- generatedFiles.push(tableName);
171
- console.log(chalk.green(` โœ“ ${filePath}`));
177
+ const tablesToProcess = Array.from(tablesMap.entries()).filter(([tableName]) => !joinTables.has(tableName));
178
+
179
+ const BATCH_SIZE = 10;
180
+ for (let i = 0; i < tablesToProcess.length; i += BATCH_SIZE) {
181
+ const batch = tablesToProcess.slice(i, i + BATCH_SIZE);
182
+
183
+ await Promise.all(batch.map(async ([tableName, meta]) => {
184
+ // โ”€โ”€ File overwrite protection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
185
+ const filePath = path.join(outDir, `${tableName}.ts`);
186
+ if (fs.existsSync(filePath) && !force) {
187
+ skippedFiles.push(tableName);
188
+ return;
189
+ }
190
+
191
+ let sampleData: Record<string, unknown>[] | undefined = undefined;
192
+ if (runDataInference) {
193
+ try {
194
+ const { rows } = await client.query(`SELECT * FROM "${pgSchema}"."${tableName}" LIMIT 100`);
195
+ sampleData = rows;
196
+ } catch (err) {
197
+ console.error(chalk.yellow(`โš  Failed to sample data for table ${tableName}: ${err instanceof Error ? err.message : String(err)}`));
198
+ }
199
+ }
200
+
201
+ const fileContent = generateCollectionFile(
202
+ tableName,
203
+ meta,
204
+ fks,
205
+ joinTables,
206
+ tablesMap,
207
+ enumMap,
208
+ sampleData,
209
+ );
210
+
211
+ fs.writeFileSync(filePath, fileContent, "utf-8");
212
+ generatedFiles.push(tableName);
213
+ console.log(chalk.green(` โœ“ ${filePath}`));
214
+ }));
172
215
  }
173
216
 
174
217
  // Generate index.ts (sorted alphabetically for deterministic output)
@@ -617,6 +617,10 @@ export class EntityFetchService {
617
617
 
618
618
  return entity;
619
619
  } catch (e) {
620
+ if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
621
+ console.error(`[EntityFetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
622
+ console.error(`Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.`);
623
+ }
620
624
  console.warn(`[EntityFetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select:`, e);
621
625
  }
622
626
  }
@@ -733,6 +737,10 @@ export class EntityFetchService {
733
737
 
734
738
  return entities;
735
739
  } catch (e) {
740
+ if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
741
+ console.error(`[EntityFetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
742
+ console.error(`Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.`);
743
+ }
736
744
  console.warn(`[EntityFetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select:`, e);
737
745
  }
738
746
  }
@@ -1195,6 +1203,10 @@ export class EntityFetchService {
1195
1203
 
1196
1204
  return restRows;
1197
1205
  } catch (e) {
1206
+ if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
1207
+ console.error(`[EntityFetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
1208
+ console.error(`Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.`);
1209
+ }
1198
1210
  console.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back:`, e);
1199
1211
  }
1200
1212
  }
@@ -1305,6 +1317,10 @@ export class EntityFetchService {
1305
1317
 
1306
1318
  return restRow;
1307
1319
  } catch (e) {
1320
+ if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
1321
+ console.error(`[EntityFetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
1322
+ console.error(`Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.`);
1323
+ }
1308
1324
  console.warn(`[fetchEntityForRest] db.query.findFirst failed for ${collectionPath}, falling back:`, e);
1309
1325
  }
1310
1326
  }
@@ -111,7 +111,7 @@ export class EntityPersistService {
111
111
  targetColumnName = relation.localKey;
112
112
  } else if (relation.foreignKeyOnTarget) {
113
113
  targetColumnName = relation.foreignKeyOnTarget;
114
- } else if (relation.joinPath && relation.joinPath.length > 0) {
114
+ } else if (relation.joinPath && relation.joinPath.length === 1) {
115
115
  const targetTableName = getTableName(targetCollection);
116
116
  const relevantJoinStep = relation.joinPath.find(joinStep => joinStep.table === targetTableName);
117
117
 
@@ -123,6 +123,12 @@ export class EntityPersistService {
123
123
  const targetColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to);
124
124
  targetColumnName = targetColumnNames[0];
125
125
  }
126
+ } else if (relation.joinPath && relation.joinPath.length > 1) {
127
+ // For multi-hop relations (like many-to-many through a junction table),
128
+ // there is no direct foreign key on the target table pointing to the parent.
129
+ // The relationship is managed via the junction table.
130
+ // We shouldn't inject the parent ID directly into the target entity payload.
131
+ break;
126
132
  } else {
127
133
  throw new Error(`Relation '${relationKey}' lacks configuration for path-based saving.`);
128
134
  }
@@ -292,51 +298,127 @@ export class EntityPersistService {
292
298
 
293
299
  if (pgError) {
294
300
  const detail = pgError.detail as string | undefined;
301
+ const hint = pgError.hint as string | undefined;
295
302
  const constraint = pgError.constraint as string | undefined;
296
303
  const column = pgError.column as string | undefined;
297
304
  const table = pgError.table as string | undefined;
305
+ const dataType = pgError.dataType as string | undefined;
306
+ const pgMessage = pgError.message || "Unknown database error";
307
+
308
+ const suffix = hint ? ` Hint: ${hint}` : "";
309
+ const tableRef = table ?? collectionSlug;
298
310
 
299
311
  switch (pgError.code) {
300
312
  case "23503": // foreign_key_violation
301
313
  return new Error(
302
314
  detail
303
- ? `Foreign key constraint violated: ${detail}`
304
- : `Cannot save: a foreign key constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".`
315
+ ? `Foreign key constraint violated: ${detail}${suffix}`
316
+ : `Cannot save: a foreign key constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".${suffix}`
305
317
  );
306
318
  case "23505": // unique_violation
307
319
  return new Error(
308
320
  detail
309
- ? `Duplicate value: ${detail}`
310
- : `Cannot save: a unique constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".`
321
+ ? `Duplicate value: ${detail}${suffix}`
322
+ : `Cannot save: a unique constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".${suffix}`
311
323
  );
312
324
  case "23502": // not_null_violation
313
325
  return new Error(
314
- `Missing required field: "${column ?? "unknown"}" in "${table ?? collectionSlug}" cannot be empty.`
326
+ `Missing required field: "${column ?? "unknown"}" in "${tableRef}" cannot be empty.${suffix}`
315
327
  );
316
328
  case "23514": // check_violation
317
329
  return new Error(
318
- `Validation failed: a check constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".`
330
+ `Validation failed: a check constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".${suffix}`
331
+ );
332
+ case "22P02": // invalid_text_representation (e.g. invalid UUID, wrong enum value)
333
+ return new Error(
334
+ `Invalid data format in "${collectionSlug}": ${pgMessage}${suffix}`
335
+ );
336
+ case "22001": // string_data_right_truncation (value too long)
337
+ return new Error(
338
+ `Value too long for column "${column ?? "unknown"}" in "${tableRef}": ${pgMessage}${suffix}`
339
+ );
340
+ case "22003": // numeric_value_out_of_range
341
+ return new Error(
342
+ `Numeric value out of range for column "${column ?? "unknown"}" in "${tableRef}": ${pgMessage}${suffix}`
319
343
  );
344
+ case "42703": // undefined_column
345
+ return new Error(
346
+ `Unknown column in "${tableRef}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`
347
+ );
348
+ case "42P01": // undefined_table
349
+ return new Error(
350
+ `Table not found for "${collectionSlug}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`
351
+ );
352
+ default: {
353
+ // Unhandled PG code โ€” still surface the actual database message
354
+ const parts = [`Database error in "${collectionSlug}" [${pgError.code}]: ${pgMessage}`];
355
+ if (detail) parts.push(`Detail: ${detail}`);
356
+ if (column) parts.push(`Column: ${column}`);
357
+ if (dataType) parts.push(`Data type: ${dataType}`);
358
+ if (constraint) parts.push(`Constraint: ${constraint}`);
359
+ if (hint) parts.push(`Hint: ${hint}`);
360
+ return new Error(parts.join(". "));
361
+ }
320
362
  }
321
363
  }
322
364
 
323
- // Fall through: re-throw original
324
- if (error instanceof Error) return error;
325
- return new Error(String(error));
365
+ // No PG error found โ€” try to extract a useful message from the
366
+ // Drizzle wrapper instead of leaking the raw SQL query + params.
367
+ const causeMessage = this.extractCauseMessage(error);
368
+ if (causeMessage) {
369
+ return new Error(`Database error in "${collectionSlug}": ${causeMessage}`);
370
+ }
371
+
372
+ // Last resort: use the original error message but strip the SQL query
373
+ if (error instanceof Error) {
374
+ const cleaned = this.stripSqlFromMessage(error.message, collectionSlug);
375
+ return new Error(cleaned);
376
+ }
377
+ return new Error(`Database error in "${collectionSlug}": ${String(error)}`);
378
+ }
379
+
380
+ /**
381
+ * Walk the error cause chain and return the deepest meaningful message.
382
+ */
383
+ private extractCauseMessage(error: unknown): string | null {
384
+ if (!error || typeof error !== "object") return null;
385
+ const err = error as Error & { cause?: unknown };
386
+
387
+ if (err.cause && typeof err.cause === "object") {
388
+ const deeper = this.extractCauseMessage(err.cause);
389
+ if (deeper) return deeper;
390
+ // The cause itself has a message
391
+ if (err.cause instanceof Error && err.cause.message) {
392
+ return err.cause.message;
393
+ }
394
+ }
395
+ return null;
396
+ }
397
+
398
+ /**
399
+ * Strip the raw SQL query from a Drizzle "Failed query: ..." message,
400
+ * keeping only the error description.
401
+ */
402
+ private stripSqlFromMessage(message: string, collectionSlug: string): string {
403
+ // Drizzle format: "Failed query: <SQL>\nparams: <params>"
404
+ if (message.startsWith("Failed query:")) {
405
+ return `Failed to save entity in "${collectionSlug}". Check server logs for details.`;
406
+ }
407
+ return message;
326
408
  }
327
409
 
328
410
  /**
329
411
  * Extract the underlying PostgreSQL error from a Drizzle wrapper.
330
412
  * Drizzle wraps PG errors in a `cause` property.
331
413
  */
332
- private extractPgError(error: unknown): (Error & { code?: string; detail?: unknown; constraint?: unknown; column?: unknown; table?: unknown }) | null {
414
+ private extractPgError(error: unknown): (Error & { code?: string; detail?: unknown; hint?: unknown; constraint?: unknown; column?: unknown; table?: unknown; dataType?: unknown }) | null {
333
415
  if (!error || typeof error !== "object") return null;
334
416
 
335
417
  const err = error as Error & { code?: string; cause?: unknown; detail?: unknown };
336
418
 
337
419
  // Check if the error itself has a PG error code
338
- if (err.code && /^[0-9]{5}$/.test(err.code)) {
339
- return err as Error & { code: string; detail?: unknown; constraint?: unknown; column?: unknown; table?: unknown };
420
+ if (err.code && /^[0-9A-Z]{5}$/.test(err.code)) {
421
+ return err as Error & { code: string; detail?: unknown; hint?: unknown; constraint?: unknown; column?: unknown; table?: unknown; dataType?: unknown };
340
422
  }
341
423
 
342
424
  // Check the cause chain (Drizzle wraps PG errors)
@@ -865,6 +865,41 @@ roles: authContext.roles },
865
865
 
866
866
  return parentPaths;
867
867
  }
868
+ // =============================================================================
869
+ // Lifecycle / Cleanup
870
+ // =============================================================================
871
+
872
+ /**
873
+ * Gracefully tear down all realtime resources.
874
+ *
875
+ * This MUST be called during process shutdown, **before** `pool.end()`.
876
+ * It ensures:
877
+ * 1. All debounced refetch timers are cancelled (prevents queries after pool closes).
878
+ * 2. All subscription state and callbacks are cleared.
879
+ * 3. The dedicated LISTEN client (outside the pool) is disconnected.
880
+ * 4. All WebSocket clients are removed (but not forcefully closed โ€” the
881
+ * HTTP server close will handle that).
882
+ */
883
+ async destroy(): Promise<void> {
884
+ // 1. Cancel every pending debounced refetch timer
885
+ for (const [key, timer] of this.refetchTimers) {
886
+ clearTimeout(timer);
887
+ this.refetchTimers.delete(key);
888
+ }
889
+
890
+ // 2. Clear subscriptions and callbacks
891
+ this._subscriptions.clear();
892
+ this.subscriptionCallbacks.clear();
893
+
894
+ // 3. Disconnect the dedicated LISTEN client
895
+ await this.stopListening();
896
+
897
+ // 4. Drop client references (don't close โ€” server.close drains them)
898
+ this.clients.clear();
899
+
900
+ this.debugLog("๐Ÿงน [RealtimeService] destroy() complete โ€” all resources released.");
901
+ }
902
+
868
903
  // =============================================================================
869
904
  // Cross-Instance LISTEN/NOTIFY
870
905
  // =============================================================================
@@ -64,8 +64,14 @@ export class DrizzleConditionBuilder {
64
64
  ): SQL | null {
65
65
  switch (op) {
66
66
  case "==":
67
+ if (value === null || value === undefined) {
68
+ return sql`${column} IS NULL`;
69
+ }
67
70
  return eq(column, value);
68
71
  case "!=":
72
+ if (value === null || value === undefined) {
73
+ return sql`${column} IS NOT NULL`;
74
+ }
69
75
  return sql`${column} != ${value}`;
70
76
  case ">":
71
77
  return sql`${column} > ${value}`;
package/src/websocket.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { RealtimeService } from "./services/realtimeService";
2
2
  import { PostgresBackendDriver } from "./PostgresBackendDriver";
3
- import { DataDriver, DeleteEntityProps, FetchCollectionProps, FetchEntityProps, SaveEntityProps, TableMetadata, BranchInfo, isSQLAdmin, isSchemaAdmin } from "@rebasepro/types";
3
+ import { DataDriver, DeleteEntityProps, FetchCollectionProps, FetchEntityProps, SaveEntityProps, TableMetadata, BranchInfo, isSQLAdmin, isSchemaAdmin, AuthAdapter } from "@rebasepro/types";
4
4
  import { WebSocketServer, WebSocket } from "ws";
5
5
  import { Server } from "http";
6
6
  import { inspect } from "util";
@@ -9,9 +9,18 @@ import { extractUserFromToken, AccessTokenPayload } from "@rebasepro/server-core
9
9
  // @ts-ignore
10
10
  import { AuthConfig } from "@rebasepro/server-core";
11
11
 
12
+ /**
13
+ * Normalized user identity for WebSocket sessions.
14
+ */
15
+ interface WsUserIdentity {
16
+ userId: string;
17
+ roles: string[];
18
+ isAdmin: boolean;
19
+ }
20
+
12
21
  interface ClientSession {
13
22
  ws: WebSocket;
14
- user?: AccessTokenPayload;
23
+ user?: WsUserIdentity;
15
24
  authenticated: boolean;
16
25
  /** Sliding window message counter for rate limiting */
17
26
  messageCount: number;
@@ -42,7 +51,10 @@ const ADMIN_ONLY_TYPES = new Set([
42
51
  * Check if the current session belongs to an admin user.
43
52
  */
44
53
  function isAdminSession(session: ClientSession | undefined): boolean {
45
- if (!session?.user?.roles) return false;
54
+ if (!session?.user) return false;
55
+ // Fast path: new adapter-aware sessions set isAdmin directly
56
+ if (session.user.isAdmin) return true;
57
+ if (!session.user.roles) return false;
46
58
  return session.user.roles.some((r: unknown) => {
47
59
  if (typeof r === "string") return r === "admin";
48
60
  if (r && typeof r === "object" && "isAdmin" in r) return (r as { isAdmin: boolean }).isAdmin;
@@ -55,7 +67,8 @@ export function createPostgresWebSocket(
55
67
  server: Server,
56
68
  realtimeService: RealtimeService,
57
69
  driver: PostgresBackendDriver,
58
- authConfig?: AuthConfig
70
+ authConfig?: AuthConfig,
71
+ authAdapter?: AuthAdapter
59
72
  ) {
60
73
  const isProduction = process.env.NODE_ENV === "production";
61
74
  /** Debug logger that is suppressed in production to prevent PII/data leaks */
@@ -74,7 +87,11 @@ export function createPostgresWebSocket(
74
87
  console.error("โŒ [WebSocket Server] Error:", err);
75
88
  });
76
89
 
77
- const requireAuth = authConfig?.requireAuth !== false && authConfig?.jwtSecret;
90
+ // Auth is required when either: an adapter is present (secure by default),
91
+ // OR the config has a jwtSecret and requireAuth !== false.
92
+ const requireAuth = authAdapter
93
+ ? true
94
+ : (authConfig?.requireAuth !== false && !!authConfig?.jwtSecret);
78
95
 
79
96
  wss.on("connection", (ws) => {
80
97
  const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
@@ -123,21 +140,53 @@ code } }
123
140
  return;
124
141
  }
125
142
 
126
- const user = extractUserFromToken(token);
127
- if (user) {
143
+ // Use the auth adapter when available (custom auth, Clerk, etc.)
144
+ // Fall back to JWT extraction otherwise.
145
+ let verifiedUser: WsUserIdentity | null = null;
146
+
147
+ if (authAdapter) {
148
+ try {
149
+ const adapterUser = authAdapter.verifyToken
150
+ ? await authAdapter.verifyToken(token)
151
+ : await authAdapter.verifyRequest(new Request("http://localhost/_ws_auth", {
152
+ headers: { Authorization: `Bearer ${token}` },
153
+ }));
154
+
155
+ if (adapterUser) {
156
+ verifiedUser = {
157
+ userId: adapterUser.uid,
158
+ roles: adapterUser.roles,
159
+ isAdmin: adapterUser.isAdmin,
160
+ };
161
+ }
162
+ } catch {
163
+ // Adapter threw โ€” treat as invalid token
164
+ }
165
+ } else {
166
+ // Standard JWT path
167
+ const jwtPayload = extractUserFromToken(token);
168
+ if (jwtPayload) {
169
+ verifiedUser = {
170
+ userId: jwtPayload.userId,
171
+ roles: jwtPayload.roles ?? [],
172
+ isAdmin: (jwtPayload.roles ?? []).some((r: string) => r === "admin"),
173
+ };
174
+ }
175
+ }
176
+
177
+ if (verifiedUser) {
128
178
  const session = clientSessions.get(clientId);
129
179
  if (session) {
130
- session.user = user;
180
+ session.user = verifiedUser;
131
181
  session.authenticated = true;
132
182
  }
133
183
  wsDebug(`[WS] replying AUTH_SUCCESS for requestId ${requestId}`);
134
184
  ws.send(JSON.stringify({
135
185
  type: "AUTH_SUCCESS",
136
186
  requestId,
137
- payload: { userId: user.userId,
138
- roles: user.roles }
187
+ payload: { userId: verifiedUser.userId, roles: verifiedUser.roles }
139
188
  }));
140
- wsDebug(`๐Ÿ” [WebSocket Server] Client ${clientId} authenticated as ${user.userId}`);
189
+ wsDebug(`๐Ÿ” [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.userId}`);
141
190
  } else {
142
191
  wsDebug(`[WS] replying AUTH_ERROR for requestId ${requestId} (invalid token)`);
143
192
  sendError("AUTH_ERROR", "INVALID_TOKEN", "Invalid or expired token");