@delma/fylo 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/.env.example +16 -0
- package/.github/copilot-instructions.md +113 -0
- package/.github/prompts/issue.prompt.md +19 -0
- package/.github/prompts/pr.prompt.md +18 -0
- package/.github/prompts/release.prompt.md +49 -0
- package/.github/prompts/review-pr.prompt.md +19 -0
- package/.github/prompts/sync-main.prompt.md +14 -0
- package/.github/workflows/ci.yml +37 -0
- package/.github/workflows/publish.yml +101 -0
- package/.prettierrc +7 -0
- package/LICENSE +21 -0
- package/README.md +230 -0
- package/eslint.config.js +28 -0
- package/package.json +51 -0
- package/src/CLI +37 -0
- package/src/adapters/cipher.ts +174 -0
- package/src/adapters/redis.ts +71 -0
- package/src/adapters/s3.ts +67 -0
- package/src/core/directory.ts +418 -0
- package/src/core/extensions.ts +19 -0
- package/src/core/format.ts +486 -0
- package/src/core/parser.ts +876 -0
- package/src/core/query.ts +48 -0
- package/src/core/walker.ts +167 -0
- package/src/index.ts +1088 -0
- package/src/types/fylo.d.ts +139 -0
- package/src/types/index.d.ts +3 -0
- package/src/types/query.d.ts +73 -0
- package/tests/collection/truncate.test.ts +56 -0
- package/tests/data.ts +110 -0
- package/tests/index.ts +19 -0
- package/tests/integration/create.test.ts +57 -0
- package/tests/integration/delete.test.ts +147 -0
- package/tests/integration/edge-cases.test.ts +232 -0
- package/tests/integration/encryption.test.ts +176 -0
- package/tests/integration/export.test.ts +61 -0
- package/tests/integration/join-modes.test.ts +221 -0
- package/tests/integration/nested.test.ts +212 -0
- package/tests/integration/operators.test.ts +167 -0
- package/tests/integration/read.test.ts +203 -0
- package/tests/integration/rollback.test.ts +105 -0
- package/tests/integration/update.test.ts +130 -0
- package/tests/mocks/cipher.ts +55 -0
- package/tests/mocks/redis.ts +13 -0
- package/tests/mocks/s3.ts +114 -0
- package/tests/schemas/album.d.ts +5 -0
- package/tests/schemas/album.json +5 -0
- package/tests/schemas/comment.d.ts +7 -0
- package/tests/schemas/comment.json +7 -0
- package/tests/schemas/photo.d.ts +7 -0
- package/tests/schemas/photo.json +7 -0
- package/tests/schemas/post.d.ts +6 -0
- package/tests/schemas/post.json +6 -0
- package/tests/schemas/tip.d.ts +7 -0
- package/tests/schemas/tip.json +7 -0
- package/tests/schemas/todo.d.ts +6 -0
- package/tests/schemas/todo.json +6 -0
- package/tests/schemas/user.d.ts +23 -0
- package/tests/schemas/user.json +23 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { Walker } from "./walker"
|
|
2
|
+
import TTID from "@vyckr/ttid"
|
|
3
|
+
import { S3 } from "../adapters/s3"
|
|
4
|
+
import { Redis } from "../adapters/redis"
|
|
5
|
+
import { Cipher } from "../adapters/cipher"
|
|
6
|
+
|
|
7
|
+
export class Dir {
|
|
8
|
+
|
|
9
|
+
private static readonly KEY_LIMIT = 1024
|
|
10
|
+
|
|
11
|
+
private static readonly SLASH_ASCII = "%2F"
|
|
12
|
+
|
|
13
|
+
private readonly transactions: Array<{ action: (...args: string[]) => Promise<void>, args: string[] }>;
|
|
14
|
+
|
|
15
|
+
private static _redis: Redis | null = null
|
|
16
|
+
|
|
17
|
+
private static get redis(): Redis {
|
|
18
|
+
if (!Dir._redis) Dir._redis = new Redis()
|
|
19
|
+
return Dir._redis
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
constructor() {
|
|
23
|
+
this.transactions = []
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static async claimTTID(_id: _ttid, ttlSeconds: number = 10): Promise<boolean> {
|
|
27
|
+
return await Dir.redis.claimTTID(_id, ttlSeconds)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static async reconstructData(collection: string, items: string[]) {
|
|
31
|
+
|
|
32
|
+
items = await this.readValues(collection, items)
|
|
33
|
+
|
|
34
|
+
let fieldVal: Record<string, string> = {}
|
|
35
|
+
|
|
36
|
+
for (const data of items) {
|
|
37
|
+
const segs = data.split('/')
|
|
38
|
+
const val = segs.pop()!
|
|
39
|
+
const fieldPath = segs.join('/')
|
|
40
|
+
|
|
41
|
+
// Decrypt value if field is encrypted — fieldPath starts with TTID segment
|
|
42
|
+
// so strip it to get the actual field name for the check
|
|
43
|
+
const fieldOnly = segs.slice(1).join('/')
|
|
44
|
+
if(Cipher.isConfigured() && Cipher.isEncryptedField(collection, fieldOnly)) {
|
|
45
|
+
fieldVal[fieldPath] = await Cipher.decrypt(val)
|
|
46
|
+
} else {
|
|
47
|
+
fieldVal[fieldPath] = val
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return this.constructData(fieldVal)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private static async readValues(collection: string, items: string[]) {
|
|
55
|
+
|
|
56
|
+
for(let i = 0; i < items.length; i++) {
|
|
57
|
+
|
|
58
|
+
const segments = items[i].split('/')
|
|
59
|
+
|
|
60
|
+
const filename = segments.pop()!
|
|
61
|
+
|
|
62
|
+
if(TTID.isUUID(filename)) {
|
|
63
|
+
|
|
64
|
+
const file = S3.file(collection, items[i])
|
|
65
|
+
const val = await file.text()
|
|
66
|
+
|
|
67
|
+
items[i] = `${segments.join('/')}/${val}`
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return items
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private static async filterByTimestamp(_id: _ttid, indexes: string[], { updated, created }: { updated?: _timestamp, created?: _timestamp }) {
|
|
75
|
+
|
|
76
|
+
const { createdAt, updatedAt } = TTID.decodeTime(_id)
|
|
77
|
+
|
|
78
|
+
if(updated && updatedAt) {
|
|
79
|
+
|
|
80
|
+
if((updated.$gt || updated.$gte) && (updated.$lt || updated.$lte)) {
|
|
81
|
+
|
|
82
|
+
if(updated.$gt && updated.$lt) {
|
|
83
|
+
|
|
84
|
+
if(updated.$gt! > updated.$lt!) throw new Error("Invalid updated query")
|
|
85
|
+
|
|
86
|
+
indexes = updatedAt > updated.$gt! && updatedAt < updated.$lt! ? indexes : []
|
|
87
|
+
|
|
88
|
+
} else if(updated.$gt && updated.$lte) {
|
|
89
|
+
|
|
90
|
+
if(updated.$gt! > updated.$lte!) throw new Error("Invalid updated query")
|
|
91
|
+
|
|
92
|
+
indexes = updatedAt > updated.$gt! && updatedAt <= updated.$lte! ? indexes : []
|
|
93
|
+
|
|
94
|
+
} else if(updated.$gte && updated.$lt) {
|
|
95
|
+
|
|
96
|
+
if(updated.$gte! > updated.$lt!) throw new Error("Invalid updated query")
|
|
97
|
+
|
|
98
|
+
indexes = updatedAt >= updated.$gte! && updatedAt < updated.$lt! ? indexes : []
|
|
99
|
+
|
|
100
|
+
} else if(updated.$gte && updated.$lte) {
|
|
101
|
+
|
|
102
|
+
if(updated.$gte! > updated.$lte!) throw new Error("Invalid updated query")
|
|
103
|
+
|
|
104
|
+
indexes = updatedAt >= updated.$gte! && updatedAt <= updated.$lte! ? indexes : []
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
} else if((updated.$gt || updated.$gte) && !updated.$lt && !updated.$lte) {
|
|
108
|
+
|
|
109
|
+
indexes = updated.$gt ? updatedAt > updated.$gt! ? indexes : [] : updatedAt >= updated.$gte! ? indexes : []
|
|
110
|
+
|
|
111
|
+
} else if(!updated.$gt && !updated.$gte && (updated.$lt || updated.$lte)) {
|
|
112
|
+
|
|
113
|
+
indexes = updated.$lt ? updatedAt < updated.$lt! ? indexes : [] : updatedAt <= updated.$lte! ? indexes : []
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if(created) {
|
|
118
|
+
|
|
119
|
+
if((created.$gt || created.$gte) && (created.$lt || created.$lte)) {
|
|
120
|
+
|
|
121
|
+
if(created.$gt && created.$lt) {
|
|
122
|
+
|
|
123
|
+
if(created.$gt! > created.$lt!) throw new Error("Invalid created query")
|
|
124
|
+
|
|
125
|
+
indexes = createdAt > created.$gt! && createdAt < created.$lt! ? indexes : []
|
|
126
|
+
|
|
127
|
+
} else if(created.$gt && created.$lte) {
|
|
128
|
+
|
|
129
|
+
if(created.$gt! > created.$lte!) throw new Error("Invalid updated query")
|
|
130
|
+
|
|
131
|
+
indexes = createdAt > created.$gt! && createdAt <= created.$lte! ? indexes : []
|
|
132
|
+
|
|
133
|
+
} else if(created.$gte && created.$lt) {
|
|
134
|
+
|
|
135
|
+
if(created.$gte! > created.$lt!) throw new Error("Invalid updated query")
|
|
136
|
+
|
|
137
|
+
indexes = createdAt >= created.$gte! && createdAt < created.$lt! ? indexes : []
|
|
138
|
+
|
|
139
|
+
} else if(created.$gte && created.$lte) {
|
|
140
|
+
|
|
141
|
+
if(created.$gte! > created.$lte!) throw new Error("Invalid updated query")
|
|
142
|
+
|
|
143
|
+
indexes = createdAt >= created.$gte! && createdAt <= created.$lte! ? indexes : []
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
} else if((created.$gt || created.$gte) && !created.$lt && !created.$lte) {
|
|
147
|
+
|
|
148
|
+
if(created.$gt) indexes = createdAt > created.$gt! ? indexes : []
|
|
149
|
+
else if(created.$gte) indexes = createdAt >= created.$gte! ? indexes : []
|
|
150
|
+
|
|
151
|
+
} else if(!created.$gt && !created.$gte && (created.$lt || created.$lte)) {
|
|
152
|
+
|
|
153
|
+
if(created.$lt) indexes = createdAt < created.$lt! ? indexes : []
|
|
154
|
+
else if(created.$lte) indexes = createdAt <= created.$lte! ? indexes : []
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return indexes.length > 0
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
static async *searchDocs<T extends Record<string, any>>(collection: string, pattern: string | string[], { updated, created }: { updated?: _timestamp, created?: _timestamp }, { listen = false, skip = false }: { listen: boolean, skip: boolean }, deleted: boolean = false): AsyncGenerator<Record<_ttid, T> | _ttid | void, void, { count: number, limit?: number }> {
|
|
162
|
+
|
|
163
|
+
const data = yield
|
|
164
|
+
let count = data.count
|
|
165
|
+
let limit = data.limit
|
|
166
|
+
|
|
167
|
+
const constructData = async (collection: string, _id: _ttid, items: string[]) => {
|
|
168
|
+
|
|
169
|
+
if(created || updated) {
|
|
170
|
+
|
|
171
|
+
if(await this.filterByTimestamp(_id, items, { created, updated })) {
|
|
172
|
+
|
|
173
|
+
const data = await this.reconstructData(collection, items)
|
|
174
|
+
|
|
175
|
+
return { [_id]: data } as Record<_ttid, T>
|
|
176
|
+
|
|
177
|
+
} else return {}
|
|
178
|
+
|
|
179
|
+
} else {
|
|
180
|
+
|
|
181
|
+
const data = await this.reconstructData(collection, items)
|
|
182
|
+
|
|
183
|
+
return { [_id]: data } as Record<_ttid, T>
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const processQuery = async function*(p: string): AsyncGenerator<Record<_ttid, T> | _ttid | void, void, { count: number, limit?: number }> {
|
|
188
|
+
|
|
189
|
+
let finished = false
|
|
190
|
+
|
|
191
|
+
if(listen && !deleted) {
|
|
192
|
+
|
|
193
|
+
const iter = Walker.search(collection, p, { listen, skip })
|
|
194
|
+
|
|
195
|
+
do {
|
|
196
|
+
|
|
197
|
+
const { value, done } = await iter.next({ count, limit })
|
|
198
|
+
|
|
199
|
+
if(done) finished = true
|
|
200
|
+
|
|
201
|
+
if(value) {
|
|
202
|
+
const data = yield await constructData(collection, value._id, value.data)
|
|
203
|
+
count = data.count
|
|
204
|
+
limit = data.limit
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
} while(!finished)
|
|
208
|
+
|
|
209
|
+
} else if(listen && deleted) {
|
|
210
|
+
|
|
211
|
+
const iter = Walker.search(collection, p, { listen, skip }, "delete")
|
|
212
|
+
|
|
213
|
+
do {
|
|
214
|
+
|
|
215
|
+
const { value, done } = await iter.next({ count, limit })
|
|
216
|
+
|
|
217
|
+
if(done) finished = true
|
|
218
|
+
|
|
219
|
+
if(value) {
|
|
220
|
+
const data = yield value._id
|
|
221
|
+
count = data.count
|
|
222
|
+
limit = data.limit
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
} while(!finished)
|
|
226
|
+
|
|
227
|
+
} else {
|
|
228
|
+
|
|
229
|
+
const iter = Walker.search(collection, p, { listen, skip })
|
|
230
|
+
|
|
231
|
+
do {
|
|
232
|
+
|
|
233
|
+
const { value, done } = await iter.next({ count, limit })
|
|
234
|
+
|
|
235
|
+
if(done) finished = true
|
|
236
|
+
|
|
237
|
+
if(value) {
|
|
238
|
+
const data = yield await constructData(collection, value._id, value.data)
|
|
239
|
+
count = data.count
|
|
240
|
+
limit = data.limit
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
} while(!finished)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if(Array.isArray(pattern)) {
|
|
248
|
+
|
|
249
|
+
for(const p of pattern) yield* processQuery(p)
|
|
250
|
+
|
|
251
|
+
} else yield* processQuery(pattern)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async putKeys(collection: string, { dataKey, indexKey }: { dataKey: string, indexKey: string }) {
|
|
255
|
+
|
|
256
|
+
let dataBody: string | undefined
|
|
257
|
+
let indexBody: string | undefined
|
|
258
|
+
|
|
259
|
+
if(dataKey.length > Dir.KEY_LIMIT) {
|
|
260
|
+
|
|
261
|
+
const dataSegs = dataKey.split('/')
|
|
262
|
+
|
|
263
|
+
dataBody = dataSegs.pop()!
|
|
264
|
+
|
|
265
|
+
indexKey = `${dataSegs.join('/')}/${Bun.randomUUIDv7()}`
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if(indexKey.length > Dir.KEY_LIMIT) {
|
|
269
|
+
|
|
270
|
+
const indexSegs = indexKey.split('/')
|
|
271
|
+
|
|
272
|
+
const _id = indexSegs.pop()! as _ttid
|
|
273
|
+
|
|
274
|
+
indexBody = indexSegs.pop()!
|
|
275
|
+
|
|
276
|
+
dataKey = `${indexSegs.join('/')}/${_id}`
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
await Promise.all([
|
|
280
|
+
S3.put(collection, dataKey, dataBody ?? ''),
|
|
281
|
+
S3.put(collection, indexKey, indexBody ?? '')
|
|
282
|
+
])
|
|
283
|
+
|
|
284
|
+
this.transactions.push({
|
|
285
|
+
action: S3.delete,
|
|
286
|
+
args: [collection, dataKey]
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
this.transactions.push({
|
|
290
|
+
action: S3.delete,
|
|
291
|
+
args: [collection, indexKey]
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
await Dir.redis.publish(collection, 'insert', indexKey)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async executeRollback() {
|
|
298
|
+
|
|
299
|
+
do {
|
|
300
|
+
|
|
301
|
+
const transaction = this.transactions.pop()
|
|
302
|
+
|
|
303
|
+
if(transaction) {
|
|
304
|
+
|
|
305
|
+
const { action, args } = transaction
|
|
306
|
+
|
|
307
|
+
await action(...args)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
} while(this.transactions.length > 0)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async deleteKeys(collection: string, dataKey: string) {
|
|
314
|
+
|
|
315
|
+
const segments = dataKey.split('/')
|
|
316
|
+
|
|
317
|
+
const _id = segments.shift()!
|
|
318
|
+
|
|
319
|
+
const indexKey = `${segments.join('/')}/${_id}`
|
|
320
|
+
|
|
321
|
+
const dataFile = S3.file(collection, dataKey)
|
|
322
|
+
const indexFile = S3.file(collection, indexKey)
|
|
323
|
+
|
|
324
|
+
let dataBody: string | undefined
|
|
325
|
+
let indexBody: string | undefined
|
|
326
|
+
|
|
327
|
+
if(dataFile.size > 0) dataBody = await dataFile.text()
|
|
328
|
+
if(indexFile.size > 0) indexBody = await indexFile.text()
|
|
329
|
+
|
|
330
|
+
await Promise.all([
|
|
331
|
+
S3.delete(collection, indexKey),
|
|
332
|
+
S3.delete(collection, dataKey)
|
|
333
|
+
])
|
|
334
|
+
|
|
335
|
+
this.transactions.push({
|
|
336
|
+
action: S3.put,
|
|
337
|
+
args: [collection, dataKey, dataBody ?? '']
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
this.transactions.push({
|
|
341
|
+
action: S3.put,
|
|
342
|
+
args: [collection, indexKey, indexBody ?? '']
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
await Dir.redis.publish(collection, 'delete', _id)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
static async extractKeys<T>(collection: string, _id: _ttid, data: T, parentField?: string) {
|
|
349
|
+
|
|
350
|
+
const keys: { data: string[], indexes: string[] } = { data: [], indexes: [] }
|
|
351
|
+
|
|
352
|
+
const obj = {...data}
|
|
353
|
+
|
|
354
|
+
for(const field in obj) {
|
|
355
|
+
|
|
356
|
+
const newField = parentField ? `${parentField}/${field}` : field
|
|
357
|
+
|
|
358
|
+
if(typeof obj[field] === 'object' && !Array.isArray(obj[field])) {
|
|
359
|
+
const items = await this.extractKeys(collection, _id, obj[field], newField)
|
|
360
|
+
keys.data.push(...items.data)
|
|
361
|
+
keys.indexes.push(...items.indexes)
|
|
362
|
+
} else if(typeof obj[field] === 'object' && Array.isArray(obj[field])) {
|
|
363
|
+
const items: (string | number | boolean)[] = obj[field]
|
|
364
|
+
if(items.some((item) => typeof item === 'object')) throw new Error(`Cannot have an array of objects`)
|
|
365
|
+
for(let i = 0; i < items.length; i++) {
|
|
366
|
+
let val = String(items[i]).split('/').join(this.SLASH_ASCII)
|
|
367
|
+
if(Cipher.isConfigured() && Cipher.isEncryptedField(collection, newField)) val = await Cipher.encrypt(val, true)
|
|
368
|
+
keys.data.push(`${_id}/${newField}/${i}/${val}`)
|
|
369
|
+
keys.indexes.push(`${newField}/${i}/${val}/${_id}`)
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
let val = String(obj[field]).replaceAll('/', this.SLASH_ASCII)
|
|
373
|
+
if(Cipher.isConfigured() && Cipher.isEncryptedField(collection, newField)) val = await Cipher.encrypt(val, true)
|
|
374
|
+
keys.data.push(`${_id}/${newField}/${val}`)
|
|
375
|
+
keys.indexes.push(`${newField}/${val}/${_id}`)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return keys
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
static constructData(fieldVal: Record<string, string>) {
|
|
383
|
+
|
|
384
|
+
const data: Record<string, any> = {}
|
|
385
|
+
|
|
386
|
+
for(let fullField in fieldVal) {
|
|
387
|
+
|
|
388
|
+
const fields = fullField.split('/').slice(1)
|
|
389
|
+
|
|
390
|
+
let curr = data
|
|
391
|
+
|
|
392
|
+
while(fields.length > 1) {
|
|
393
|
+
|
|
394
|
+
const field = fields.shift()!
|
|
395
|
+
|
|
396
|
+
if(typeof curr[field] !== 'object' || curr[field] === null)
|
|
397
|
+
curr[field] = isNaN(Number(fields[0])) ? {} : []
|
|
398
|
+
|
|
399
|
+
curr = curr[field]
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const lastKey = fields.shift()!
|
|
403
|
+
|
|
404
|
+
curr[lastKey] = this.parseValue(fieldVal[fullField].replaceAll(this.SLASH_ASCII, '/'))
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return data
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
static parseValue(value: string) {
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
return JSON.parse(value)
|
|
414
|
+
} catch {
|
|
415
|
+
return value
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
|
|
3
|
+
Object.appendGroup = function (target: Record<string, any>, source: Record<string, any>): Record<string, any> {
|
|
4
|
+
const result = { ...target }
|
|
5
|
+
|
|
6
|
+
for (const [sourceId, sourceGroup] of Object.entries(source)) {
|
|
7
|
+
|
|
8
|
+
if (!result[sourceId]) {
|
|
9
|
+
result[sourceId] = sourceGroup
|
|
10
|
+
continue
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
for (const [groupId, groupDoc] of Object.entries(sourceGroup)) {
|
|
14
|
+
result[sourceId][groupId] = groupDoc
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return result
|
|
19
|
+
}
|