@rebasepro/server-postgresql 0.6.1 → 0.8.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 (55) hide show
  1. package/dist/PostgresBackendDriver.d.ts +8 -0
  2. package/dist/auth/services.d.ts +21 -1
  3. package/dist/cli-errors.d.ts +29 -0
  4. package/dist/cli-helpers.d.ts +7 -0
  5. package/dist/collections/PostgresCollectionRegistry.d.ts +2 -2
  6. package/dist/index.es.js +2987 -230
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/schema/auth-default-policies.d.ts +12 -0
  9. package/dist/schema/auth-schema.d.ts +227 -0
  10. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
  11. package/dist/schema/generate-postgres-ddl.d.ts +1 -0
  12. package/dist/services/entityService.d.ts +1 -1
  13. package/dist/utils/pg-error-utils.d.ts +10 -0
  14. package/dist/utils/table-classification.d.ts +8 -0
  15. package/package.json +15 -9
  16. package/src/PostgresBackendDriver.ts +200 -68
  17. package/src/PostgresBootstrapper.ts +34 -2
  18. package/src/auth/ensure-tables.ts +56 -1
  19. package/src/auth/services.ts +94 -1
  20. package/src/cli-errors.ts +162 -0
  21. package/src/cli-helpers.ts +183 -0
  22. package/src/cli.ts +264 -245
  23. package/src/collections/PostgresCollectionRegistry.ts +6 -6
  24. package/src/data-transformer.ts +2 -2
  25. package/src/schema/auth-default-policies.ts +97 -0
  26. package/src/schema/auth-schema.ts +25 -2
  27. package/src/schema/doctor.ts +2 -4
  28. package/src/schema/generate-drizzle-schema-logic.ts +20 -82
  29. package/src/schema/generate-drizzle-schema.ts +3 -5
  30. package/src/schema/generate-postgres-ddl-logic.ts +487 -0
  31. package/src/schema/generate-postgres-ddl.ts +116 -0
  32. package/src/services/EntityPersistService.ts +10 -8
  33. package/src/services/entityService.ts +28 -3
  34. package/src/services/realtimeService.ts +26 -2
  35. package/src/utils/pg-error-utils.ts +16 -0
  36. package/src/utils/table-classification.ts +16 -0
  37. package/src/websocket.ts +9 -0
  38. package/test/auth-default-policies.test.ts +89 -0
  39. package/test/cli-helpers-extended.test.ts +324 -0
  40. package/test/cli-helpers.test.ts +59 -0
  41. package/test/connection.test.ts +292 -0
  42. package/test/databasePoolManager.test.ts +289 -0
  43. package/test/doctor-extended.test.ts +443 -0
  44. package/test/e2e/db-e2e.test.ts +293 -0
  45. package/test/e2e/pg-setup.ts +79 -0
  46. package/test/entity-callbacks-redaction.test.ts +86 -0
  47. package/test/entity-persist-composite-keys.test.ts +451 -0
  48. package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
  49. package/test/generate-postgres-ddl.test.ts +300 -0
  50. package/test/mfa-service.test.ts +544 -0
  51. package/test/pg-error-utils.test.ts +50 -1
  52. package/test/postgresDataDriver.test.ts +2 -1
  53. package/test/realtimeService-channels.test.ts +696 -0
  54. package/test/unmapped-tables-safety.test.ts +55 -342
  55. package/vitest.e2e.config.ts +10 -0
