@rebasepro/server-postgresql 0.1.2 → 0.2.1

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 (68) hide show
  1. package/LICENSE +22 -6
  2. package/dist/common/src/util/entities.d.ts +2 -2
  3. package/dist/common/src/util/relations.d.ts +1 -1
  4. package/dist/index.es.js +1160 -612
  5. package/dist/index.es.js.map +1 -1
  6. package/dist/index.umd.js +1158 -610
  7. package/dist/index.umd.js.map +1 -1
  8. package/dist/server-postgresql/src/PostgresAdapter.d.ts +6 -0
  9. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +2 -1
  10. package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +0 -5
  11. package/dist/server-postgresql/src/auth/ensure-tables.d.ts +2 -1
  12. package/dist/server-postgresql/src/auth/services.d.ts +37 -15
  13. package/dist/server-postgresql/src/index.d.ts +1 -0
  14. package/dist/server-postgresql/src/schema/auth-schema.d.ts +43 -856
  15. package/dist/server-postgresql/src/schema/default-collections.d.ts +2 -0
  16. package/dist/server-postgresql/src/schema/doctor.d.ts +10 -1
  17. package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +1 -0
  18. package/dist/server-postgresql/src/services/entity-helpers.d.ts +1 -1
  19. package/dist/server-postgresql/src/services/realtimeService.d.ts +12 -0
  20. package/dist/server-postgresql/src/websocket.d.ts +2 -1
  21. package/dist/types/src/controllers/auth.d.ts +9 -8
  22. package/dist/types/src/controllers/client.d.ts +3 -0
  23. package/dist/types/src/types/auth_adapter.d.ts +356 -0
  24. package/dist/types/src/types/collections.d.ts +67 -2
  25. package/dist/types/src/types/database_adapter.d.ts +94 -0
  26. package/dist/types/src/types/entity_actions.d.ts +7 -1
  27. package/dist/types/src/types/entity_callbacks.d.ts +1 -1
  28. package/dist/types/src/types/entity_views.d.ts +36 -1
  29. package/dist/types/src/types/index.d.ts +2 -0
  30. package/dist/types/src/types/plugins.d.ts +1 -1
  31. package/dist/types/src/types/properties.d.ts +24 -5
  32. package/dist/types/src/types/property_config.d.ts +6 -2
  33. package/dist/types/src/types/relations.d.ts +1 -1
  34. package/dist/types/src/types/translations.d.ts +8 -0
  35. package/dist/types/src/users/user.d.ts +5 -0
  36. package/package.json +21 -15
  37. package/src/PostgresAdapter.ts +59 -0
  38. package/src/PostgresBackendDriver.ts +57 -8
  39. package/src/PostgresBootstrapper.ts +35 -15
  40. package/src/auth/ensure-tables.ts +82 -189
  41. package/src/auth/services.ts +421 -170
  42. package/src/cli.ts +44 -13
  43. package/src/data-transformer.ts +78 -8
  44. package/src/history/HistoryService.ts +25 -2
  45. package/src/index.ts +1 -0
  46. package/src/schema/auth-schema.ts +130 -98
  47. package/src/schema/default-collections.ts +68 -0
  48. package/src/schema/doctor-cli.ts +5 -1
  49. package/src/schema/doctor.ts +85 -8
  50. package/src/schema/generate-drizzle-schema-logic.ts +74 -27
  51. package/src/schema/generate-drizzle-schema.ts +13 -3
  52. package/src/schema/introspect-db-inference.ts +5 -5
  53. package/src/schema/introspect-db-logic.ts +9 -2
  54. package/src/schema/introspect-db.ts +14 -3
  55. package/src/services/EntityFetchService.ts +5 -5
  56. package/src/services/RelationService.ts +2 -2
  57. package/src/services/entity-helpers.ts +1 -1
  58. package/src/services/realtimeService.ts +145 -136
  59. package/src/utils/drizzle-conditions.ts +16 -2
  60. package/src/websocket.ts +113 -37
  61. package/test/auth-services.test.ts +163 -74
  62. package/test/data-transformer-hardening.test.ts +57 -0
  63. package/test/data-transformer.test.ts +43 -0
  64. package/test/generate-drizzle-schema.test.ts +7 -5
  65. package/test/introspect-db-utils.test.ts +4 -1
  66. package/test/postgresDataDriver.test.ts +17 -0
  67. package/test/realtimeService.test.ts +7 -7
  68. package/test/websocket.test.ts +139 -0
