@lobb-js/core 0.23.0 → 0.24.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.
@@ -1,10 +1,10 @@
1
- import type { FindAllParamsInput } from "../../types/index.ts";
1
+ import type { FindAllParams, FindAllParamsOutput, FindOneParams, ChildParams, CreateChildren, UpdateChildren } from "../../types/index.ts";
2
2
  import type { DatabaseDriver } from "../../types/index.ts";
3
3
  import type { MassReturn } from "../../types/index.ts";
4
4
  import type { PoolClient } from "pg";
5
5
  import type { Context as HonoContext } from "hono";
6
6
 
7
- import { findAllParamsSchema } from "../../types/index.ts";
7
+ import { findAllParamsSchema, findOneParamsSchema } from "../../types/index.ts";
8
8
  import { ZodError } from "zod";
9
9
  import { LobbError } from "../../LobbError.ts";
10
10
  import { Lobb } from "../../Lobb.ts";
@@ -36,7 +36,7 @@ export class CollectionStore {
36
36
  client,
37
37
  }: {
38
38
  collectionName: string;
39
- params?: FindAllParamsInput;
39
+ params?: FindAllParams;
40
40
  triggeredBy?: TriggeredBy;
41
41
  context?: HonoContext;
42
42
  client?: PoolClient;
@@ -87,12 +87,19 @@ export class CollectionStore {
87
87
  );
88
88
  }
89
89
 
90
- const parsedParams = findAllParamsSchema.parse(params);
90
+ const parsedParams = findAllParamsSchema.parse(params ?? {});
91
+
92
+ const resolvedParams: FindAllParamsOutput = {
93
+ ...parsedParams,
94
+ limit: parsedParams.limit ?? 100,
95
+ offset: parsedParams.offset ?? 0,
96
+ fields: buildDriverFields(collectionName, parsedParams.fields),
97
+ };
91
98
 
92
99
  try {
93
100
  const result = await this.dbDriver.findAll(
94
101
  collectionName,
95
- parsedParams,
102
+ resolvedParams,
96
103
  client,
97
104
  );
98
105
 
@@ -108,6 +115,13 @@ export class CollectionStore {
108
115
  },
109
116
  )).data;
110
117
 
118
+ result.data = await this.resolveFields(collectionName, result.data, parsedParams.fields, client) as any;
119
+ result.data = stripInternalFields(collectionName, parsedParams.fields, result.data) as any;
120
+
121
+ if (parsedParams.children && Object.keys(parsedParams.children).length > 0) {
122
+ result.data = await this.resolveChildren(collectionName, result.data, parsedParams.children, client) as any;
123
+ }
124
+
111
125
  return result;