@@ -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`.
@@ -123,6 +133,7 @@ export class PostgresBackendDriver implements DataDriver {
123
133
  if (!collection && !path) return {
124
134
  collection: undefined,
125
135
  callbacks: undefined,
136
+ globalCallbacks: undefined,
126
137
  propertyCallbacks: undefined
127
138
  };
128
139
  const registryCollection = this.registry?.getCollectionByPath(path);
@@ -134,6 +145,7 @@ export class PostgresBackendDriver implements DataDriver {
134
145
  : collection as EntityCollection<M>;
135
146
 
136
147
  const callbacks = resolvedCollection?.callbacks;
148
+ const globalCallbacks = this.registry?.getGlobalCallbacks();
137
149
  const properties = resolvedCollection?.properties;
138
150
  let propertyCallbacks;
139
151
  if (properties) {
@@ -142,6 +154,7 @@ export class PostgresBackendDriver implements DataDriver {
142
154
  return {
143
155
  collection: resolvedCollection,
144
156
  callbacks,
157
+ globalCallbacks,
145
158
  propertyCallbacks
146
159
  };
147
160
  }
@@ -174,13 +187,24 @@ export class PostgresBackendDriver implements DataDriver {
174
187
  const {
175
188
  collection: resolvedCollection,
176
189
  callbacks,
190
+ globalCallbacks,
177
191
  propertyCallbacks
178
192
  } = this.resolveCollectionCallbacks(collection, path);
179
193
 
180
- if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
194
+ if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
181
195
  const contextForCallback = this.buildCallContext();
182
196
  return Promise.all(entities.map(async (entity) => {
183
197
  let fetched = entity;
198
+ // 1. Global callbacks first
199
+ if (globalCallbacks?.afterRead) {
200
+ fetched = await globalCallbacks.afterRead({
201
+ collection: resolvedCollection as unknown as EntityCollection,
202
+ path,
203
+ entity: fetched as unknown as Entity<Record<string, unknown>>,
204
+ context: contextForCallback
205
+ }) as unknown as Entity<M>;
206
+ }
207
+ // 2. Collection callbacks second
184
208
  if (callbacks?.afterRead) {
185
209
  fetched = await callbacks.afterRead({
186
210
  collection: resolvedCollection as EntityCollection<M>,
@@ -189,13 +213,14 @@ export class PostgresBackendDriver implements DataDriver {
189
213
  context: contextForCallback
190
214
  }) ?? fetched;
191
215
  }
216
+ // 3. Property callbacks third
192
217
  if (propertyCallbacks?.afterRead) {
193
218
  fetched = await propertyCallbacks.afterRead({
194
- collection: resolvedCollection as EntityCollection<M>,
219
+ collection: resolvedCollection as unknown as EntityCollection,
195
220
  path,
196
- entity: fetched,
221
+ entity: fetched as unknown as Entity<Record<string, unknown>>,
197
222
  context: contextForCallback
198
- }) as Entity<M> ?? fetched;
223
+ }) as unknown as Entity<M>;
199
224
  }
200
225
  return fetched;
201
226
  }));
@@ -283,26 +308,38 @@ export class PostgresBackendDriver implements DataDriver {
283
308
  const {
284
309
  collection: resolvedCollection,
285
310
  callbacks,
311
+ globalCallbacks,
286
312
  propertyCallbacks
287
313
  } = this.resolveCollectionCallbacks(collection, path);
288
314
 
289
- if (entity && (callbacks?.afterRead || propertyCallbacks?.afterRead)) {
315
+ if (entity && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
290
316
  const contextForCallback = this.buildCallContext();
317
+ // 1. Global callbacks first
318
+ if (globalCallbacks?.afterRead) {
319
+ entity = await globalCallbacks.afterRead({
320
+ collection: resolvedCollection as unknown as EntityCollection,
321
+ path,
322
+ entity: entity as unknown as Entity<Record<string, unknown>>,
323
+ context: contextForCallback
324
+ }) as unknown as Entity<M>;
325
+ }
326
+ // 2. Collection callbacks second
291
327
  if (callbacks?.afterRead) {
292
328
  entity = await callbacks.afterRead({
293
329
  collection: resolvedCollection as EntityCollection<M>,
294
330
  path,
295
- entity,
331
+ entity: entity!,
296
332
  context: contextForCallback
297
- }) ?? entity;
333
+ });
298
334
  }
335
+ // 3. Property callbacks third
299
336
  if (propertyCallbacks?.afterRead) {
300
337
  entity = await propertyCallbacks.afterRead({
301
- collection: resolvedCollection as EntityCollection<M>,
338
+ collection: resolvedCollection as unknown as EntityCollection,
302
339
  path,
303
- entity,
340
+ entity: entity as unknown as Entity<Record<string, unknown>>,
304
341
  context: contextForCallback
305
- }) as Entity<M> ?? entity;
342
+ }) as unknown as Entity<M>;
306
343
  }
307
344
  }
308
345
 
@@ -365,6 +402,7 @@ export class PostgresBackendDriver implements DataDriver {
365
402
  const {
366
403
  collection: resolvedCollection,
367
404
  callbacks,
405
+ globalCallbacks,
368
406
  propertyCallbacks
369
407
  } = this.resolveCollectionCallbacks(collection, path);
370
408
 
@@ -380,7 +418,22 @@ export class PostgresBackendDriver implements DataDriver {
380
418
  }
381
419
  }
382
420
 
383
- if (callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
421
+ if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
422
+ // 1. Global callbacks first
423
+ if (globalCallbacks?.beforeSave) {
424
+ const result = await globalCallbacks.beforeSave({
425
+ collection: resolvedCollection as unknown as EntityCollection,
426
+ path,
427
+ entityId,
428
+ values: updatedValues,
429
+ previousValues: previousValuesForHistory,
430
+ status,
431
+ context: contextForCallback
432
+ });
433
+ if (result) updatedValues = mergeDeep(updatedValues, result);
434
+ }
435
+
436
+ // 2. Collection callbacks second
384
437
  if (callbacks?.beforeSave) {
385
438
  const result = await callbacks.beforeSave({
386
439
  collection: resolvedCollection as EntityCollection<M>,
@@ -394,9 +447,10 @@ export class PostgresBackendDriver implements DataDriver {
394
447
  if (result) updatedValues = mergeDeep(updatedValues, result);
395
448
  }
396
449
 
450
+ // 3. Property callbacks third
397
451
  if (propertyCallbacks?.beforeSave) {
398
452
  const result = await propertyCallbacks.beforeSave({
399
- collection: resolvedCollection as EntityCollection<M>,
453
+ collection: resolvedCollection as unknown as EntityCollection,
400
454
  path,
401
455
  entityId,
402
456
  values: updatedValues,
@@ -428,7 +482,17 @@ export class PostgresBackendDriver implements DataDriver {
428
482
  resolvedCollection?.databaseId
429
483
  );
430
484
 
431
- if (savedEntity && (callbacks?.afterRead || propertyCallbacks?.afterRead)) {
485
+ if (savedEntity && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
486
+ // 1. Global callbacks first
487
+ if (globalCallbacks?.afterRead) {
488
+ savedEntity = await globalCallbacks.afterRead({
489
+ collection: resolvedCollection as unknown as EntityCollection,
490
+ path,
491
+ entity: savedEntity as unknown as Entity<Record<string, unknown>>,
492
+ context: contextForCallback
493
+ }) as unknown as Entity<M>;
494
+ }
495
+ // 2. Collection callbacks second
432
496
  if (callbacks?.afterRead) {
433
497
  savedEntity = await callbacks.afterRead({
434
498
  collection: resolvedCollection as EntityCollection<M>,
@@ -437,17 +501,31 @@ export class PostgresBackendDriver implements DataDriver {
437
501
  context: contextForCallback
438
502
  }) ?? savedEntity;
439
503
  }
504
+ // 3. Property callbacks third
440
505
  if (propertyCallbacks?.afterRead) {
441
506
  savedEntity = await propertyCallbacks.afterRead({
442
- collection: resolvedCollection as EntityCollection<M>,
507
+ collection: resolvedCollection as unknown as EntityCollection,
443
508
  path,
444
- entity: savedEntity,
509
+ entity: savedEntity as unknown as Entity<Record<string, unknown>>,
445
510
  context: contextForCallback
446
- }) as Entity<M> ?? savedEntity;
511
+ }) as unknown as Entity<M>;
447
512
  }
448
513
  }
449
514
 
450
- if (callbacks?.afterSave || propertyCallbacks?.afterSave) {
515
+ if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
516
+ // 1. Global callbacks first
517
+ if (globalCallbacks?.afterSave) {
518
+ await globalCallbacks.afterSave({
519
+ collection: resolvedCollection as unknown as EntityCollection,
520
+ path,
521
+ entityId: savedEntity.id,
522
+ values: savedEntity.values,
523
+ previousValues: previousValuesForHistory,
524
+ status,
525
+ context: contextForCallback
526
+ });
527
+ }
528
+ // 2. Collection callbacks second
451
529
  if (callbacks?.afterSave) {
452
530
  await callbacks.afterSave({
453
531
  collection: resolvedCollection as EntityCollection<M>,
@@ -459,9 +537,10 @@ export class PostgresBackendDriver implements DataDriver {
459
537
  context: contextForCallback
460
538
  });
461
539
  }
540
+ // 3. Property callbacks third
462
541
  if (propertyCallbacks?.afterSave) {
463
542
  await propertyCallbacks.afterSave({
464
- collection: resolvedCollection as EntityCollection<M>,
543
+ collection: resolvedCollection as unknown as EntityCollection,
465
544
  path,
466
545
  entityId: savedEntity.id,
467
546
  values: savedEntity.values,
@@ -503,7 +582,20 @@ export class PostgresBackendDriver implements DataDriver {
503
582
 
504
583
  return savedEntity;
505
584
  } catch (error) {
506
- if (callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
585
+ if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
586
+ // 1. Global callbacks first
587
+ if (globalCallbacks?.afterSaveError) {
588
+ await globalCallbacks.afterSaveError({
589
+ collection: resolvedCollection as unknown as EntityCollection,
590
+ path,
591
+ entityId: entityId || "unknown",
592
+ values: updatedValues,
593
+ previousValues: undefined,
594
+ status,
595
+ context: contextForCallback
596
+ });
597
+ }
598
+ // 2. Collection callbacks second
507
599
  if (callbacks?.afterSaveError) {
508
600
  await callbacks.afterSaveError({
509
601
  collection: resolvedCollection as EntityCollection<M>,
@@ -515,9 +607,10 @@ export class PostgresBackendDriver implements DataDriver {
515
607
  context: contextForCallback
516
608
  });
517
609
  }
610
+ // 3. Property callbacks third
518
611
  if (propertyCallbacks?.afterSaveError) {
519
612
  await propertyCallbacks.afterSaveError({
520
- collection: resolvedCollection as EntityCollection<M>,
613
+ collection: resolvedCollection as unknown as EntityCollection,
521
614
  path,
522
615
  entityId: entityId || "unknown",
523
616
  values: updatedValues,
@@ -540,13 +633,28 @@ export class PostgresBackendDriver implements DataDriver {
540
633
  const {
541
634
  collection: resolvedCollection,
542
635
  callbacks,
636
+ globalCallbacks,
543
637
  propertyCallbacks
544
638
  } = this.resolveCollectionCallbacks(collection, entity.path);
545
639
 
546
640
  const contextForCallback = this.buildCallContext();
547
641
 
548
- if (callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
642
+ if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
549
643
  let preventDefault = false;
644
+ // 1. Global callbacks first
645
+ if (globalCallbacks?.beforeDelete) {
646
+ const result = await globalCallbacks.beforeDelete({
647
+ collection: resolvedCollection as unknown as EntityCollection,
648
+ path: entity.path,
649
+ entityId: entity.id,
650
+ entity,
651
+ context: contextForCallback
652
+ });
653
+ if (result === false) {
654
+ preventDefault = true;
655
+ }
656
+ }
657
+ // 2. Collection callbacks second
550
658
  if (callbacks?.beforeDelete) {
551
659
  const result = await callbacks.beforeDelete({
552
660
  collection: resolvedCollection as EntityCollection<M>,
@@ -559,9 +667,10 @@ export class PostgresBackendDriver implements DataDriver {
559
667
  preventDefault = true;
560
668
  }
561
669
  }
670
+ // 3. Property callbacks third
562
671
  if (propertyCallbacks?.beforeDelete) {
563
672
  const result = await propertyCallbacks.beforeDelete({
564
- collection: resolvedCollection as EntityCollection<M>,
673
+ collection: resolvedCollection as unknown as EntityCollection,
565
674
  path: entity.path,
566
675
  entityId: entity.id,
567
676
  entity,
@@ -582,7 +691,18 @@ export class PostgresBackendDriver implements DataDriver {
582
691
  entity.databaseId || resolvedCollection?.databaseId
583
692
  );
584
693
 
585
- if (callbacks?.afterDelete || propertyCallbacks?.afterDelete) {
694
+ if (globalCallbacks?.afterDelete || callbacks?.afterDelete || propertyCallbacks?.afterDelete) {
695
+ // 1. Global callbacks first
696
+ if (globalCallbacks?.afterDelete) {
697
+ await globalCallbacks.afterDelete({
698
+ collection: resolvedCollection as unknown as EntityCollection,
699
+ path: entity.path,
700
+ entityId: entity.id,
701
+ entity,
702
+ context: contextForCallback
703
+ });
704
+ }
705
+ // 2. Collection callbacks second
586
706
  if (callbacks?.afterDelete) {
587
707
  await callbacks.afterDelete({
588
708
  collection: resolvedCollection as EntityCollection<M>,
@@ -592,9 +712,10 @@ export class PostgresBackendDriver implements DataDriver {
592
712
  context: contextForCallback
593
713
  });
594
714
  }
715
+ // 3. Property callbacks third
595
716
  if (propertyCallbacks?.afterDelete) {
596
717
  await propertyCallbacks.afterDelete({
597
- collection: resolvedCollection as EntityCollection<M>,
718
+ collection: resolvedCollection as unknown as EntityCollection,
598
719
  path: entity.path,
599
720
  entityId: entity.id,
600
721
  entity,
@@ -684,10 +805,11 @@ export class PostgresBackendDriver implements DataDriver {
684
805
 
685
806
  async executeSql(sqlText: string, options?: {
686
807
  database?: string,
687
- role?: string
808
+ role?: string,
809
+ params?: unknown[]
688
810
  }): Promise<Record<string, unknown>[]> {
689
811
  if (!options?.database && !options?.role) {
690
- return this.entityService.executeSql(sqlText);
812
+ return this.entityService.executeSql(sqlText, options?.params);
691
813
  }
692
814
 
693
815
  const targetDb = this.getTargetDb(options?.database);
@@ -698,7 +820,7 @@ export class PostgresBackendDriver implements DataDriver {
698
820
  // as it's a no-op that can fail on managed Postgres setups where the connection
699
821
  // user doesn't have permission to SET ROLE.
700
822
  let needsRoleSwitch = false;
701
- if (options?.role && process.env.DISABLE_DB_ROLE_SWITCHING !== "true") {
823
+ if (options?.role && process.env.DISABLE_DB_ROLE_SWITCHING !== "true" && !this._roleSwitchingDisabled) {
702
824
  try {
703
825
  const currentRoleResult = await targetDb.execute(drizzleSql.raw("SELECT current_user AS role"));
704
826
  const currentRole = (currentRoleResult.rows?.[0] as Record<string, unknown>)?.role as string | undefined;
@@ -711,14 +833,57 @@ export class PostgresBackendDriver implements DataDriver {
711
833
 
712
834
  if (needsRoleSwitch && options?.role) {
713
835
  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
- });
836
+ try {
837
+ return await targetDb.transaction(async (tx) => {
838
+ await tx.execute(drizzleSql.raw(`SET LOCAL ROLE "${safeRole}"`));
839
+ let result;
840
+ if (options?.params && options.params.length > 0) {
841
+ const parts = sqlText.split(/\$(\d+)/);
842
+ const chunks: ReturnType<typeof drizzleSql.raw | typeof drizzleSql.param>[] = [];
843
+ for (let i = 0; i < parts.length; i++) {
844
+ if (i % 2 === 0) {
845
+ if (parts[i].length > 0) chunks.push(drizzleSql.raw(parts[i]));
846
+ } else {
847
+ chunks.push(drizzleSql.param(options.params[Number(parts[i]) - 1]));
848
+ }
849
+ }
850
+ result = await tx.execute(drizzleSql.join(chunks, drizzleSql.raw("")));
851
+ } else {
852
+ result = await tx.execute(drizzleSql.raw(sqlText));
853
+ }
854
+ return result.rows as Record<string, unknown>[];
855
+ });
856
+ } catch (roleError: unknown) {
857
+ if (isRoleSwitchingPermissionError(roleError)) {
858
+ logger.warn(
859
+ `[PostgresBackendDriver] SET LOCAL ROLE "${safeRole}" failed — ` +
860
+ `the connection user lacks permission. Falling back to executing ` +
861
+ `without role switching. To suppress this warning, set ` +
862
+ `DISABLE_DB_ROLE_SWITCHING=true in your .env file.`
863
+ );
864
+ this._roleSwitchingDisabled = true;
865
+ // Fall through to execute without role switching below
866
+ } else {
867
+ throw roleError;
868
+ }
869
+ }
719
870
  }
720
871
 
721
- const result = await targetDb.execute(drizzleSql.raw(sqlText));
872
+ let result;
873
+ if (options?.params && options.params.length > 0) {
874
+ const parts = sqlText.split(/\$(\d+)/);
875
+ const chunks: ReturnType<typeof drizzleSql.raw | typeof drizzleSql.param>[] = [];
876
+ for (let i = 0; i < parts.length; i++) {
877
+ if (i % 2 === 0) {
878
+ if (parts[i].length > 0) chunks.push(drizzleSql.raw(parts[i]));
879
+ } else {
880
+ chunks.push(drizzleSql.param(options.params[Number(parts[i]) - 1]));
881
+ }
882
+ }
883
+ result = await targetDb.execute(drizzleSql.join(chunks, drizzleSql.raw("")));
884
+ } else {
885
+ result = await targetDb.execute(drizzleSql.raw(sqlText));
886
+ }
722
887
  return result.rows as Record<string, unknown>[];
723
888
  } catch (error: unknown) {
724
889
  const msg = error instanceof Error ? error.message : String(error);
@@ -780,48 +945,15 @@ export class PostgresBackendDriver implements DataDriver {
780
945
  ORDER BY table_name;
781
946
  `);
