@rebasepro/server-postgresql 0.3.0 → 0.5.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 (60) hide show
  1. package/README.md +69 -89
  2. package/dist/common/src/collections/default-collections.d.ts +5 -8
  3. package/dist/common/src/data/query_builder.d.ts +6 -2
  4. package/dist/common/src/util/permissions.d.ts +14 -6
  5. package/dist/index.es.js +379 -611
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/index.umd.js +375 -607
  8. package/dist/index.umd.js.map +1 -1
  9. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +2 -0
  10. package/dist/server-postgresql/src/auth/ensure-tables.d.ts +7 -4
  11. package/dist/server-postgresql/src/auth/services.d.ts +17 -42
  12. package/dist/server-postgresql/src/data-transformer.d.ts +0 -3
  13. package/dist/server-postgresql/src/databasePoolManager.d.ts +1 -1
  14. package/dist/server-postgresql/src/schema/auth-schema.d.ts +87 -340
  15. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +2 -1
  16. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +4 -0
  17. package/dist/server-postgresql/src/services/entityService.d.ts +4 -0
  18. package/dist/server-postgresql/src/types.d.ts +3 -0
  19. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +5 -1
  20. package/dist/server-postgresql/src/websocket.d.ts +8 -3
  21. package/dist/types/src/controllers/auth.d.ts +2 -2
  22. package/dist/types/src/controllers/client.d.ts +25 -40
  23. package/dist/types/src/controllers/data.d.ts +21 -3
  24. package/dist/types/src/controllers/data_driver.d.ts +5 -0
  25. package/dist/types/src/controllers/email.d.ts +2 -0
  26. package/dist/types/src/types/auth_adapter.d.ts +3 -56
  27. package/dist/types/src/types/backend.d.ts +38 -3
  28. package/dist/types/src/types/backend_hooks.d.ts +2 -17
  29. package/dist/types/src/types/collections.d.ts +30 -6
  30. package/dist/types/src/types/entity_views.d.ts +19 -28
  31. package/dist/types/src/types/properties.d.ts +9 -15
  32. package/dist/types/src/types/user_management_delegate.d.ts +16 -53
  33. package/dist/types/src/users/index.d.ts +0 -1
  34. package/dist/types/src/users/user.d.ts +0 -1
  35. package/package.json +6 -6
  36. package/src/PostgresBackendDriver.ts +10 -0
  37. package/src/PostgresBootstrapper.ts +27 -22
  38. package/src/auth/ensure-tables.ts +82 -129
  39. package/src/auth/services.ts +99 -197
  40. package/src/cli.ts +50 -23
  41. package/src/data-transformer.ts +57 -95
  42. package/src/databasePoolManager.ts +2 -1
  43. package/src/schema/auth-schema.ts +13 -69
  44. package/src/schema/doctor.ts +44 -3
  45. package/src/schema/generate-drizzle-schema-logic.ts +33 -3
  46. package/src/schema/generate-drizzle-schema.ts +2 -6
  47. package/src/schema/introspect-db-logic.ts +7 -0
  48. package/src/services/EntityFetchService.ts +13 -1
  49. package/src/services/EntityPersistService.ts +38 -12
  50. package/src/services/entityService.ts +7 -0
  51. package/src/types.ts +4 -0
  52. package/src/utils/drizzle-conditions.ts +40 -5
  53. package/src/websocket.ts +38 -25
  54. package/test/auth-services.test.ts +7 -150
  55. package/test/doctor.test.ts +6 -2
  56. package/test/relation-pipeline-gaps.test.ts +315 -0
  57. package/dist/server-postgresql/src/schema/default-collections.d.ts +0 -2
  58. package/dist/types/src/users/roles.d.ts +0 -14
  59. package/drizzle.test.config.ts +0 -10
  60. package/src/schema/default-collections.ts +0 -69
