@cocreate/firestore 1.0.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/src/index.js ADDED
@@ -0,0 +1,847 @@
1
+ const { Firestore } = require("@google-cloud/firestore");
2
+ const {
3
+ dotNotationToObject,
4
+ queryData,
5
+ searchData,
6
+ sortData,
7
+ isValidDate
8
+ } = require("@cocreate/utils");
9
+
10
+ const organizations = {};
11
+
12
+ /**
13
+ * Dynamically resolves or caches a long-lived Firestore database client connection.
14
+ * Caches the connection promise to prevent duplicate authentication handshakes.
15
+ * Normalizes custom credential protocols directly out of standard CoCreate storageUrls.
16
+ */
17
+ async function dbClient(data) {
18
+ if (data.storageUrl) {
19
+ if (!organizations[data.organization_id]) {
20
+ organizations[data.organization_id] = {};
21
+ }
22
+ try {
23
+ if (!organizations[data.organization_id][data.storageUrl]) {
24
+ const connectionPromise = (async () => {
25
+ let config = {};
26
+
27
+ // Option A: The Credentials Path URL (e.g. firestore:///path/to/credentials.json)
28
+ if (data.storageUrl.startsWith("firestore:///")) {
29
+ const filePath = data.storageUrl.replace("firestore://", "");
30
+ config = { keyFilename: filePath };
31
+ }
32
+ // Option B: Query Params Encoded URI (e.g. firestore://project-id?client_email=...&private_key=...)
33
+ else if (data.storageUrl.startsWith("firestore://")) {
34
+ const urlParsed = new URL(data.storageUrl);
35
+ const projectId = urlParsed.host || urlParsed.hostname;
36
+ const clientEmail = urlParsed.searchParams.get("client_email");
37
+ const privateKey = urlParsed.searchParams.get("private_key");
38
+
39
+ config = {
40
+ projectId,
41
+ credentials: {
42
+ client_email: clientEmail,
43
+ private_key: privateKey ? privateKey.replace(/\\n/g, "\n") : undefined
44
+ }
45
+ };
46
+ }
47
+
48
+ // Safely map optional custom Firestore Database ID configurations
49
+ if (data.database && data.database !== "(default)") {
50
+ config.databaseId = data.database;
51
+ }
52
+
53
+ const client = new Firestore(config);
54
+
55
+ // Run a dummy check to verify connection viability early
56
+ await client.listCollections();
57
+ return client;
58
+ })();
59
+
60
+ organizations[data.organization_id][data.storageUrl] = connectionPromise;
61
+ }
62
+
63
+ // Await connection resolution safely
64
+ return await organizations[data.organization_id][data.storageUrl];
65
+ } catch (error) {
66
+ console.error(
67
+ `${data.organization_id}: storageUrl ${data.storageUrl} failed to connect to Firestore`
68
+ );
69
+ errorHandler(data, error);
70
+ return { status: false };
71
+ }
72
+ }
73
+
74
+ errorHandler(data, "missing StorageUrl");
75
+ return;
76
+ }
77
+
78
+ /**
79
+ * Global Event Listener: orgDeleted
80
+ * Gracefully terminates active Firestore client instances and frees up system memory.
81
+ */
82
+ process.on("orgDeleted", (organization_id) => {
83
+ const orgPools = organizations[organization_id];
84
+ if (orgPools) {
85
+ console.log(`[Firestore Utilities] Cleanup Scheduled: Initiating 5-second grace period for Org: ${organization_id}`);
86
+ setTimeout(async () => {
87
+ const currentPools = organizations[organization_id];
88
+ if (!currentPools) return;
89
+
90
+ console.log(`[Firestore Utilities] Cleanup Executing: Closing connection pools for Org: ${organization_id}`);
91
+ const clientPromises = Object.values(currentPools);
92
+
93
+ for (const clientPromise of clientPromises) {
94
+ try {
95
+ const client = await clientPromise;
96
+ if (client && typeof client.terminate === "function") {
97
+ await client.terminate();
98
+ }
99
+ } catch (err) {
100
+ console.error(`[Firestore Utilities] Error closing Firestore client for Org ${organization_id}:`, err);
101
+ }
102
+ }
103
+
104
+ delete organizations[organization_id];
105
+ console.log(`[Firestore Utilities] Memory Cleared: Registry freed for Org: ${organization_id}`);
106
+ }, 5000);
107
+ }
108
+ });
109
+
110
+ /**
111
+ * Universal router matching the standard CoCreate pipeline signature.
112
+ */
113
+ function send(data) {
114
+ let [type, method] = data.method.split(".");
115
+ if (type === "database") return database(method, data);
116
+ if (type === "array") return array(method, data);
117
+ if (type === "object") return object(method, data);
118
+ }
119
+
120
+ /**
121
+ * "database" operations mapped to Firestore Databases.
122
+ */
123
+ function database(method, data) {
124
+ return new Promise(
125
+ async (resolve, reject) => {
126
+ let type = "database";
127
+ let databaseArray = [];
128
+
129
+ try {
130
+ const db = await dbClient(data);
131
+ if (!db || db.status === false) return data;
132
+
133
+ if (method === "read") {
134
+ // Firestore is serverless and generally operates on a single default schema database per context.
135
+ // We construct catalog names dynamically matching the target structure.
136
+ const dbName = db.databaseId || data.database || "(default)";
137
+ const dbObj = { name: dbName };
138
+
139
+ process.emit("usage", {
140
+ type: "egress",
141
+ data: { api: "listDatabases", context: dbName },
142
+ organization_id: data.organization_id
143
+ });
144
+
145
+ if (data.$filter && data.$filter.query) {
146
+ let isFilter = queryData(dbObj, data.$filter.query);
147
+ if (isFilter) {
148
+ databaseArray.push({
149
+ database: dbObj,
150
+ storage: data.storageName
151
+ });
152
+ }
153
+ } else {
154
+ databaseArray.push({
155
+ database: dbObj,
156
+ storage: data.storageName
157
+ });
158
+ }
159
+ resolve(createData(data, databaseArray, type));
160
+ }
161
+ if (method === "delete") {
162
+ // Wiping an entire Firestore instance is emulated cleanly by deleting all collections recursively.
163
+ const collections = await db.listCollections();
164
+ for (let col of collections) {
165
+ process.emit("usage", {
166
+ type: "egress",
167
+ data: { api: "recursiveDelete", collection: col.id },
168
+ organization_id: data.organization_id
169
+ });
170
+ await db.recursiveDelete(col);
171
+ }
172
+ resolve({ status: true });
173
+ }
174
+ } catch (error) {
175
+ errorHandler(data, error);
176
+ console.log(method, "error", error);
177
+ resolve(data);
178
+ }
179
+ },
180
+ (error) => {
181
+ errorHandler(data, error);
182
+ }
183
+ );
184
+ }
185
+
186
+ /**
187
+ * "array" operations mapped to Firestore Collections.
188
+ */
189
+ function array(method, data) {
190
+ return new Promise(
191
+ async (resolve, reject) => {
192
+ let type = "array";
193
+ let arrayArray = [];
194
+
195
+ try {
196
+ const db = await dbClient(data);
197
+ if (!db || db.status === false) return data;
198
+
199
+ if (data.request) data.array = data.request;
200
+
201
+ let databases = data.database;
202
+ if (!Array.isArray(databases)) databases = [databases];
203
+
204
+ let databasesLength = databases.length;
205
+ for (let database of databases) {
206
+ if (method === "read") {
207
+ process.emit("usage", {
208
+ type: "egress",
209
+ data: { api: "listCollections" },
210
+ organization_id: data.organization_id
211
+ });
212
+
213
+ const collections = await db.listCollections();
214
+ for (let col of collections) {
215
+ const tableObj = { name: col.id };
216
+
217
+ if (data.$filter && data.$filter.query) {
218
+ let isFilter = queryData(tableObj, data.$filter.query);
219
+ if (isFilter) {
220
+ arrayArray.push({
221
+ name: col.id,
222
+ database,
223
+ storage: data.storageName
224
+ });
225
+ }
226
+ } else {
227
+ arrayArray.push({
228
+ name: col.id,
229
+ database,
230
+ storage: data.storageName
231
+ });
232
+ }
233
+ }
234
+
235
+ databasesLength -= 1;
236
+ if (!databasesLength) {
237
+ data = createData(data, arrayArray, type);
238
+ resolve(data);
239
+ }
240
+ } else {
241
+ let arrays = data.array;
242
+ if (method === "update") arrays = Object.entries(data.array);
243
+ if (!Array.isArray(arrays)) arrays = [arrays];
244
+
245
+ let arraysLength = arrays.length;
246
+ for (let array of arrays) {
247
+ if (method === "create") {
248
+ // Firestore creates collections implicitly. To guarantee visibility in list collections,
249
+ // we write a placeholder metadata document that is safely overwritten by standard CRUD later.
250
+ const placeholderDocRef = db.collection(array).doc("_placeholder_");
251
+
252
+ process.emit("usage", {
253
+ type: "egress",
254
+ data: { api: "createCollection", collection: array },
255
+ organization_id: data.organization_id
256
+ });
257
+
258
+ await placeholderDocRef.set({ isPlaceholder: true, organization_id: data.organization_id });
259
+
260
+ arrayArray.push({
261
+ name: array,
262
+ database,
263
+ storage: data.storageName
264
+ });
265
+
266
+ arraysLength -= 1;
267
+ if (!arraysLength) databasesLength -= 1;
268
+ if (!databasesLength && !arraysLength) {
269
+ data = createData(data, arrayArray, type);
270
+ resolve(data);
271
+ }
272
+ } else {
273
+ if (method === "update") {
274
+ let [oldName, newName] = array;
275
+ process.emit("usage", {
276
+ type: "egress",
277
+ data: { api: "renameCollection", from: oldName, to: newName },
278
+ organization_id: data.organization_id
279
+ });
280
+
281
+ // Firestore doesn't support atomic collection renames natively.
282
+ // We migrate documents recursively in batches to fulfill expectations seamlessly.
283
+ const oldCollectionRef = db.collection(oldName);
284
+ const newCollectionRef = db.collection(newName);
285
+ const snapshot = await oldCollectionRef.get();
286
+
287
+ const batch = db.batch();
288
+ snapshot.forEach((doc) => {
289
+ batch.set(newCollectionRef.doc(doc.id), doc.data());
290
+ batch.delete(doc.ref);
291
+ });
292
+ await batch.commit();
293
+
294
+ arrayArray.push({
295
+ name: newName,
296
+ oldName: oldName,
297
+ database,
298
+ storage: data.storageName
299
+ });
300
+
301
+ arraysLength -= 1;
302
+ if (!arraysLength) databasesLength -= 1;
303
+ if (!databasesLength && !arraysLength) {
304
+ data = createData(data, arrayArray, type);
305
+ resolve(data);
306
+ }
307
+ }
308
+
309
+ if (method === "delete") {
310
+ process.emit("usage", {
311
+ type: "egress",
312
+ data: { api: "deleteCollection", collection: array },
313
+ organization_id: data.organization_id
314
+ });
315
+
316
+ const collectionRef = db.collection(array);
317
+ await db.recursiveDelete(collectionRef);
318
+
319
+ arrayArray.push({
320
+ name: array,
321
+ database,
322
+ storage: data.storageName
323
+ });
324
+
325
+ arraysLength -= 1;
326
+ if (!arraysLength) databasesLength -= 1;
327
+ if (!databasesLength && !arraysLength) {
328
+ data = createData(data, arrayArray, type);
329
+ resolve(data);
330
+ }
331
+ }
332
+ }
333
+ }
334
+ }
335
+ }
336
+ } catch (error) {
337
+ errorHandler(data, error);
338
+ console.log(method, "error", error);
339
+ resolve(data);
340
+ }
341
+ },
342
+ (error) => {
343
+ errorHandler(data, error);
344
+ }
345
+ );
346
+ }
347
+
348
+ /**
349
+ * "object" operations mapped to Firestore Documents.
350
+ */
351
+ function object(method, data) {
352
+ return new Promise(
353
+ async (resolve, reject) => {
354
+ try {
355
+ const db = await dbClient(data);
356
+ if (!db || db.status === false) return data;
357
+
358
+ let type = "object";
359
+ let documents = [];
360
+
361
+ if (!data["timeStamp"]) {
362
+ data["timeStamp"] = new Date().toISOString();
363
+ }
364
+
365
+ let databases = data.database;
366
+ if (!Array.isArray(databases)) databases = [databases];
367
+
368
+ for (let database of databases) {
369
+ let arrays = data.array;
370
+ if (!Array.isArray(arrays)) arrays = [arrays];
371
+
372
+ for (let array of arrays) {
373
+ const reference = {
374
+ $storage: data.storageName,
375
+ $database: database,
376
+ $array: array
377
+ };
378
+
379
+ if (!data[type]) data[type] = [];
380
+ else if (typeof data[type] === "string")
381
+ data[type] = [{ _id: data[type] }];
382
+ else if (!Array.isArray(data[type]))
383
+ data[type] = [data[type]];
384
+
385
+ let isFilter = !!data.$filter;
386
+ if ((isFilter && !data[type].length) || data.isFilter) {
387
+ data[type].splice(0, 0, { isFilter: "isEmptyObjectFilter" });
388
+ }
389
+
390
+ for (let i = 0; i < data[type].length; i++) {
391
+ let $storage = data[type][i].$storage || [];
392
+ let $database = data[type][i].$database || [];
393
+ let $array = data[type][i].$array || [];
394
+
395
+ if (!Array.isArray($storage)) $storage = [data[type][i].$storage];
396
+ if (!Array.isArray($database)) $database = [data[type][i].$database];
397
+ if (!Array.isArray($array)) $array = [data[type][i].$array];
398
+
399
+ if (!$storage.includes(data.storageName)) $storage.push(data.storageName);
400
+ if (!$database.includes(database)) $database.push(database);
401
+ if (!$array.includes(array)) $array.push(array);
402
+
403
+ delete data[type][i].$storage;
404
+ delete data[type][i].$database;
405
+ delete data[type][i].$array;
406
+
407
+ let queryFilter = isFilter ? data.$filter : (data[type][i].$filter || null);
408
+
409
+ if (method === "create") {
410
+ data[type][i] = replaceArray(data[type][i]);
411
+ data[type][i] = dotNotationToObject(data[type][i]);
412
+
413
+ const id = data[type][i]._id || `doc_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
414
+ const orgId = data.organization_id;
415
+ const created = {
416
+ on: new Date(data.timeStamp).toISOString(),
417
+ by: data.user_id || data.clientId
418
+ };
419
+
420
+ const recordPayload = { ...data[type][i] };
421
+ delete recordPayload._id;
422
+ delete recordPayload.organization_id;
423
+
424
+ const docRef = db.collection(array).doc(id);
425
+
426
+ process.emit("usage", {
427
+ type: "egress",
428
+ data: { api: "setDoc", path: `${array}/${id}`, payload: recordPayload },
429
+ organization_id: data.organization_id
430
+ });
431
+
432
+ await docRef.set({
433
+ _id: id,
434
+ organization_id: orgId,
435
+ created,
436
+ modified: null,
437
+ ...recordPayload
438
+ });
439
+
440
+ const savedDoc = await docRef.get();
441
+ if (savedDoc.exists) {
442
+ data[type][i] = { ...savedDoc.data(), $storage, $database, $array };
443
+ }
444
+ }
445
+ else if (method === "read") {
446
+ let queryRef = db.collection(array);
447
+ queryRef = compileFilterToFirestore(queryRef, queryFilter, data.organization_id);
448
+
449
+ if (data.$filter && data.$filter.sort) {
450
+ for (let s of data.$filter.sort) {
451
+ let dir = s.direction === -1 || s.direction === "desc" ? "desc" : "asc";
452
+ queryRef = queryRef.orderBy(s.key, dir);
453
+ }
454
+ }
455
+ if (data.$filter && typeof data.$filter.limit === "number") {
456
+ queryRef = queryRef.limit(data.$filter.limit);
457
+ }
458
+ if (data.$filter && typeof data.$filter.index === "number") {
459
+ // Standard offset queries are supported but startAt is normally preferred in production
460
+ queryRef = queryRef.offset(data.$filter.index);
461
+ }
462
+
463
+ if (data[type][i]._id && !isFilter) {
464
+ const docRef = db.collection(array).doc(data[type][i]._id);
465
+ process.emit("usage", {
466
+ type: "egress",
467
+ data: { api: "getDoc", path: `${array}/${data[type][i]._id}` },
468
+ organization_id: data.organization_id
469
+ });
470
+ const docSnap = await docRef.get();
471
+
472
+ if (docSnap.exists) {
473
+ let dbRecord = docSnap.data();
474
+
475
+ // Offline synchronization check
476
+ if ($storage.length && data[type][i].modified && data[type][i].modified.on) {
477
+ let clientTime = new Date(data[type][i].modified.on);
478
+ let dbTime = new Date(dbRecord.modified ? dbRecord.modified.on : 0);
479
+
480
+ if (clientTime > dbTime) {
481
+ const updatedRecord = { ...dbRecord, ...data[type][i] };
482
+ const savedRecord = await executeUpdateTransaction(db, array, data[type][i]._id, updatedRecord, data);
483
+ data[type][i] = { ...savedRecord, $storage, $database, $array };
484
+ } else {
485
+ data[type][i] = { ...data[type][i], ...dbRecord, $storage, $database, $array };
486
+ }
487
+ } else {
488
+ data[type][i] = { ...data[type][i], ...dbRecord, $storage, $database, $array };
489
+ }
490
+ } else {
491
+ data[type].splice(i, 1);
492
+ i--;
493
+ }
494
+ } else {
495
+ process.emit("usage", {
496
+ type: "egress",
497
+ data: { api: "queryCollection", collection: array },
498
+ organization_id: data.organization_id
499
+ });
500
+ const querySnapshot = await queryRef.get();
501
+
502
+ for (let doc of querySnapshot.docs) {
503
+ const parsedRow = doc.data();
504
+ if (data.$filter && data.$filter.search) {
505
+ let isMatch = searchData(parsedRow, data.$filter.search);
506
+ if (!isMatch) continue;
507
+ }
508
+ documents.push({
509
+ ...parsedRow,
510
+ ...reference
511
+ });
512
+ }
513
+ }
514
+ }
515
+ else if (method === "update") {
516
+ const updatePayload = { ...data[type][i] };
517
+ delete updatePayload._id;
518
+ delete updatePayload.$filter;
519
+
520
+ if (data[type][i]._id) {
521
+ const updatedDoc = await executeUpdateTransaction(db, array, data[type][i]._id, updatePayload, data);
522
+ if (updatedDoc) {
523
+ data[type][i] = { ...updatedDoc, $storage, $database, $array };
524
+ }
525
+ } else if (isFilter) {
526
+ let queryRef = db.collection(array);
527
+ queryRef = compileFilterToFirestore(queryRef, queryFilter, data.organization_id);
528
+
529
+ process.emit("usage", {
530
+ type: "egress",
531
+ data: { api: "queryForUpdate", collection: array },
532
+ organization_id: data.organization_id
533
+ });
534
+ const querySnapshot = await queryRef.get();
535
+
536
+ for (let doc of querySnapshot.docs) {
537
+ const updatedDoc = await executeUpdateTransaction(db, array, doc.id, updatePayload, data);
538
+ if (updatedDoc) {
539
+ documents.push({ ...updatedDoc, ...reference });
540
+ }
541
+ }
542
+ }
543
+ }
544
+ else if (method === "delete") {
545
+ if (data[type][i]._id) {
546
+ const docRef = db.collection(array).doc(data[type][i]._id);
547
+ process.emit("usage", {
548
+ type: "egress",
549
+ data: { api: "deleteDoc", path: `${array}/${data[type][i]._id}` },
550
+ organization_id: data.organization_id
551
+ });
552
+ await docRef.delete();
553
+ data[type][i] = { _id: data[type][i]._id, $storage, $database, $array };
554
+ } else if (isFilter) {
555
+ let queryRef = db.collection(array);
556
+ queryRef = compileFilterToFirestore(queryRef, queryFilter, data.organization_id);
557
+
558
+ process.emit("usage", {
559
+ type: "egress",
560
+ data: { api: "queryForDelete", collection: array },
561
+ organization_id: data.organization_id
562
+ });
563
+ const querySnapshot = await queryRef.get();
564
+
565
+ const batch = db.batch();
566
+ for (let doc of querySnapshot.docs) {
567
+ batch.delete(doc.ref);
568
+ documents.push({ _id: doc.id, ...reference });
569
+ }
570
+ await batch.commit();
571
+ }
572
+ }
573
+ }
574
+ }
575
+ }
576
+
577
+ data = createData(data, documents, type);
578
+ resolve(data);
579
+ } catch (error) {
580
+ errorHandler(data, error);
581
+ console.log(method, "error", error);
582
+ resolve(data);
583
+ }
584
+ },
585
+ (error) => {
586
+ errorHandler(data, error);
587
+ }
588
+ );
589
+ }
590
+
591
+ /**
592
+ * Executes a state-safe transactional update pipeline matching MongoDB syntax operations inside Cloud Firestore.
593
+ * Natively scales over concurrent race conditions utilizing Google Cloud's transactional isolation mechanics.
594
+ */
595
+ async function executeUpdateTransaction(db, arrayName, docId, rawUpdateInput, requestData) {
596
+ const docRef = db.collection(arrayName).doc(docId);
597
+ try {
598
+ return await db.runTransaction(async (transaction) => {
599
+ const sfDoc = await transaction.get(docRef);
600
+ let currentRecord = sfDoc.exists ? sfDoc.data() : null;
601
+
602
+ if (!currentRecord) {
603
+ if (requestData.upsert || rawUpdateInput.upsert || rawUpdateInput.$upsert) {
604
+ currentRecord = { _id: docId, organization_id: requestData.organization_id };
605
+ } else {
606
+ return null;
607
+ }
608
+ }
609
+
610
+ const modified = {
611
+ on: new Date(requestData.timeStamp).toISOString(),
612
+ by: requestData.user_id || requestData.clientId
613
+ };
614
+
615
+ const updatedPayload = applyMongoUpdate(currentRecord, rawUpdateInput);
616
+
617
+ const savePayload = { ...updatedPayload };
618
+ delete savePayload._id;
619
+ delete savePayload.organization_id;
620
+ delete savePayload.created;
621
+ delete savePayload.modified;
622
+
623
+ const recordToWrite = {
624
+ _id: docId,
625
+ organization_id: requestData.organization_id,
626
+ created: currentRecord.created || modified,
627
+ modified,
628
+ ...savePayload
629
+ };
630
+
631
+ process.emit("usage", {
632
+ type: "egress",
633
+ data: { api: "transactionalSetDoc", path: `${arrayName}/${docId}` },
634
+ organization_id: requestData.organization_id
635
+ });
636
+
637
+ transaction.set(docRef, recordToWrite);
638
+ return recordToWrite;
639
+ });
640
+ } catch (error) {
641
+ throw error;
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Applies deep MongoDB modification operators ($set, $unset, $push, $pull, $addToSet, $inc)
647
+ * recursively on a target record to maintain absolute system synchronization behaviors.
648
+ */
649
+ function applyMongoUpdate(record, updatePayload) {
650
+ let target = { ...record };
651
+
652
+ Object.keys(updatePayload).forEach((rawKey) => {
653
+ if (rawKey.startsWith("$")) return;
654
+ let cleanKey = rawKey.replace(/\[(\d+)\]/g, ".$1");
655
+ setNestedValue(target, cleanKey, updatePayload[rawKey]);
656
+ });
657
+
658
+ if (updatePayload.$set) {
659
+ Object.keys(updatePayload.$set).forEach((key) => {
660
+ let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
661
+ setNestedValue(target, cleanKey, updatePayload.$set[key]);
662
+ });
663
+ }
664
+
665
+ if (updatePayload.$unset) {
666
+ Object.keys(updatePayload.$unset).forEach((key) => {
667
+ let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
668
+ deleteNestedValue(target, cleanKey);
669
+ });
670
+ }
671
+
672
+ if (updatePayload.$inc) {
673
+ Object.keys(updatePayload.$inc).forEach((key) => {
674
+ let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
675
+ let currentVal = getNestedValue(target, cleanKey) || 0;
676
+ setNestedValue(target, cleanKey, Number(currentVal) + Number(updatePayload.$inc[key]));
677
+ });
678
+ }
679
+
680
+ if (updatePayload.$push) {
681
+ Object.keys(updatePayload.$push).forEach((key) => {
682
+ let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
683
+ let arr = getNestedValue(target, cleanKey);
684
+ if (!Array.isArray(arr)) arr = [];
685
+
686
+ const pushValue = updatePayload.$push[key];
687
+ if (pushValue && pushValue.$each) {
688
+ let valuesToPush = pushValue.$each;
689
+ let position = typeof pushValue.$position === "number" ? pushValue.$position : arr.length;
690
+ arr.splice(position, 0, ...valuesToPush);
691
+ } else {
692
+ arr.push(pushValue);
693
+ }
694
+ setNestedValue(target, cleanKey, arr);
695
+ });
696
+ }
697
+
698
+ if (updatePayload.$pull) {
699
+ Object.keys(updatePayload.$pull).forEach((key) => {
700
+ let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
701
+ let arr = getNestedValue(target, cleanKey);
702
+ if (Array.isArray(arr)) {
703
+ const filterVal = updatePayload.$pull[key];
704
+ arr = arr.filter(item => {
705
+ if (typeof filterVal === "object" && filterVal !== null) {
706
+ return !queryData(item, filterVal);
707
+ }
708
+ return item !== filterVal;
709
+ });
710
+ setNestedValue(target, cleanKey, arr);
711
+ }
712
+ });
713
+ }
714
+
715
+ if (updatePayload.$addToSet) {
716
+ Object.keys(updatePayload.$addToSet).forEach((key) => {
717
+ let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
718
+ let arr = getNestedValue(target, cleanKey);
719
+ if (!Array.isArray(arr)) arr = [];
720
+
721
+ const addValue = updatePayload.$addToSet[key];
722
+ if (addValue && addValue.$each) {
723
+ addValue.$each.forEach(item => {
724
+ if (!arr.includes(item)) arr.push(item);
725
+ });
726
+ } else {
727
+ if (!arr.includes(addValue)) arr.push(addValue);
728
+ }
729
+ setNestedValue(target, cleanKey, arr);
730
+ });
731
+ }
732
+
733
+ return target;
734
+ }
735
+
736
+ /**
737
+ * Normalizes and compiles complex MongoDB logic filters cleanly into a chained Firestore Query reference structure.
738
+ */
739
+ function compileFilterToFirestore(queryRef, filter, organizationId) {
740
+ // Restrict and enforce organization scope limits uniformly
741
+ let resolvedQuery = queryRef.where("organization_id", "==", organizationId);
742
+
743
+ if (filter && filter.query) {
744
+ for (let key in filter.query) {
745
+ if (["$or", "$and", "organization_id"].includes(key)) continue;
746
+
747
+ let value = filter.query[key];
748
+
749
+ if (value && typeof value === "object" && !Array.isArray(value)) {
750
+ for (let op in value) {
751
+ let operand = value[op];
752
+ if (op === "$gt") resolvedQuery = resolvedQuery.where(key, ">", operand);
753
+ else if (op === "$gte") resolvedQuery = resolvedQuery.where(key, ">=", operand);
754
+ else if (op === "$lt") resolvedQuery = resolvedQuery.where(key, "<", operand);
755
+ else if (op === "$lte") resolvedQuery = resolvedQuery.where(key, "<=", operand);
756
+ else if (op === "$ne") resolvedQuery = resolvedQuery.where(key, "!=", operand);
757
+ else if (op === "$in") resolvedQuery = resolvedQuery.where(key, "in", operand);
758
+ }
759
+ } else {
760
+ resolvedQuery = resolvedQuery.where(key, "==", value);
761
+ }
762
+ }
763
+ }
764
+
765
+ return resolvedQuery;
766
+ }
767
+
768
+ function replaceArray(data = {}) {
769
+ let object = {};
770
+ Object.keys(data).forEach((key) => {
771
+ object[key.replace(/\[(\d+)\]/g, ".$1")] = data[key];
772
+ });
773
+ return object;
774
+ }
775
+
776
+ function getNestedValue(obj, path) {
777
+ return path.split(".").reduce((acc, part) => acc && acc[part], obj);
778
+ }
779
+
780
+ function setNestedValue(obj, path, value) {
781
+ const parts = path.split(".");
782
+ const last = parts.pop();
783
+ const target = parts.reduce((acc, part) => acc[part] = acc[part] || {}, obj);
784
+ target[last] = value;
785
+ }
786
+
787
+ function deleteNestedValue(obj, path) {
788
+ const parts = path.split(".");
789
+ const last = parts.pop();
790
+ const target = parts.reduce((acc, part) => acc && acc[part], obj);
791
+ if (target) delete target[last];
792
+ }
793
+
794
+ /**
795
+ * Syncs active state blocks and updates values cleanly inside CoCreate payload objects.
796
+ */
797
+ function createData(data, array, type) {
798
+ if (data[type] && data[type][0] && data[type][0].isFilter === "isEmptyObjectFilter") {
799
+ data[type].shift();
800
+ data.isFilter = true;
801
+ }
802
+
803
+ let key = type !== "object" ? "name" : "_id";
804
+
805
+ if (!Array.isArray(data[type])) {
806
+ console.log("data[type] is not an array", type);
807
+ } else {
808
+ for (let i = 0; i < array.length; i++) {
809
+ const matchIndex = data[type].findIndex((item) => item[key] === array[i][key]);
810
+ if (matchIndex !== -1) {
811
+ for (let $type of ["$storage", "$database", "$array"]) {
812
+ if (!data[type][matchIndex][$type]) data[type][matchIndex][$type] = [];
813
+ if (!Array.isArray(data[type][matchIndex][$type])) {
814
+ data[type][matchIndex][$type] = [data[type][matchIndex][$type]];
815
+ }
816
+ if (!data[type][matchIndex][$type].includes(array[i][$type])) {
817
+ data[type][matchIndex][$type].push(array[i][$type]);
818
+ }
819
+ delete array[i][$type];
820
+ }
821
+ data[type][matchIndex] = { ...data[type][matchIndex], ...array[i] };
822
+ } else {
823
+ data[type].push(array[i]);
824
+ }
825
+ }
826
+ }
827
+ return data;
828
+ }
829
+
830
+ function errorHandler(data, error, database, array) {
831
+ let errorMessage = typeof error === "object" && error.message ? error.message : error;
832
+ let errorObject = {
833
+ message: errorMessage,
834
+ storage: "firestore"
835
+ };
836
+
837
+ if (database) errorObject.database = database;
838
+ if (array) errorObject.array = array;
839
+
840
+ if (Array.isArray(data.error)) {
841
+ data.error.push(errorObject);
842
+ } else {
843
+ data.error = [errorObject];
844
+ }
845
+ }
846
+
847
+ module.exports = { send };