782
947
 
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
948
  const allTables = result
790
949
  .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
- });
950
+ .filter((name: string) => classifyTable(name, "public") !== "rebase-internal");
796
951
 
797
952
  // Detect junction tables: tables where every column is part of a foreign key.
798
953
  // These are typically many-to-many connection tables and shouldn't be suggested.
799
954
  let junctionTables = new Set<string>();
800
955
  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));
956
+ junctionTables = await detectJunctionTables(this.executeSql.bind(this));
825
957
  } catch (e) {
826
958
  logger.warn("Could not detect junction tables", { error: e });
827
959
  }
@@ -55,6 +55,9 @@ export interface PostgresDriverInternals {
55
55
  poolManager?: DatabasePoolManager;
56
56
  }
57
57
 
58
+ // Re-export from shared CLI error utilities
59
+ import { isEconnrefused } from "./cli-errors";
60
+
58
61
  /**
59
62
  * Default PostgreSQL bootstrapper.
60
63
  *
@@ -117,10 +120,39 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
117
120
  : connection) as import("pg").Pool;
118
121
  const schemaAwareDb = createDrizzle(rawClient, { schema: mergedSchema });
119
122
 
120
- // Verify connection
123
+ // Verify connection — fail fast if the database is unreachable
121
124
  try {
122
125
  await schemaAwareDb.execute(sql`SELECT 1`);
123
- } catch (err) {
126
+ } catch (err: unknown) {
127
+ const isConnectionRefused = isEconnrefused(err);
128
+ if (isConnectionRefused) {
129
+ // Parse host/port from connection string for a helpful message
130
+ let hostInfo = pgConfig.connectionString || "unknown";
131
+ try {
132
+ const parsed = new URL(pgConfig.connectionString || "");
133
+ hostInfo = `${parsed.hostname}:${parsed.port || 5432}`;
134
+ } catch { /* use raw string */ }
135
+
136
+ const message =
137
+ `\n` +
138
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
139
+ ` ❌ Cannot connect to PostgreSQL at ${hostInfo}\n` +
140
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
141
+ `\n` +
142
+ ` The database server is not running or is not accepting\n` +
143
+ ` connections. Common fixes:\n` +
144
+ `\n` +
145
+ ` • brew services start postgresql@18\n` +
146
+ ` • docker compose up -d postgres\n` +
147
+ ` • Verify DATABASE_URL in your .env file\n` +
148
+ `\n` +
149
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
150
+ logger.error(message);
151
+ throw new Error(`Cannot connect to PostgreSQL at ${hostInfo}: connection refused. Is the database running?`);
152
+ }
153
+
154
+ // For other errors (timeouts, auth failures, etc.) warn but continue —
155
+ // the pool may recover on subsequent attempts.
124
156
  logger.error("❌ Failed to connect to PostgreSQL", { error: err });