@@ -635,3 +635,318 @@ describe("sanitizeRelation: auto-inferred junction table naming", () => {
635
635
  expect(normalized.through).toBeUndefined();
636
636
  });
637
637
  });
638
+
639
+ // ═══════════════════════════════════════════════════════════════════════
640
+ // 4. Owning direction batch relation loading (tasks → client pattern)
641
+ // ═══════════════════════════════════════════════════════════════════════
642
+
643
+ describe("batchFetchRelatedEntities: owning direction (FK-based)", () => {
644
+ let registry: PostgresCollectionRegistry;
645
+
646
+ // Mock collections simulating tasks → clients owning relation
647
+ const mockClientsTable = {
648
+ id: { name: "id", dataType: "string" },
649
+ name: { name: "name" },
650
+ email: { name: "email" },
651
+ _def: { tableName: "clients" }
652
+ };
653
+
654
+ const mockTasksTable = {
655
+ id: { name: "id", dataType: "string" },
656
+ clientId: { name: "client_id", dataType: "string" },
657
+ title: { name: "title" },
658
+ _def: { tableName: "tasks" }
659
+ };
660
+
661
+ const clientsCollection: EntityCollection = {
662
+ slug: "clients",
663
+ name: "Clients",
664
+ table: "clients",
665
+ properties: {
666
+ id: { type: "string", isId: "uuid" },
667
+ name: { type: "string" },
668
+ email: { type: "string" }
669
+ }
670
+ };
671
+
672
+ const tasksCollection: EntityCollection = {
673
+ slug: "tasks",
674
+ name: "Tasks",
675
+ table: "tasks",
676
+ properties: {
677
+ id: { type: "string", isId: "uuid" },
678
+ clientId: { type: "string", columnName: "client_id" },
679
+ title: { type: "string" },
680
+ client: {
681
+ type: "relation",
682
+ relationName: "client",
683
+ target: () => clientsCollection,
684
+ cardinality: "one",
685
+ direction: "owning",
686
+ localKey: "clientId"
687
+ } as any
688
+ }
689
+ };
690
+
691
+ /**
692
+ * Mock DB that returns different results for sequential queries.
693
+ * The owning-direction path issues 2 queries:
694
+ * 1. SELECT parentId, fkValue FROM tasks WHERE id IN (...)
695
+ * 2. SELECT * FROM clients WHERE id IN (...)
696
+ */
697
+ function createSequencedMockDb(resultSequence: (() => unknown[])[]) {
698
+ let queryIndex = 0;
699
+
700
+ function makeChainable(): Record<string, unknown> {
701
+ const chain: Record<string, unknown> = {
702
+ select: jest.fn(() => chain),
703
+ from: jest.fn(() => chain),
704
+ where: jest.fn(() => chain),
705
+ $dynamic: jest.fn(() => chain),
706
+ limit: jest.fn(() => chain),
707
+ offset: jest.fn(() => chain),
708
+ orderBy: jest.fn(() => chain),
709
+ innerJoin: jest.fn(() => chain),
710
+ then: (resolve: (val: unknown[]) => void) => {
711
+ const idx = queryIndex++;
712
+ resolve(resultSequence[idx] ? resultSequence[idx]() : []);
713
+ }
714
+ };
715
+ return chain;
716
+ }
717
+
718
+ return makeChainable() as unknown as jest.Mocked<NodePgDatabase>;
719
+ }
720
+
721
+ beforeEach(() => {
722
+ registry = new PostgresCollectionRegistry();
723
+
724
+ jest.spyOn(registry, "getCollectionByPath").mockImplementation(path => {
725
+ if (path?.startsWith("tasks")) return tasksCollection;
726
+ if (path?.startsWith("clients")) return clientsCollection;
727
+ return undefined;
728
+ });
729
+
730
+ jest.spyOn(registry, "getTable").mockImplementation(tableName => {
731
+ if (tableName === "tasks") return mockTasksTable as any;
732
+ if (tableName === "clients") return mockClientsTable as any;
733
+ return undefined;
734
+ });
735
+
736
+ jest.spyOn(registry, "getCollections").mockReturnValue([
737
+ tasksCollection, clientsCollection
738
+ ]);
739
+ });
740
+
741
+ afterEach(() => {
742
+ jest.restoreAllMocks();
743
+ });
744
+
745
+ it("should batch-load owning relation data with correct FK mapping", async () => {
746
+ const clientUuid = "77e340ca-c6f1-4559-a360-a853a87c066c";
747
+ const taskUuid = "46737ae3-a3f3-4663-92d4-17aecdabbd38";
748
+
749
+ const db = createSequencedMockDb([
750
+ // Query 1: FK lookup from tasks table
751
+ () => [{ parentId: taskUuid, fkValue: clientUuid }],
752
+ // Query 2: Target entity from clients table
753
+ () => [{ id: clientUuid, name: "Francesco", email: "f@test.com" }]
754
+ ]);
755
+
756
+ const service = new RelationService(db, registry);
757
+ const relation = tasksCollection.properties.client as unknown as Relation;
758
+
759
+ const results = await service.batchFetchRelatedEntities(
760
+ "tasks", [taskUuid], "client", relation
761
+ );
762
+
763
+ expect(results.size).toBe(1);
764
+
765
+ const clientEntity = results.get(taskUuid);
766
+ expect(clientEntity).toBeDefined();
767
+ expect(clientEntity!.id).toBe(clientUuid);
768
+ expect(clientEntity!.path).toBe("clients");
769
+ expect(clientEntity!.values).toBeDefined();
770
+ expect(clientEntity!.values.name).toBe("Francesco");
771
+ expect(clientEntity!.values.email).toBe("f@test.com");
772
+ });
773
+
774
+ it("should handle multiple tasks pointing to the same client", async () => {
775
+ const clientUuid = "77e340ca-c6f1-4559-a360-a853a87c066c";
776
+ const task1 = "task-1-uuid";
777
+ const task2 = "task-2-uuid";
778
+
779
+ const db = createSequencedMockDb([
780
+ // Both tasks have the same clientId
781
+ () => [
782
+ { parentId: task1, fkValue: clientUuid },
783
+ { parentId: task2, fkValue: clientUuid }
784
+ ],
785
+ // Only one client row
786
+ () => [{ id: clientUuid, name: "Francesco", email: "f@test.com" }]
787
+ ]);
788
+
789
+ const service = new RelationService(db, registry);
790
+ const relation = tasksCollection.properties.client as unknown as Relation;
791
+
792
+ const results = await service.batchFetchRelatedEntities(
793
+ "tasks", [task1, task2], "client", relation
794
+ );
795
+
796
+ expect(results.size).toBe(2);
797
+ expect(results.get(task1)!.values.name).toBe("Francesco");
798
+ expect(results.get(task2)!.values.name).toBe("Francesco");
799
+ });
800
+
801
+ it("should handle tasks with null FK values gracefully", async () => {
802
+ const task1 = "task-1-uuid";
803
+
804
+ const db = createSequencedMockDb([
805
+ // FK is null
806
+ () => [{ parentId: task1, fkValue: null }],
807
+ ]);
808
+
809
+ const service = new RelationService(db, registry);
810
+ const relation = tasksCollection.properties.client as unknown as Relation;
811
+
812
+ const results = await service.batchFetchRelatedEntities(
813
+ "tasks", [task1], "client", relation
814
+ );
815
+
816
+ // No results because FK is null
817
+ expect(results.size).toBe(0);
818
+ });
819
+ });
820
+
821
+ // ═══════════════════════════════════════════════════════════════════════
822
+ // 5. Relation data round-trip: createRelationRefWithData → JSON → reviver
823
+ // ═══════════════════════════════════════════════════════════════════════
824
+
825
+ import { createRelationRefWithData } from "@rebasepro/common";
826
+ import { EntityRelation } from "@rebasepro/types";
827
+
828
+ // Inline reviver for test isolation (matches packages/client/src/reviver.ts)
829
+ function rebaseReviver(_key: string, value: unknown): unknown {
830
+ if (value && typeof value === "object" && "__type" in value) {
831
+ const record = value as Record<string, unknown>;
832
+ switch (record.__type) {
833
+ case "relation":
834
+ case "EntityRelation":
835
+ return new EntityRelation(
836
+ record.id as string | number,
837
+ record.path as string,
838
+ record.data as any
839
+ );
840
+ default:
841
+ return value;
842
+ }
843
+ }
844
+ return value;
845
+ }
846
+
847
+ describe("Relation data JSON round-trip", () => {
848
+ it("should preserve relation data through JSON.stringify → JSON.parse with reviver", () => {
849
+ const clientEntity = {
850
+ id: "client-uuid-123",
851
+ path: "clients",
852
+ values: {
853
+ name: "Francesco",
854
+ email: "f@test.com",
855
+ status: "active"
856
+ }
857
+ };
858
+
859
+ // Server creates this
860
+ const ref = createRelationRefWithData(clientEntity.id, clientEntity.path, clientEntity as any);
861
+
862
+ // Verify server-side structure
863
+ expect(ref.__type).toBe("relation");
864
+ expect(ref.id).toBe("client-uuid-123");
865
+ expect(ref.path).toBe("clients");
866
+ expect(ref.data).toBeDefined();
867
+ expect(ref.data.values.name).toBe("Francesco");
868
+
869
+ // Simulate full entity with relation in values
870
+ const taskEntity = {
871
+ id: "task-uuid-456",
872
+ path: "tasks",
873
+ values: {
874
+ title: "Send intro email",
875
+ client: ref,
876
+ status: "pending"
877
+ }
878
+ };
879
+
880
+ // Server JSON.stringify for WebSocket
881
+ const json = JSON.stringify(taskEntity);
882
+
883
+ // Client JSON.parse with reviver
884
+ const parsed = JSON.parse(json, rebaseReviver);
885
+
886
+ // The client relation should be an EntityRelation instance
887
+ const clientRelation = parsed.values.client;
888
+ expect(clientRelation).toBeInstanceOf(EntityRelation);
889
+ expect(clientRelation.id).toBe("client-uuid-123");
890
+ expect(clientRelation.path).toBe("clients");
891
+
892
+ // The data field should be preserved
893
+ expect(clientRelation.data).toBeDefined();
894
+ expect(clientRelation.data.values).toBeDefined();
895
+ expect(clientRelation.data.values.name).toBe("Francesco");
896
+ expect(clientRelation.data.values.email).toBe("f@test.com");
897
+ });
898
+
899
+ it("should handle entity with no relation data (stub)", () => {
900
+ const stubRef = { id: "client-uuid", path: "clients", __type: "relation" as const };
901
+
902
+ const json = JSON.stringify({ values: { client: stubRef } });
903
+ const parsed = JSON.parse(json, rebaseReviver);
904
+
905
+ const clientRelation = parsed.values.client;
906
+ expect(clientRelation).toBeInstanceOf(EntityRelation);
907
+ expect(clientRelation.id).toBe("client-uuid");
908
+
909
+ // data should be undefined for stubs
910
+ expect(clientRelation.data).toBeUndefined();
911
+ });
912
+
913
+ it("should handle WebSocket collection_update message format", () => {
914
+ const clientEntity = {
915
+ id: "client-uuid",
916
+ path: "clients",
917
+ values: { name: "Acme Corp", email: "acme@corp.com" }
918
+ };
919
+
920
+ const ref = createRelationRefWithData(clientEntity.id, clientEntity.path, clientEntity as any);
921
+
922
+ // Simulate full WebSocket message
923
+ const wsMessage = {
924
+ type: "collection_update",
925
+ subscriptionId: "sub-123",
926
+ entities: [
927
+ {
928
+ id: "task-1",
929
+ path: "tasks",
930
+ values: { title: "Task A", client: ref, status: "pending" }
931
+ },
932
+ {
933
+ id: "task-2",
934
+ path: "tasks",
935
+ values: { title: "Task B", client: ref, status: "completed" }
936
+ }
937
+ ]
938
+ };
939
+
940
+ const json = JSON.stringify(wsMessage);
941
+ const parsed = JSON.parse(json, rebaseReviver);
942
+
943
+ // Both tasks should have correctly hydrated client relations
944
+ for (const entity of parsed.entities) {
945
+ const clientRel = entity.values.client;
946
+ expect(clientRel).toBeInstanceOf(EntityRelation);
947
+ expect(clientRel.data).toBeDefined();
948
+ expect(clientRel.data.values.name).toBe("Acme Corp");
949
+ }
950
+ });
951
+ });
952
+
@@ -1,2 +0,0 @@
1
- import { PostgresCollection } from "@rebasepro/types";
2
- export declare const defaultUsersCollection: PostgresCollection;
@@ -1,14 +0,0 @@
1
- export type Role = {
2
- /**
3
- * ID of the role
4
- */
5
- id: string;
6
- /**
7
- * Name of the role
8
- */
9
- name: string;
10
- /**
11
- * If this flag is true, the user can perform any action
12
- */
13
- isAdmin?: boolean;
14
- };
@@ -1,10 +0,0 @@
1
- import { defineConfig } from "drizzle-kit";
2
-
3
- export default defineConfig({
4
- schema: "./test-schema-no-policies.ts",
5
- out: "./drizzle-test-out",
6
- dialect: "postgresql",
7
- dbCredentials: {
8
- url: process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/postgres"
9
- }
10
- });
@@ -1,69 +0,0 @@
1
- import { PostgresCollection } from "@rebasepro/types";
2
-
3
- export const defaultUsersCollection: PostgresCollection = {
4
- name: "Users",
5
- singularName: "User",
6
- slug: "users",
7
- table: "users",
8
- schema: "rebase",
9
- icon: "Users",
10
- group: "Settings",
11
- properties: {
12
- id: {
13
- name: "ID",
14
- type: "string",
15
- isId: "uuid"
16
- },
17
- email: {
18
- name: "Email",
19
- type: "string",
20
- validation: { required: true, unique: true }
21
- },
22
- password_hash: {
23
- name: "Password Hash",
24
- type: "string",
25
- ui: { hideFromCollection: true }
26
- },
27
- display_name: {
28
- name: "Display Name",
29
- type: "string"
30
- },
31
- photo_url: {
32
- name: "Photo URL",
33
- type: "string"
34
- },
35
- email_verified: {
36
- name: "Email Verified",
37
- type: "boolean",
38
- defaultValue: false
39
- },
40
- email_verification_token: {
41
- name: "Email Verification Token",
42
- type: "string",
43
- ui: { hideFromCollection: true }
44
- },
45
- email_verification_sent_at: {
46
- name: "Email Verification Sent At",
47
- type: "date",
48
- ui: { hideFromCollection: true }
49
- },
50
- metadata: {
51
- name: "Metadata",
52
- type: "map",
53
- defaultValue: {},
54
- ui: { hideFromCollection: true }
55
- },
56
- created_at: {
57
- name: "Created At",
58
- type: "date",
59
- autoValue: "on_create",
60
- ui: { readOnly: true, hideFromCollection: true }
61
- },
62
- updated_at: {
63
- name: "Updated At",
64
- type: "date",
65
- autoValue: "on_update",
66
- ui: { readOnly: true, hideFromCollection: true }
67
- }
68
- }
69
- };