@rebasepro/server-postgresql 0.3.0 → 0.4.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/common/src/collections/default-collections.d.ts +5 -8
- package/dist/common/src/data/query_builder.d.ts +6 -2
- package/dist/index.es.js +301 -500
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +297 -496
- package/dist/index.umd.js.map +1 -1
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +2 -0
- package/dist/server-postgresql/src/auth/ensure-tables.d.ts +7 -4
- package/dist/server-postgresql/src/auth/services.d.ts +6 -31
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +87 -340
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +2 -1
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +4 -0
- package/dist/server-postgresql/src/services/entityService.d.ts +4 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +5 -1
- package/dist/types/src/controllers/auth.d.ts +2 -2
- package/dist/types/src/controllers/client.d.ts +25 -40
- package/dist/types/src/controllers/data.d.ts +21 -3
- package/dist/types/src/controllers/data_driver.d.ts +5 -0
- package/dist/types/src/controllers/email.d.ts +2 -0
- package/dist/types/src/types/auth_adapter.d.ts +3 -56
- package/dist/types/src/types/backend.d.ts +2 -2
- package/dist/types/src/types/backend_hooks.d.ts +2 -17
- package/dist/types/src/types/collections.d.ts +9 -5
- package/dist/types/src/types/entity_views.d.ts +19 -28
- package/dist/types/src/types/properties.d.ts +9 -7
- package/dist/types/src/types/user_management_delegate.d.ts +16 -53
- package/dist/types/src/users/index.d.ts +0 -1
- package/dist/types/src/users/user.d.ts +0 -1
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +10 -0
- package/src/PostgresBootstrapper.ts +25 -21
- package/src/auth/ensure-tables.ts +82 -129
- package/src/auth/services.ts +71 -170
- package/src/schema/auth-schema.ts +13 -69
- package/src/schema/doctor.ts +44 -3
- package/src/schema/generate-drizzle-schema-logic.ts +33 -3
- package/src/schema/generate-drizzle-schema.ts +2 -6
- package/src/schema/introspect-db-logic.ts +7 -0
- package/src/services/EntityFetchService.ts +13 -1
- package/src/services/EntityPersistService.ts +9 -0
- package/src/services/entityService.ts +7 -0
- package/src/utils/drizzle-conditions.ts +40 -5
- package/src/websocket.ts +1 -3
- package/test/auth-services.test.ts +7 -150
- package/test/doctor.test.ts +6 -2
- package/test/relation-pipeline-gaps.test.ts +315 -0
- package/dist/server-postgresql/src/schema/default-collections.d.ts +0 -2
- package/dist/types/src/users/roles.d.ts +0 -14
- 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,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
|
-
};
|