@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.
- package/dist/PostgresBackendDriver.d.ts +8 -0
- package/dist/auth/services.d.ts +21 -1
- package/dist/cli-errors.d.ts +29 -0
- package/dist/cli-helpers.d.ts +7 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +2 -2
- package/dist/index.es.js +2987 -230
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-default-policies.d.ts +12 -0
- package/dist/schema/auth-schema.d.ts +227 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -0
- package/dist/services/entityService.d.ts +1 -1
- package/dist/utils/pg-error-utils.d.ts +10 -0
- package/dist/utils/table-classification.d.ts +8 -0
- package/package.json +15 -9
- package/src/PostgresBackendDriver.ts +200 -68
- package/src/PostgresBootstrapper.ts +34 -2
- package/src/auth/ensure-tables.ts +56 -1
- package/src/auth/services.ts +94 -1
- package/src/cli-errors.ts +162 -0
- package/src/cli-helpers.ts +183 -0
- package/src/cli.ts +264 -245
- package/src/collections/PostgresCollectionRegistry.ts +6 -6
- package/src/data-transformer.ts +2 -2
- package/src/schema/auth-default-policies.ts +97 -0
- package/src/schema/auth-schema.ts +25 -2
- package/src/schema/doctor.ts +2 -4
- package/src/schema/generate-drizzle-schema-logic.ts +20 -82
- package/src/schema/generate-drizzle-schema.ts +3 -5
- package/src/schema/generate-postgres-ddl-logic.ts +487 -0
- package/src/schema/generate-postgres-ddl.ts +116 -0
- package/src/services/EntityPersistService.ts +10 -8
- package/src/services/entityService.ts +28 -3
- package/src/services/realtimeService.ts +26 -2
- package/src/utils/pg-error-utils.ts +16 -0
- package/src/utils/table-classification.ts +16 -0
- package/src/websocket.ts +9 -0
- package/test/auth-default-policies.test.ts +89 -0
- package/test/cli-helpers-extended.test.ts +324 -0
- package/test/cli-helpers.test.ts +59 -0
- package/test/connection.test.ts +292 -0
- package/test/databasePoolManager.test.ts +289 -0
- package/test/doctor-extended.test.ts +443 -0
- package/test/e2e/db-e2e.test.ts +293 -0
- package/test/e2e/pg-setup.ts +79 -0
- package/test/entity-callbacks-redaction.test.ts +86 -0
- package/test/entity-persist-composite-keys.test.ts +451 -0
- package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
- package/test/generate-postgres-ddl.test.ts +300 -0
- package/test/mfa-service.test.ts +544 -0
- package/test/pg-error-utils.test.ts +50 -1
- package/test/postgresDataDriver.test.ts +2 -1
- package/test/realtimeService-channels.test.ts +696 -0
- package/test/unmapped-tables-safety.test.ts +55 -342
- 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
|
|
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
|
|
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
|
-
})
|
|
333
|
+
});
|
|
298
334
|
}
|
|
335
|
+
// 3. Property callbacks third
|
|
299
336
|
if (propertyCallbacks?.afterRead) {
|
|
300
337
|
entity = await propertyCallbacks.afterRead({
|
|
301
|
-
collection: resolvedCollection as EntityCollection
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
715
|
-
await
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)} (
|