@@ -1159,7 +1159,7 @@ export class RelationService {
1159
1159
  if (parentFKValue !== null && parentFKValue !== undefined) {
1160
1160
  await tx.update(targetTable)
1161
1161
  .set({ [targetFKColName]: null })
1162
- .where(eq(targetFKCol, parentFKValue as unknown as string));
1162
+ .where(eq(targetFKCol, String(parentFKValue)));
1163
1163
  }
1164
1164
  continue;
1165
1165
  }
@@ -1172,7 +1172,7 @@ export class RelationService {
1172
1172
  if (parentFKValue !== null && parentFKValue !== undefined) {
1173
1173
  await tx.update(targetTable)
1174
1174
  .set({ [targetFKColName]: null })
1175
- .where(eq(targetFKCol, parentFKValue as unknown as string));
1175
+ .where(eq(targetFKCol, String(parentFKValue)));
1176
1176
  } else {
1177
1177
  console.warn(`Cannot set joinPath relation '${relation.relationName}' because parent FK value is null/undefined`);
1178
1178
  continue;
@@ -13,7 +13,7 @@ import { getTableName } from "@rebasepro/common";
13
13
 
14
14
  /**
15
15
  * Interface for Drizzle column metadata introspection.
16
- * Replaces unsafe `as unknown as Record<string, unknown>` double-cast chains.
16
+ * Replaces unsafe `as Record<string, unknown>` double-cast chains.
17
17
  */
18
18
  export interface DrizzleColumnMeta {
19
19
  columnType?: string;
@@ -23,6 +23,10 @@ export interface SubscriptionAuthContext {
23
23
  roles: string[];
24
24
  }
25
25
 
26
+ interface DataDriverWithData extends DataDriver {
27
+ data: unknown;
28
+ }
29
+
26
30
  type RealTimeListenCollectionProps = ListenCollectionProps & {
27
31
  subscriptionId: string
28
32
  };
@@ -283,29 +287,18 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
283
287
  });
284
288
 
285
289
  // Send initial data
286
- let entities;
287
- if (this.driver) {
288
- entities = await this.driver.fetchCollection({
289
- path: request.path,
290
- collection: collection,
291
- filter: request.filter,
292
- orderBy: request.orderBy,
293
- order: request.order,
294
- limit: request.limit,
295
- startAfter: request.startAfter,
296
- searchString: request.searchString
297
- });
298
- } else {
299
- entities = await this.entityService.fetchCollection(request.path, {
290
+ const entities = await this.fetchCollectionWithAuth(
291
+ request.path,
292
+ {
300
293
  filter: request.filter,
301
294
  orderBy: request.orderBy,
302
295
  order: request.order,
303
296
  limit: request.limit,
304
297
  startAfter: request.startAfter as Record<string, unknown> | undefined,
305
- databaseId: request.collection?.databaseId,
306
298
  searchString: request.searchString
307
- });
308
- }
299
+ },
300
+ authContext
301
+ );
309
302
 
310
303
  this.sendCollectionUpdate(clientId, subscriptionId, entities);
311
304
 
@@ -338,20 +331,11 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
338
331
  });
339
332
 
340
333
  // Send initial data
341
- let entity;
342
- if (this.driver) {
343
- entity = await this.driver.fetchEntity({
344
- path: request.path,
345
- entityId: request.entityId,
346
- collection: collection
347
- });
348
- } else {
349
- entity = await this.entityService.fetchEntity(
350
- request.path,
351
- request.entityId,
352
- request.collection?.databaseId
353
- );
354
- }
334
+ const entity = await this.fetchEntityWithAuth(
335
+ request.path,
336
+ String(request.entityId),
337
+ authContext
338
+ );
355
339
 
356
340
  this.sendEntityUpdate(clientId, subscriptionId, entity || null);
357
341
 
@@ -565,29 +549,28 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
565
549
  searchString: collectionRequest.searchString
566
550
  });
567
551
 