125
157
  logger.warn("⚠️ Continuing without initial database verification. Drizzle/PG will attempt to connect on subsequent queries.");
126
158
  }
@@ -82,7 +82,37 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Ent
82
82
  const passwordResetTokensTableName = `"${authSchema}"."password_reset_tokens"`;
83
83
  const appConfigTableName = `"${authSchema}"."app_config"`;
84
84
 
85
- // ── Create tables (idempotent) ──────────────────────────────────
85
+ // ── Create users table (idempotent) ─────────────────────────────
86
+ // The users table MUST be created before any dependent auth tables
87
+ // (user_identities, refresh_tokens, etc.) because they all hold
88
+ // foreign keys referencing users(id). When a developer runs
89
+ // `pnpm dev` for the first time without `db:migrate`, this ensures
90
+ // the server can self-bootstrap.
91
+ const idDefault = userIdType === "UUID"
92
+ ? "DEFAULT gen_random_uuid()"
93
+ : userIdType === "INTEGER"
94
+ ? "GENERATED ALWAYS AS IDENTITY"
95
+ : "DEFAULT gen_random_uuid()::text";
96
+
97
+ await db.execute(sql`
98
+ CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
99
+ id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},
100
+ email VARCHAR(255) UNIQUE NOT NULL,
101
+ display_name VARCHAR(255),
102
+ photo_url VARCHAR(500),
103
+ roles TEXT[] DEFAULT '{}' NOT NULL,
104
+ password_hash VARCHAR(255),
105
+ email_verified BOOLEAN DEFAULT FALSE NOT NULL,
106
+ email_verification_token VARCHAR(255),
107
+ email_verification_sent_at TIMESTAMP WITH TIME ZONE,
108
+ is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
109
+ metadata JSONB DEFAULT '{}' NOT NULL,
110
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
111
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
112
+ )
113
+ `);
114
+
115
+ // ── Create dependent auth tables (idempotent) ───────────────────
86
116
 