112
126
  } catch (error: any) {
113
127
  if (error instanceof ZodError) {
@@ -128,18 +142,22 @@ export class CollectionStore {
128
142
  public async findOne({
129
143
  collectionName,
130
144
  id,
145
+ params,
131
146
  triggeredBy = "INTERNAL",
132
147
  context,
133
148
  client,
134
149
  }: {
135
150
  collectionName: string;
136
151
  id: string;
152
+ params?: FindOneParams;
137
153
  triggeredBy?: TriggeredBy;
138
154
  context?: HonoContext;
139
155
  client?: PoolClient;
140
156
  }): Promise<ExposedServiceOutput> {
141
157
  return await beginTransaction(
142
158
  async (client) => {
159
+ const { fields } = findOneParamsSchema.parse(params ?? {});
160
+
143
161
  const eventResult: any = await Lobb.instance.eventSystem.emit(
144
162
  `core.store.preFindOne`,
145
163
  {
@@ -151,32 +169,27 @@ export class CollectionStore {
151
169
  },
152
170
  );
153
171
 
154
- const params: FindAllParamsInput = {};
155
-
156
- params.filter = {
157
- id: eventResult.id,
172
+ const queryParams: FindAllParams = {
173
+ fields,
174
+ children: params?.children,
175
+ limit: 1,
176
+ offset: 0,
177
+ filter: { id: eventResult.id },
158
178
  };
159
179
 
160
180
  if (eventResult.filter) {
161
- if (params.filter) {
162
- params.filter = {
163
- $and: [
164
- params.filter,
165
- eventResult.filter,
166
- ],
167
- };
168
- } else {
169
- params.filter = eventResult.filter;
170
- }
181
+ queryParams.filter = { $and: [queryParams.filter, eventResult.filter] };
171
182
  }
172
183
 
173
- const parsedParams = findAllParamsSchema.parse(params);
174
-
175
- let data = (await this.dbDriver.findAll(
184
+ const findAllResult = await this.findAll({
176
185
  collectionName,
177
- parsedParams,
186
+ params: queryParams,
187
+ triggeredBy,
188
+ context,
178
189
  client,
179
- )).data[0];
190
+ });
191
+
192
+ let data = findAllResult.data[0];
180
193
 
181
194
  if (!data) {
182
195
  throw new LobbError({
@@ -207,12 +220,14 @@ export class CollectionStore {
207
220
  public async createOne({
208
221
  collectionName,
209
222
  data,
223
+ children,
210
224
  triggeredBy = "INTERNAL",
211
225
  context,
212
226
  client,
213
227
  }: {
214
228
  collectionName: string;
215
229
  data: Record<string, any>;
230
+ children?: CreateChildren;
216
231
  triggeredBy?: TriggeredBy;
217
232
  context?: HonoContext;
218
233
  client?: PoolClient;
@@ -221,6 +236,10 @@ export class CollectionStore {
221
236
  async (client) => {
222
237
  await this.handleSingletonCollection(collectionName, context, client);
223
238
 
239
+ if (!Lobb.instance.configManager.isCollectionVirtual(collectionName)) {
240
+ data = await this.processInlineFKCreates(collectionName, data, triggeredBy, context, client);
241
+ }
242
+
224
243
  const result = await Lobb.instance.eventSystem.emit(
225
244
  `core.store.preCreateOne`,
226
245
  {
@@ -240,6 +259,7 @@ export class CollectionStore {
240
259
  );
241
260
  }
242
261
 
262
+
243
263
  data = (await Lobb.instance.eventSystem.emit(
244
264
  `core.store.createOne`,
245
265
  {
@@ -251,6 +271,10 @@ export class CollectionStore {
251
271
  },
252
272
  )).data;
253
273
 
274
+ if (children) {
275
+ await this.processChildrenOps(collectionName, data.id, children, triggeredBy, context, client);
276
+ }
277
+
254
278
  return {
255
279
  data,
256
280
  };
@@ -263,6 +287,7 @@ export class CollectionStore {
263
287
  collectionName,
264
288
  id,
265
289
  data,
290
+ children,
266
291
  triggeredBy = "INTERNAL",
267
292
  context,
268
293
  client,
@@ -270,6 +295,7 @@ export class CollectionStore {
270
295
  collectionName: string;
271
296
  id: string;
272
297
  data: Record<string, any> | null;
298
+ children?: UpdateChildren;
273
299
  triggeredBy?: TriggeredBy;
274
300
  context?: HonoContext;
275
301
  client?: PoolClient;
@@ -305,6 +331,10 @@ export class CollectionStore {
305
331
  },
306
332
  )).data;
307
333
 
334
+ if (children) {
335
+ await this.processChildrenOps(collectionName, data.id, children, triggeredBy, context, client);
336
+ }
337
+
308
338
  return {
309
339
  data,
310
340
  };
@@ -636,6 +666,556 @@ export class CollectionStore {
636
666
  return filteredPayload;
637
667
  }
638
668
 
669
+ private async resolveFields(
670
+ collectionName: string,
671
+ records: Record<string, any>[],
672
+ fields: string | string[] | undefined,
673
+ client: PoolClient,
674
+ ): Promise<Record<string, any>[]> {
675
+ const { relations } = parseFieldsToTree(normalizeFields(fields));
676
+ if (Object.keys(relations).length === 0) return records;
677
+ return this.resolveRelations(collectionName, records, relations, client);
678
+ }
679
+
680
+ private async resolveRelations(
681
+ collectionName: string,
682
+ records: Record<string, any>[],
683
+ relations: Record<string, FieldsTree>,
684
+ client: PoolClient,
685
+ ): Promise<Record<string, any>[]> {
686
+ const results = records.map((r) => ({ ...r }));
687
+
688
+ for (const [fieldName, subTree] of Object.entries(relations)) {
689
+ const relation = Lobb.instance.configManager.getParentRelation(collectionName, fieldName);
690
+
691
+ if (!relation) {
692
+ // Check for polymorphic virtual field
693
+ const poly = Lobb.instance.configManager.getPolymorphicRelationByVirtualField(collectionName, fieldName);
694
+ if (!poly) {
695
+ throw new LobbError({
696
+ code: "BAD_REQUEST",
697
+ message: `Cannot resolve field (${fieldName}) on collection (${collectionName}): no relation is defined for this field`,
698
+ });
699
+ }
700
+
701
+ // Group records by target collection (each record can point to a different one)
702
+ const groups: Record<string, any[]> = {};
703
+ for (const result of results) {
704
+ const targetCollection = result[poly.collectionField];
705
+ const targetId = result[poly.idField];
706
+ if (targetCollection && targetId != null) {
707
+ if (!groups[targetCollection]) groups[targetCollection] = [];
708
+ groups[targetCollection].push(result);
709
+ }
710
+ }
711
+
712
+ const subFields = subTree.rootFields.includes("*")
713
+ ? "*"
714
+ : [...new Set(["id", ...Object.keys(subTree.relations), ...subTree.rootFields])].join(",") || "id";
715
+
716
+ // Batch-fetch per target collection
717
+ const refMap: Record<string, any> = {};
718
+ for (const [targetCollection, groupRecords] of Object.entries(groups)) {
719
+ const ids = [...new Set(groupRecords.map((r) => String(r[poly.idField])))];
720
+ const fetched = await this.findAll({
721
+ collectionName: targetCollection,
722
+ params: { fields: subFields, filter: { id: { $in: ids } } },
723
+ triggeredBy: "INTERNAL",
724
+ client,
725
+ });
726
+ let fetchedRecords = fetched.data;
727
+ if (Object.keys(subTree.relations).length > 0 && fetchedRecords.length > 0) {
728
+ fetchedRecords = await this.resolveRelations(targetCollection, fetchedRecords, subTree.relations, client);
729
+ }
730
+ for (const refRecord of fetchedRecords) {
731
+ refMap[`${targetCollection}:${refRecord.id}`] = refRecord;
732
+ }
733
+ }
734
+
735
+ for (const result of results) {
736
+ const key = `${result[poly.collectionField]}:${result[poly.idField]}`;
737
+ result[fieldName] = refMap[key] ?? null;
738
+ }
739
+
740
+ continue;
741
+ }
742
+
743
+ const uniqueIds = [
744
+ ...new Set(
745
+ results
746
+ .map((r) => r[fieldName])
747
+ .filter((id) => id != null)
748
+ .map(String),
749
+ ),
750
+ ];
751
+
752
+ const refMap: Record<string, any> = {};
753
+
754
+ if (uniqueIds.length) {
755
+ const subFields = subTree.rootFields.includes("*")
756
+ ? "*"
757
+ : [...new Set(["id", ...Object.keys(subTree.relations), ...subTree.rootFields])].join(",") || "id";
758
+
759
+ const refRecords = await this.findAll({
760
+ collectionName: relation.to.collection,
761
+ params: {
762
+ fields: subFields,
763
+ filter: { [relation.to.field]: { $in: uniqueIds } },
764
+ },
765
+ triggeredBy: "INTERNAL",
766
+ client,
767
+ });
768
+
769
+ let fetchedRecords = refRecords.data;
770
+
771
+ if (Object.keys(subTree.relations).length > 0 && fetchedRecords.length > 0) {
772
+ fetchedRecords = await this.resolveRelations(
773
+ relation.to.collection,
774
+ fetchedRecords,
775
+ subTree.relations,
776
+ client,
777
+ );
778
+ }
779
+
780
+ for (const refRecord of fetchedRecords) {
781
+ refMap[String(refRecord[relation.to.field])] = refRecord;
782
+ }
783
+ }
784
+
785
+ for (const result of results) {
786
+ const refId = result[fieldName];
787
+ result[fieldName] = refId != null ? (refMap[String(refId)] ?? null) : null;
788
+ }
789
+ }
790
+
791
+ return results;
792
+ }
793
+
794
+ private async resolveChildren(
795
+ collectionName: string,
796
+ records: Record<string, any>[],
797
+ children: Record<string, ChildParams>,
798
+ client: PoolClient,
799
+ ): Promise<Record<string, any>[]> {
800
+ const results = records.map((r) => ({ ...r }));
801
+
802
+ for (const [childCollectionName, childParams] of Object.entries(children)) {
803
+ const resolved = this.resolveChildRelationType(collectionName, childCollectionName);
804
+
805
+ if (!resolved) {
806
+ throw new LobbError({
807
+ code: "BAD_REQUEST",
808
+ message: `Cannot fetch children (${childCollectionName}) for collection (${collectionName}): no relation is defined`,
809
+ });
810
+ }
811
+
812
+ const userChildFields = childParams.fields;
813
+ const hasWildcard = !userChildFields ||
814
+ (Array.isArray(userChildFields) ? userChildFields.includes("*") : userChildFields.includes("*"));
815
+
816
+ if (resolved.type === "fk") {
817
+ const fkField = resolved.relation.from.field;
818
+ const parentIds = [...new Set(results.map((r) => r[resolved.relation.to.field]).filter((id) => id != null).map(String))];
819
+
820
+ if (parentIds.length === 0) {
821
+ for (const result of results) result[childCollectionName] = [];
822
+ continue;
823
+ }
824
+
825
+ const childFieldsWithFK = hasWildcard
826
+ ? userChildFields
827
+ : Array.isArray(userChildFields)
828
+ ? [...new Set([...userChildFields, "id", fkField])]
829
+ : `${userChildFields},id,${fkField}`;
830
+
831
+ const fkFilter = { [fkField]: { $in: parentIds } };
832
+ const filter = childParams.filter ? { $and: [fkFilter, childParams.filter] } : fkFilter;
833
+
834
+ const childRecords = await this.findAll({
835
+ collectionName: childCollectionName,
836
+ params: { ...childParams, fields: childFieldsWithFK, filter },
837
+ triggeredBy: "INTERNAL",
838
+ client,
839
+ });
840
+
841
+ const childMap: Record<string, any[]> = {};
842
+ for (const parentId of parentIds) childMap[parentId] = [];
843
+ for (const childRecord of childRecords.data) {
844
+ const parentId = String(childRecord[fkField]);
845
+ if (childMap[parentId]) childMap[parentId].push(childRecord);
846
+ }
847
+
848
+ if (!hasWildcard) {
849
+ const requestedFields = Array.isArray(userChildFields)
850
+ ? userChildFields
851
+ : userChildFields?.split(",").map((f) => f.trim()) ?? [];
852
+ if (!requestedFields.includes(fkField)) {
853
+ for (const group of Object.values(childMap)) {
854
+ for (const record of group) delete record[fkField];
855
+ }
856
+ }
857
+ }
858
+
859
+ for (const result of results) {
860
+ result[childCollectionName] = childMap[String(result[resolved.relation.to.field])] ?? [];
861
+ }
862
+ } else if (resolved.type === "m2m") {
863
+ const { junctionCollection, parentFKField, targetFKField } = resolved.junction;
864
+ const parentIds = [...new Set(results.map((r) => r["id"]).filter((id) => id != null).map(String))];
865
+
866
+ if (parentIds.length === 0) {
867
+ for (const result of results) result[childCollectionName] = [];
868
+ continue;
869
+ }
870
+
871
+ // Fetch junction rows to get target IDs per parent
872
+ const junctionRecords = await this.findAll({
873
+ collectionName: junctionCollection,
874
+ params: { fields: `${parentFKField},${targetFKField}`, filter: { [parentFKField]: { $in: parentIds } } },
875
+ triggeredBy: "INTERNAL",
876
+ client,
877
+ });
878
+
879
+ const targetIdsByParent: Record<string, string[]> = {};
880
+ for (const parentId of parentIds) targetIdsByParent[parentId] = [];
881
+ for (const jr of junctionRecords.data) {
882
+ const parentId = String(jr[parentFKField]);
883
+ const targetId = String(jr[targetFKField]);
884
+ if (targetIdsByParent[parentId]) targetIdsByParent[parentId].push(targetId);
885
+ }
886
+
887
+ const allTargetIds = [...new Set(Object.values(targetIdsByParent).flat())];
888
+ const targetMap: Record<string, any> = {};
889
+
890
+ if (allTargetIds.length > 0) {
891
+ const childFieldsWithId = hasWildcard ? userChildFields : Array.isArray(userChildFields)
892
+ ? [...new Set([...userChildFields, "id"])]
893
+ : `${userChildFields},id`;
894
+
895
+ const targetRecords = await this.findAll({
896
+ collectionName: childCollectionName,
897
+ params: { ...childParams, fields: childFieldsWithId, filter: { id: { $in: allTargetIds } } },
898
+ triggeredBy: "INTERNAL",
899
+ client,
900
+ });
901
+
902
+ for (const tr of targetRecords.data) targetMap[String(tr.id)] = tr;
903
+ }
904
+
905
+ for (const result of results) {
906
+ const parentId = String(result["id"]);
907
+ result[childCollectionName] = (targetIdsByParent[parentId] ?? []).map((id) => targetMap[id]).filter(Boolean);
908
+ }
909
+ } else {
910
+ // Polymorphic — child records have polymorphic FK pointing to parent
911
+ const { collectionField, idField } = resolved.poly;
912
+ const parentIds = [...new Set(results.map((r) => r["id"]).filter((id) => id != null).map(String))];
913
+
914
+ if (parentIds.length === 0) {
915
+ for (const result of results) result[childCollectionName] = [];
916
+ continue;
917
+ }
918
+
919
+ const childFieldsWithFK = hasWildcard
920
+ ? userChildFields
921
+ : Array.isArray(userChildFields)
922
+ ? [...new Set([...userChildFields, "id", idField])]
923
+ : `${userChildFields},id,${idField}`;
924
+
925
+ const polyFilter = { [collectionField]: collectionName, [idField]: { $in: parentIds } };
926
+ const filter = childParams.filter ? { $and: [polyFilter, childParams.filter] } : polyFilter;
927
+
928
+ const childRecords = await this.findAll({
929
+ collectionName: childCollectionName,
930
+ params: { ...childParams, fields: childFieldsWithFK, filter },
931
+ triggeredBy: "INTERNAL",
932
+ client,
933
+ });
934
+
935
+ const childMap: Record<string, any[]> = {};
936
+ for (const parentId of parentIds) childMap[parentId] = [];
937
+ for (const childRecord of childRecords.data) {
938
+ const parentId = String(childRecord[idField]);
939
+ if (childMap[parentId]) childMap[parentId].push(childRecord);
940
+ }
941
+
942
+ if (!hasWildcard) {
943
+ const requestedFields = Array.isArray(userChildFields)
944
+ ? userChildFields
945
+ : userChildFields?.split(",").map((f) => f.trim()) ?? [];
946
+ if (!requestedFields.includes(idField)) {
947
+ for (const group of Object.values(childMap)) {
948
+ for (const record of group) delete record[idField];
949
+ }
950
+ }
951
+ }
952
+
953
+ for (const result of results) {
954
+ result[childCollectionName] = childMap[String(result["id"])] ?? [];
955
+ }
956
+ }
957
+ }
958
+
959
+ return results;
960
+ }
961
+
962
+ private async processInlineFKCreates(
963
+ collectionName: string,
964
+ data: Record<string, any>,
965
+ triggeredBy: TriggeredBy,
966
+ context: HonoContext | undefined,
967
+ client: PoolClient,
968
+ ): Promise<Record<string, any>> {
969
+ const result = { ...data };
970
+
971
+ for (const [fieldName, value] of Object.entries(result)) {
972
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
973
+
974
+ // Direct FK inline create: { data: {...} }
975
+ const fkRelation = Lobb.instance.configManager.getParentRelation(collectionName, fieldName);
976
+ if (fkRelation && value.data && typeof value.data === "object") {
977
+ const created = await this.createOne({
978
+ collectionName: fkRelation.to.collection,
979
+ data: value.data,
980
+ triggeredBy,
981
+ context,
982
+ client,
983
+ });
984
+ result[fieldName] = created.data.id;
985
+ continue;
986
+ }
987
+
988
+ // Polymorphic inline create: { collection: "...", data: {...} }
989
+ const polyRelation = Lobb.instance.configManager.getPolymorphicRelationByVirtualField(collectionName, fieldName);
990
+ if (polyRelation && value.collection && value.data && typeof value.data === "object") {
991
+ if (!polyRelation.allowedCollections.includes(value.collection)) {
992
+ throw new LobbError({
993
+ code: "BAD_REQUEST",
994
+ message: `Collection (${value.collection}) is not allowed for polymorphic field (${fieldName})`,
995
+ });
996
+ }
997
+ const created = await this.createOne({
998
+ collectionName: value.collection,
999
+ data: value.data,
1000
+ triggeredBy,
1001
+ context,
1002
+ client,
1003
+ });
1004
+ result[polyRelation.collectionField] = value.collection;
1005
+ result[polyRelation.idField] = created.data.id;
1006
+ delete result[fieldName];
1007
+ }
1008
+ }
1009
+
1010
+ return result;
1011
+ }
1012
+
1013
+ private resolveChildRelationType(
1014
+ collectionName: string,
1015
+ childCollectionName: string,
1016
+ ): { type: "fk"; relation: any } | { type: "m2m"; junction: any } | { type: "polymorphic"; poly: any } | null {
1017
+ const directRelation = Lobb.instance.configManager.getChildRelations(collectionName)
1018
+ .find((r) => r.from.collection === childCollectionName);
1019
+ if (directRelation) return { type: "fk", relation: directRelation };
1020
+
1021
+ const junction = Lobb.instance.configManager.getM2MJunction(collectionName, childCollectionName);
1022
+ if (junction) return { type: "m2m", junction };
1023
+
1024
+ const poly = Lobb.instance.configManager.getPolymorphicChildRelation(collectionName, childCollectionName);
1025
+ if (poly) return { type: "polymorphic", poly };
1026
+
1027
+ return null;
1028
+ }
1029
+
1030
+ private async processChildrenOps(
1031
+ collectionName: string,
1032
+ parentId: string | number,
1033
+ children: Record<string, { create?: Array<Record<string, any> & { children?: any }>; link?: (string | number)[]; unlink?: (string | number)[]; delete?: (string | number)[]; set?: (string | number)[]; update?: Array<{ id: string | number } & Record<string, any>> }>,
1034
+ triggeredBy: TriggeredBy,
1035
+ context: HonoContext | undefined,
1036
+ client: PoolClient,
1037
+ ): Promise<void> {
1038
+ for (const [childCollectionName, ops] of Object.entries(children)) {
1039
+ if (!ops.create?.length && !ops.link?.length && !ops.unlink?.length && !ops.delete?.length && !ops.set && !ops.update?.length) continue;
1040
+
1041
+ const resolved = this.resolveChildRelationType(collectionName, childCollectionName);
1042
+ if (!resolved) {
1043
+ throw new LobbError({
1044
+ code: "BAD_REQUEST",
1045
+ message: `Cannot process children: no relation found from (${childCollectionName}) to (${collectionName})`,
1046
+ });
1047
+ }
1048
+
1049
+ for (const childData of ops.create ?? []) {
1050
+ const { children: nestedChildren, ...data } = childData;
1051
+
1052
+ if (resolved.type === "fk") {
1053
+ await this.createOne({
1054
+ collectionName: childCollectionName,
1055
+ data: { ...data, [resolved.relation.from.field]: parentId },
1056
+ children: nestedChildren, triggeredBy, context, client,
1057
+ });
1058
+ } else if (resolved.type === "m2m") {
1059
+ const created = await this.createOne({
1060
+ collectionName: childCollectionName,
1061
+ data, children: nestedChildren, triggeredBy, context, client,
1062
+ });
1063
+ await this.createOne({
1064
+ collectionName: resolved.junction.junctionCollection,
1065
+ data: { [resolved.junction.parentFKField]: parentId, [resolved.junction.targetFKField]: created.data.id },
1066
+ triggeredBy, context, client,
1067
+ });
1068
+ } else {
1069
+ await this.createOne({
1070
+ collectionName: childCollectionName,
1071
+ data: { ...data, [resolved.poly.collectionField]: collectionName, [resolved.poly.idField]: parentId },
1072
+ children: nestedChildren, triggeredBy, context, client,
1073
+ });
1074
+ }
1075
+ }
1076
+
1077
+ for (const id of ops.link ?? []) {
1078
+ if (resolved.type === "fk") {
1079
+ await this.updateOne({
1080
+ collectionName: childCollectionName,
1081
+ id: String(id),
1082
+ data: { [resolved.relation.from.field]: parentId },
1083
+ triggeredBy, context, client,
1084
+ });
1085
+ } else if (resolved.type === "m2m") {
1086
+ await this.createOne({
1087
+ collectionName: resolved.junction.junctionCollection,
1088
+ data: { [resolved.junction.parentFKField]: parentId, [resolved.junction.targetFKField]: id },
1089
+ triggeredBy, context, client,
1090
+ });
1091
+ } else {
1092
+ await this.updateOne({
1093
+ collectionName: childCollectionName,
1094
+ id: String(id),
1095
+ data: { [resolved.poly.collectionField]: collectionName, [resolved.poly.idField]: parentId },
1096
+ triggeredBy, context, client,
1097
+ });
1098
+ }
1099
+ }
1100
+
1101
+ for (const id of ops.unlink ?? []) {
1102
+ if (resolved.type === "fk") {
1103
+ await this.updateOne({
1104
+ collectionName: childCollectionName,
1105
+ id: String(id),
1106
+ data: { [resolved.relation.from.field]: null },
1107
+ triggeredBy, context, client,
1108
+ });
1109
+ } else if (resolved.type === "m2m") {
1110
+ await this.deleteMany({
1111
+ collectionName: resolved.junction.junctionCollection,
1112
+ filter: { [resolved.junction.parentFKField]: parentId, [resolved.junction.targetFKField]: id },
1113
+ triggeredBy, context, client,
1114
+ });
1115
+ } else {
1116
+ await this.updateOne({
1117
+ collectionName: childCollectionName,
1118
+ id: String(id),
1119
+ data: { [resolved.poly.collectionField]: null, [resolved.poly.idField]: null },
1120
+ triggeredBy, context, client,
1121
+ });
1122
+ }
1123
+ }
1124
+
1125
+ for (const id of ops.delete ?? []) {
1126
+ if (resolved.type === "m2m") {
1127
+ await this.deleteMany({
1128
+ collectionName: resolved.junction.junctionCollection,
1129
+ filter: { [resolved.junction.parentFKField]: parentId, [resolved.junction.targetFKField]: id },
1130
+ triggeredBy, context, client,
1131
+ });
1132
+ }
1133
+ await this.deleteOne({
1134
+ collectionName: childCollectionName,
1135
+ id: String(id),
1136
+ triggeredBy, context, client,
1137
+ });
1138
+ }
1139
+
1140
+ if (ops.set) {
1141
+ const newIds = ops.set.map(String);
1142
+
1143
+ if (resolved.type === "fk") {
1144
+ const currentIds = await this.dbDriver.getIdsByFilter(
1145
+ childCollectionName,
1146
+ { [resolved.relation.from.field]: parentId },
1147
+ client,
1148
+ );
1149
+ for (const currentId of currentIds) {
1150
+ if (!newIds.includes(String(currentId))) {
1151
+ await this.updateOne({
1152
+ collectionName: childCollectionName,
1153
+ id: String(currentId),
1154
+ data: { [resolved.relation.from.field]: null },
1155
+ triggeredBy, context, client,
1156
+ });
1157
+ }
1158
+ }
1159
+ for (const id of newIds) {
1160
+ await this.updateOne({
1161
+ collectionName: childCollectionName,
1162
+ id,
1163
+ data: { [resolved.relation.from.field]: parentId },
1164
+ triggeredBy, context, client,
1165
+ });
1166
+ }
1167
+ } else if (resolved.type === "m2m") {
1168
+ await this.deleteMany({
1169
+ collectionName: resolved.junction.junctionCollection,
1170
+ filter: { [resolved.junction.parentFKField]: parentId },
1171
+ triggeredBy, context, client,
1172
+ });
1173
+ for (const id of newIds) {
1174
+ await this.createOne({
1175
+ collectionName: resolved.junction.junctionCollection,
1176
+ data: { [resolved.junction.parentFKField]: parentId, [resolved.junction.targetFKField]: id },
1177
+ triggeredBy, context, client,
1178
+ });
1179
+ }
1180
+ } else {
1181
+ const currentIds = await this.dbDriver.getIdsByFilter(
1182
+ childCollectionName,
1183
+ { [resolved.poly.collectionField]: collectionName, [resolved.poly.idField]: parentId },
1184
+ client,
1185
+ );
1186
+ for (const currentId of currentIds) {
1187
+ if (!newIds.includes(String(currentId))) {
1188
+ await this.updateOne({
1189
+ collectionName: childCollectionName,
1190
+ id: String(currentId),
1191
+ data: { [resolved.poly.collectionField]: null, [resolved.poly.idField]: null },
1192
+ triggeredBy, context, client,
1193
+ });
1194
+ }
1195
+ }
1196
+ for (const id of newIds) {
1197
+ await this.updateOne({
1198
+ collectionName: childCollectionName,
1199
+ id,
1200
+ data: { [resolved.poly.collectionField]: collectionName, [resolved.poly.idField]: parentId },
1201
+ triggeredBy, context, client,
1202
+ });
1203
+ }
1204
+ }
1205
+ }
1206
+
1207
+ for (const item of ops.update ?? []) {
1208
+ const { id, ...data } = item;
1209
+ await this.updateOne({
1210
+ collectionName: childCollectionName,
1211
+ id: String(id),
1212
+ data,
1213
+ triggeredBy, context, client,
1214
+ });
1215
+ }
1216
+ }
1217
+ }
1218
+
639
1219
  private async handleSingletonCollection(
640
1220
  collectionName: string,
641
1221
  context?: HonoContext,
@@ -654,3 +1234,81 @@ export class CollectionStore {
654
1234
  }
655
1235
  }
656
1236
  }
1237
+
1238
+ interface FieldsTree {
1239
+ rootFields: string[];
1240
+ relations: Record<string, FieldsTree>;
1241
+ }
1242
+
1243
+ function normalizeFields(fields: string | string[] | undefined): string[] {
1244
+ if (!fields) return ["*"];
1245
+ if (Array.isArray(fields)) return fields.map((f) => f.trim()).filter(Boolean);
1246
+ return fields.split(",").map((f) => f.trim()).filter(Boolean);
1247
+ }
1248
+
1249
+ function parseFieldsToTree(fields: string[]): FieldsTree {
1250
+ const tree: FieldsTree = { rootFields: [], relations: {} };
1251
+ for (const field of fields) {
1252
+ addToTree(tree, field);
1253
+ }
1254
+ return tree;
1255
+ }
1256
+
1257
+ function addToTree(tree: FieldsTree, path: string): void {
1258
+ const dot = path.indexOf(".");
1259
+ if (dot === -1) {
1260
+ tree.rootFields.push(path);
1261
+ } else {
1262
+ const relationName = path.slice(0, dot);
1263
+ const rest = path.slice(dot + 1);
1264
+ if (!tree.relations[relationName]) {
1265
+ tree.relations[relationName] = { rootFields: [], relations: {} };
1266
+ }
1267
+ addToTree(tree.relations[relationName], rest);
1268
+ }
1269
+ }
1270
+
1271
+ function buildDriverFields(
1272
+ collectionName: string,
1273
+ fields: string | string[] | undefined,
1274
+ ): string {
1275
+ const normalized = normalizeFields(fields).filter((f) => !f.includes("."));
1276
+ const allCollectionFields = Object.entries(
1277
+ Lobb.instance.configManager.getCollection(collectionName).fields,
1278
+ )
1279
+ .filter(([, cfg]) => !cfg.virtual)
1280
+ .map(([name]) => name);
1281
+
1282
+ const requestedFields = normalized.includes("*") ? allCollectionFields : normalized;
1283
+
1284
+ return [
1285
+ ...new Set([
1286
+ "id",
1287
+ ...Lobb.instance.configManager.getCollectionFKFields(collectionName),
1288
+ ...requestedFields,
1289
+ ]),
1290
+ ].join(",");
1291
+ }
1292
+
1293
+ function stripInternalFields(
1294
+ collectionName: string,
1295
+ fields: string | string[] | undefined,
1296
+ records: Record<string, any>[],
1297
+ ): Record<string, any>[] {
1298
+ const normalized = normalizeFields(fields);
1299
+ if (normalized.includes("*")) return records;
1300
+
1301
+ const internalFields = ["id", ...Lobb.instance.configManager.getCollectionFKFields(collectionName)];
1302
+ const tree = parseFieldsToTree(normalized);
1303
+ const userRequested = new Set([...tree.rootFields, ...Object.keys(tree.relations)]);
1304
+
1305
+ return records.map((record) => {
1306
+ const stripped: Record<string, any> = {};
1307
+ for (const key of Object.keys(record)) {
1308
+ if (!internalFields.includes(key) || userRequested.has(key)) {
1309
+ stripped[key] = record[key];
1310
+ }
1311
+ }
1312
+ return stripped;
1313
+ });
1314
+ }