568
- // If we have auth context, wrap in a transaction with session vars
569
- if (authContext) {
570
- return await this.db.transaction(async (tx) => {
571
- await tx.execute(drizzleSql`SELECT set_config('app.user_id', ${authContext.userId}, true)`);
572
- await tx.execute(drizzleSql`SELECT set_config('app.user_roles', ${authContext.roles.join(",")}, true)`);
573
- await tx.execute(drizzleSql`SELECT set_config('app.jwt', ${JSON.stringify({ sub: authContext.userId,
574
- roles: authContext.roles })}, true)`);
575
- const txEntityService = new EntityService(tx, this.registry);
576
- let fetchedEntities;
577
- if (collectionRequest.searchString) {
578
- fetchedEntities = await txEntityService.searchEntities(
579
- notifyPath,
580
- collectionRequest.searchString,
581
- {
552
+ // Always wrap in a transaction with session vars, defaulting to anonymous context if missing
553
+ const activeAuth = authContext || { userId: "anon", roles: ["anon"] };
554
+ return await this.db.transaction(async (tx) => {
555
+ await tx.execute(drizzleSql`SELECT set_config('app.user_id', ${activeAuth.userId}, true)`);
556
+ await tx.execute(drizzleSql`SELECT set_config('app.user_roles', ${activeAuth.roles.join(",")}, true)`);
557
+ await tx.execute(drizzleSql`SELECT set_config('app.jwt', ${JSON.stringify({ sub: activeAuth.userId, roles: activeAuth.roles })}, true)`);
558
+ const txEntityService = new EntityService(tx, this.registry);
559
+ let fetchedEntities;
560
+ if (collectionRequest.searchString) {
561
+ fetchedEntities = await txEntityService.searchEntities(
562
+ notifyPath,
563
+ collectionRequest.searchString,
564
+ {
582
565
  filter: collectionRequest.filter as FilterValues<string>,
583
566
  orderBy: collectionRequest.orderBy,
584
567
  order: collectionRequest.order,
585
568
  limit: collectionRequest.limit,
586
569
  databaseId: collectionRequest.databaseId
587
570
  }
588
- );
589
- } else {
590
- fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
571
+ );
572
+ } else {
573
+ fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
591
574
  filter: collectionRequest.filter as FilterValues<string>,
592
575
  orderBy: collectionRequest.orderBy,
593
576
  order: collectionRequest.order,
@@ -596,52 +579,48 @@ roles: authContext.roles })}, true)`);
596
579
  startAfter: collectionRequest.startAfter,
597
580
  databaseId: collectionRequest.databaseId
598
581
  });
599
- }
582
+ }
600
583
 
601
- // Re-apply `afterRead` lifecycle hooks to ensure consistent data structures
602
- // between the initial driver fetch and this RLS-bound refetch.
603
- const registryCollection = this.registry.getCollectionByPath(notifyPath);
604
- const resolvedCollection = collection ? { ...collection,
584
+ // Re-apply `afterRead` lifecycle hooks to ensure consistent data structures
585
+ // between the initial driver fetch and this RLS-bound refetch.
586
+ const registryCollection = this.registry.getCollectionByPath(notifyPath);
587
+ const resolvedCollection = collection ? { ...collection,
605
588
  ...registryCollection } as EntityCollection : registryCollection as EntityCollection;
606
589
 
607
- const callbacks = resolvedCollection?.callbacks;
608
- const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
609
-
610
- if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
611
- const contextForCallback = {
612
- user: { uid: authContext.userId,
613
- roles: authContext.roles },
614
- driver: this.driver,
615
- data: this.driver ? (this.driver as unknown as Record<string, unknown>).data : undefined
616
- } as unknown as RebaseCallContext;
617
-
618
- return await Promise.all(fetchedEntities.map(async (entity) => {
619
- let processedEntity = entity;
620
- if (callbacks?.afterRead) {
621
- processedEntity = await callbacks.afterRead({
622
- collection: resolvedCollection,
623
- path: notifyPath,
624
- entity: processedEntity,
625
- context: contextForCallback
626
- }) ?? processedEntity;
627
- }
628
- if (propertyCallbacks?.afterRead) {
629
- processedEntity = await propertyCallbacks.afterRead({
630
- collection: resolvedCollection,
631
- path: notifyPath,
632
- entity: processedEntity,
633
- context: contextForCallback
634
- }) ?? processedEntity;
635
- }
636
- return processedEntity;
637
- }));
638
- }
639
-
640
- return fetchedEntities;
641
- });
642
- }
590
+ const callbacks = resolvedCollection?.callbacks;
591
+ const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
592
+
593
+ if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
594
+ const contextForCallback = {
595
+ user: { uid: activeAuth.userId, roles: activeAuth.roles },
596
+ driver: this.driver,
597
+ data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
598
+ } as unknown as RebaseCallContext;
599
+
600
+ return await Promise.all(fetchedEntities.map(async (entity) => {
601
+ let processedEntity = entity;
602
+ if (callbacks?.afterRead) {
603
+ processedEntity = await callbacks.afterRead({
604
+ collection: resolvedCollection,
605
+ path: notifyPath,
606
+ entity: processedEntity,
607
+ context: contextForCallback
608
+ }) ?? processedEntity;
609
+ }
610
+ if (propertyCallbacks?.afterRead) {
611
+ processedEntity = await propertyCallbacks.afterRead({
612
+ collection: resolvedCollection,
613
+ path: notifyPath,
614
+ entity: processedEntity,
615
+ context: contextForCallback
616
+ }) ?? processedEntity;
617
+ }
618
+ return processedEntity;
619
+ }));
620
+ }
643
621
 
644
- return fetchFn();
622
+ return fetchedEntities;
623
+ });
645
624
  }
646
625
 
647
626
  // No driver — use entityService directly (no auth wrapping possible)
@@ -726,7 +705,7 @@ roles: authContext.roles },
726
705
  */
727
706
  private async fetchEntityWithAuth(
728
707
  notifyPath: string,
729
- entityId: string,
708
+ entityId: string | number,
730
709
  authContext?: SubscriptionAuthContext
731
710
  ): Promise<Entity | undefined> {
732
711
  if (this.driver) {
@@ -737,56 +716,51 @@ roles: authContext.roles },
737
716
  collection
738
717
  });
739
718
 
740
- // If we have auth context, wrap in a transaction with session vars
741
- if (authContext) {
742
- return await this.db.transaction(async (tx) => {
743
- await tx.execute(drizzleSql`SELECT set_config('app.user_id', ${authContext.userId}, true)`);
744
- await tx.execute(drizzleSql`SELECT set_config('app.user_roles', ${authContext.roles.join(",")}, true)`);
745
- await tx.execute(drizzleSql`SELECT set_config('app.jwt', ${JSON.stringify({ sub: authContext.userId,
746
- roles: authContext.roles })}, true)`);
747
- const txEntityService = new EntityService(tx, this.registry);
748
- let processedEntity = await txEntityService.fetchEntity(notifyPath, entityId, collection?.databaseId);
749
-
750
- if (processedEntity) {
751
- const registryCollection = this.registry.getCollectionByPath(notifyPath);
752
- const resolvedCollection = collection ? { ...collection,
719
+ // Always wrap in a transaction with session vars, defaulting to anonymous context if missing
720
+ const activeAuth = authContext || { userId: "anon", roles: ["anon"] };
721
+ return await this.db.transaction(async (tx) => {
722
+ await tx.execute(drizzleSql`SELECT set_config('app.user_id', ${activeAuth.userId}, true)`);
723
+ await tx.execute(drizzleSql`SELECT set_config('app.user_roles', ${activeAuth.roles.join(",")}, true)`);
724
+ await tx.execute(drizzleSql`SELECT set_config('app.jwt', ${JSON.stringify({ sub: activeAuth.userId, roles: activeAuth.roles })}, true)`);
725
+ const txEntityService = new EntityService(tx, this.registry);
726
+ let processedEntity = await txEntityService.fetchEntity(notifyPath, entityId, collection?.databaseId);
727
+
728
+ if (processedEntity) {
729
+ const registryCollection = this.registry.getCollectionByPath(notifyPath);
730
+ const resolvedCollection = collection ? { ...collection,
753
731
  ...registryCollection } as EntityCollection : registryCollection as EntityCollection;
754
732
 
755
- const callbacks = resolvedCollection?.callbacks;
756
- const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
757
-
758
- if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
759
- const contextForCallback = {
760
- user: { uid: authContext.userId,
761
- roles: authContext.roles },
762
- driver: this.driver,
763
- data: this.driver ? (this.driver as unknown as Record<string, unknown>).data : undefined
764
- } as unknown as RebaseCallContext;
765
-
766
- if (callbacks?.afterRead) {
767
- processedEntity = await callbacks.afterRead({
768
- collection: resolvedCollection,
769
- path: notifyPath,
770
- entity: processedEntity,
771
- context: contextForCallback
772
- }) ?? processedEntity;
773
- }
774
- if (propertyCallbacks?.afterRead) {
775
- processedEntity = await propertyCallbacks.afterRead({
776
- collection: resolvedCollection,
777
- path: notifyPath,
778
- entity: processedEntity,
779
- context: contextForCallback
780
- }) ?? processedEntity;
781
- }
733
+ const callbacks = resolvedCollection?.callbacks;
734
+ const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
735
+
736
+ if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
737
+ const contextForCallback = {
738
+ user: { uid: activeAuth.userId, roles: activeAuth.roles },
739
+ driver: this.driver,
740
+ data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
741
+ } as unknown as RebaseCallContext;
742
+
743
+ if (callbacks?.afterRead) {
744
+ processedEntity = await callbacks.afterRead({
745
+ collection: resolvedCollection,
746
+ path: notifyPath,
747
+ entity: processedEntity,
748
+ context: contextForCallback
749
+ }) ?? processedEntity;
750
+ }
751
+ if (propertyCallbacks?.afterRead) {
752
+ processedEntity = await propertyCallbacks.afterRead({
753
+ collection: resolvedCollection,
754
+ path: notifyPath,
755
+ entity: processedEntity,
756
+ context: contextForCallback
757
+ }) ?? processedEntity;
782
758
  }
783
759
  }
760
+ }
784
761
 
785
- return processedEntity;
786
- });
787
- }
788
-
789
- return fetchFn();
762
+ return processedEntity;
763
+ });
790
764
  }
791
765
 
792
766
  return await this.entityService.fetchEntity(notifyPath, entityId);
@@ -865,6 +839,41 @@ roles: authContext.roles },
865
839
 
866
840
  return parentPaths;
867
841
  }
842
+ // =============================================================================
843
+ // Lifecycle / Cleanup
844
+ // =============================================================================
845
+
846
+ /**
847
+ * Gracefully tear down all realtime resources.
848
+ *
849
+ * This MUST be called during process shutdown, **before** `pool.end()`.
850
+ * It ensures:
851
+ * 1. All debounced refetch timers are cancelled (prevents queries after pool closes).
852
+ * 2. All subscription state and callbacks are cleared.
853
+ * 3. The dedicated LISTEN client (outside the pool) is disconnected.
854
+ * 4. All WebSocket clients are removed (but not forcefully closed — the
855
+ * HTTP server close will handle that).
856
+ */
857
+ async destroy(): Promise<void> {
858
+ // 1. Cancel every pending debounced refetch timer
859
+ for (const [key, timer] of this.refetchTimers) {
860
+ clearTimeout(timer);
861
+ this.refetchTimers.delete(key);
862
+ }
863
+
864
+ // 2. Clear subscriptions and callbacks
865
+ this._subscriptions.clear();
866
+ this.subscriptionCallbacks.clear();
867
+
868
+ // 3. Disconnect the dedicated LISTEN client
869
+ await this.stopListening();
870
+
871
+ // 4. Drop client references (don't close — server.close drains them)
872
+ this.clients.clear();
873
+
874
+ this.debugLog("🧹 [RealtimeService] destroy() complete — all resources released.");
875
+ }
876
+
868
877
  // =============================================================================
869
878
  // Cross-Instance LISTEN/NOTIFY
870
879
  // =============================================================================
@@ -1,5 +1,5 @@
1
1
  import { and, eq, or, sql, SQL, ilike, inArray } from "drizzle-orm";
2
- import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
2
+ import { AnyPgColumn, PgTable, PgVarchar, PgText, PgChar } from "drizzle-orm/pg-core";
3
3
  import { FilterValues, WhereFilterOp, Relation, JoinStep } from "@rebasepro/types";
4
4
  import { getColumnName, resolveCollectionRelations } from "@rebasepro/common";
5
5
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
@@ -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}`;
@@ -647,7 +653,15 @@ export class DrizzleConditionBuilder {
647
653
  if (p.type === "string" && !p.enum && p.isId !== "uuid") {
648
654
  const fieldColumn = table[key as keyof typeof table] as AnyPgColumn;
649
655
  if (fieldColumn) {
650
- searchConditions.push(ilike(fieldColumn, `%${searchString}%`));
656
+ // Verify that the underlying database column supports string pattern-matching
657
+ const supportsILike =
658
+ fieldColumn instanceof PgVarchar ||
659
+ fieldColumn instanceof PgText ||
660
+ fieldColumn instanceof PgChar ||
661
+ (fieldColumn && typeof fieldColumn === "object" && !("columnType" in fieldColumn));
662
+ if (supportsILike) {
663
+ searchConditions.push(ilike(fieldColumn, `%${searchString}%`));
664
+ }
651
665
  }
652
666
  }
653
667
  }