87
117
  // Create user_identities table
88
118
  await db.execute(sql`
@@ -155,6 +185,31 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Ent
155
185
  ON ${sql.raw(passwordResetTokensTableName)}(user_id)
156
186
  `);
157
187
 
188
+ // Create magic link tokens table
189
+ const magicLinkTokensTableName = `"${authSchema}"."magic_link_tokens"`;
190
+ await db.execute(sql`
191
+ CREATE TABLE IF NOT EXISTS ${sql.raw(magicLinkTokensTableName)} (
192
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
193
+ user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
194
+ token_hash TEXT NOT NULL UNIQUE,
195
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
196
+ used_at TIMESTAMP WITH TIME ZONE,
197
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
198
+ )
199
+ `);
200
+
201
+ // Create index on token_hash for magic link lookups
202
+ await db.execute(sql`
203
+ CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_hash
204
+ ON ${sql.raw(magicLinkTokensTableName)}(token_hash)
205
+ `);
206
+
207
+ // Create index on user_id for magic link cleanup
208
+ await db.execute(sql`
209
+ CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user
210
+ ON ${sql.raw(magicLinkTokensTableName)}(user_id)
211
+ `);
212
+
158
213
  // Create app config table
159
214
  await db.execute(sql`
160
215
  CREATE TABLE IF NOT EXISTS ${sql.raw(appConfigTableName)} (