@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
|
2
|
+
import { Query } from './core/query'
|
|
3
|
+
import { Parser } from './core/parser'
|
|
4
|
+
import { Dir } from "./core/directory";
|
|
5
|
+
import TTID from '@vyckr/ttid';
|
|
6
|
+
import Gen from "@vyckr/chex"
|
|
7
|
+
import { Walker } from './core/walker';
|
|
8
|
+
import { S3 } from "./adapters/s3"
|
|
9
|
+
import { Cipher } from "./adapters/cipher"
|
|
10
|
+
import './core/format'
|
|
11
|
+
import './core/extensions'
|
|
12
|
+
|
|
13
|
+
export default class Fylo {
|
|
14
|
+
|
|
15
|
+
private static LOGGING = process.env.LOGGING
|
|
16
|
+
|
|
17
|
+
private static MAX_CPUS = navigator.hardwareConcurrency
|
|
18
|
+
|
|
19
|
+
private static readonly STRICT = process.env.STRICT
|
|
20
|
+
|
|
21
|
+
private static ttidLock: Promise<void> = Promise.resolve()
|
|
22
|
+
|
|
23
|
+
private static readonly SCHEMA_DIR = process.env.SCHEMA_DIR
|
|
24
|
+
|
|
25
|
+
/** Collections whose schema `$encrypted` config has already been loaded. */
|
|
26
|
+
private static readonly loadedEncryption: Set<string> = new Set()
|
|
27
|
+
|
|
28
|
+
private dir: Dir;
|
|
29
|
+
|
|
30
|
+
constructor() {
|
|
31
|
+
this.dir = new Dir()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Executes a SQL query and returns the results.
|
|
36
|
+
* @param SQL The SQL query to execute.
|
|
37
|
+
* @returns The results of the query.
|
|
38
|
+
*/
|
|
39
|
+
async executeSQL<T extends Record<string, any>, U extends Record<string, any> = Record<string, unknown>>(SQL: string) {
|
|
40
|
+
|
|
41
|
+
const op = SQL.match(/^(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP)/i)
|
|
42
|
+
|
|
43
|
+
if(!op) throw new Error("Missing SQL Operation")
|
|
44
|
+
|
|
45
|
+
switch(op.shift()) {
|
|
46
|
+
case "CREATE":
|
|
47
|
+
return await Fylo.createCollection((Parser.parse(SQL) as _storeDelete<T>).$collection!)
|
|
48
|
+
case "DROP":
|
|
49
|
+
return await Fylo.dropCollection((Parser.parse(SQL) as _storeDelete<T>).$collection!)
|
|
50
|
+
case "SELECT":
|
|
51
|
+
const query = Parser.parse<T>(SQL) as _storeQuery<T>
|
|
52
|
+
if(SQL.includes('JOIN')) return await Fylo.joinDocs(query as _join<T, U>)
|
|
53
|
+
const selCol = (query as _storeQuery<T>).$collection
|
|
54
|
+
delete (query as _storeQuery<T>).$collection
|
|
55
|
+
let docs: Record<string, unknown> | Array<_ttid> = query.$onlyIds ? new Array<_ttid> : {}
|
|
56
|
+
for await (const data of Fylo.findDocs(selCol! as string, query as _storeQuery<T>).collect()) {
|
|
57
|
+
if(typeof data === 'object') {
|
|
58
|
+
docs = Object.appendGroup(docs, data)
|
|
59
|
+
} else (docs as Array<_ttid>).push(data as _ttid)
|
|
60
|
+
}
|
|
61
|
+
return docs
|
|
62
|
+
case "INSERT":
|
|
63
|
+
const insert = Parser.parse<T>(SQL) as _storeInsert<T>
|
|
64
|
+
const insCol = insert.$collection
|
|
65
|
+
delete insert.$collection
|
|
66
|
+
return await this.putData(insCol!, insert.$values)
|
|
67
|
+
case "UPDATE":
|
|
68
|
+
const update = Parser.parse<T>(SQL) as _storeUpdate<T>
|
|
69
|
+
const updateCol = update.$collection
|
|
70
|
+
delete update.$collection
|
|
71
|
+
return await this.patchDocs(updateCol!, update)
|
|
72
|
+
case "DELETE":
|
|
73
|
+
const del = Parser.parse<T>(SQL) as _storeDelete<T>
|
|
74
|
+
const delCol = del.$collection
|
|
75
|
+
delete del.$collection
|
|
76
|
+
return await this.delDocs(delCol!, del)
|
|
77
|
+
default:
|
|
78
|
+
throw new Error("Invalid Operation")
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Creates a new schema for a collection.
|
|
84
|
+
* @param collection The name of the collection.
|
|
85
|
+
*/
|
|
86
|
+
static async createCollection(collection: string) {
|
|
87
|
+
|
|
88
|
+
await S3.createBucket(collection)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Drops an existing schema for a collection.
|
|
93
|
+
* @param collection The name of the collection.
|
|
94
|
+
*/
|
|
95
|
+
static async dropCollection(collection: string) {
|
|
96
|
+
|
|
97
|
+
await S3.deleteBucket(collection)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Loads encrypted field config from a collection's JSON schema if not already loaded.
|
|
102
|
+
* Reads the `$encrypted` array from the schema and registers fields with Cipher.
|
|
103
|
+
* Auto-configures the Cipher key from `ENCRYPTION_KEY` env var on first use.
|
|
104
|
+
*/
|
|
105
|
+
private static async loadEncryption(collection: string): Promise<void> {
|
|
106
|
+
if (Fylo.loadedEncryption.has(collection)) return
|
|
107
|
+
Fylo.loadedEncryption.add(collection)
|
|
108
|
+
|
|
109
|
+
if (!Fylo.SCHEMA_DIR) return
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const res = await import(`${Fylo.SCHEMA_DIR}/${collection}.json`)
|
|
113
|
+
const schema = res.default as Record<string, unknown>
|
|
114
|
+
const encrypted = schema.$encrypted
|
|
115
|
+
|
|
116
|
+
if (Array.isArray(encrypted) && encrypted.length > 0) {
|
|
117
|
+
if (!Cipher.isConfigured()) {
|
|
118
|
+
const secret = process.env.ENCRYPTION_KEY
|
|
119
|
+
if (!secret) throw new Error('Schema declares $encrypted fields but ENCRYPTION_KEY env var is not set')
|
|
120
|
+
if (secret.length < 32) throw new Error('ENCRYPTION_KEY must be at least 32 characters long')
|
|
121
|
+
await Cipher.configure(secret)
|
|
122
|
+
}
|
|
123
|
+
Cipher.registerFields(collection, encrypted as string[])
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// No schema file found — no encryption for this collection
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Rolls back all transcations in current instance
|
|
132
|
+
*/
|
|
133
|
+
async rollback() {
|
|
134
|
+
await this.dir.executeRollback()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Imports data from a URL into a collection.
|
|
139
|
+
* @param collection The name of the collection.
|
|
140
|
+
* @param url The URL of the data to import.
|
|
141
|
+
* @param limit The maximum number of documents to import.
|
|
142
|
+
*/
|
|
143
|
+
async importBulkData<T extends Record<string, any>>(collection: string, url: URL, limit?: number) {
|
|
144
|
+
|
|
145
|
+
const res = await fetch(url)
|
|
146
|
+
|
|
147
|
+
if(!res.headers.get('content-type')?.includes('application/json')) throw new Error('Response is not JSON')
|
|
148
|
+
|
|
149
|
+
let count = 0
|
|
150
|
+
let batchNum = 0
|
|
151
|
+
|
|
152
|
+
const flush = async (batch: T[]) => {
|
|
153
|
+
|
|
154
|
+
if(!batch.length) return
|
|
155
|
+
|
|
156
|
+
const items = limit && count + batch.length > limit ? batch.slice(0, limit - count) : batch
|
|
157
|
+
|
|
158
|
+
batchNum++
|
|
159
|
+
|
|
160
|
+
const start = Date.now()
|
|
161
|
+
await this.batchPutData(collection, items)
|
|
162
|
+
count += items.length
|
|
163
|
+
|
|
164
|
+
if(count % 10000 === 0) console.log("Count:", count)
|
|
165
|
+
|
|
166
|
+
if(Fylo.LOGGING) {
|
|
167
|
+
const bytes = JSON.stringify(items).length
|
|
168
|
+
const elapsed = Date.now() - start
|
|
169
|
+
const bytesPerSec = (bytes / (elapsed / 1000)).toFixed(2)
|
|
170
|
+
console.log(`Batch ${batchNum} of ${bytes} bytes took ${elapsed === Infinity ? 'Infinity' : elapsed}ms (${bytesPerSec} bytes/sec)`)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Detect format from the first byte of the body:
|
|
175
|
+
// 0x5b ('[') → JSON array: buffer the full body, then parse and process in slices.
|
|
176
|
+
// Otherwise → NDJSON stream: parse incrementally with Bun.JSONL.parseChunk, which
|
|
177
|
+
// accepts Uint8Array directly (zero-copy for ASCII) and tracks the split-line
|
|
178
|
+
// remainder internally via the returned `read` offset — no manual incomplete-
|
|
179
|
+
// line state machine needed.
|
|
180
|
+
let isJsonArray: boolean | null = null
|
|
181
|
+
const jsonArrayChunks: Uint8Array[] = []
|
|
182
|
+
let jsonArrayLength = 0
|
|
183
|
+
|
|
184
|
+
let pending = new Uint8Array(0)
|
|
185
|
+
let batch: T[] = []
|
|
186
|
+
|
|
187
|
+
for await (const chunk of res.body as AsyncIterable<Uint8Array>) {
|
|
188
|
+
|
|
189
|
+
if(isJsonArray === null) isJsonArray = chunk[0] === 0x5b
|
|
190
|
+
|
|
191
|
+
if(isJsonArray) {
|
|
192
|
+
jsonArrayChunks.push(chunk)
|
|
193
|
+
jsonArrayLength += chunk.length
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Prepend any leftover bytes from the previous iteration (an unterminated line),
|
|
198
|
+
// then parse. `read` points past the last complete line; `pending` holds the rest.
|
|
199
|
+
const merged = new Uint8Array(pending.length + chunk.length)
|
|
200
|
+
merged.set(pending)
|
|
201
|
+
merged.set(chunk, pending.length)
|
|
202
|
+
|
|
203
|
+
const { values, read } = Bun.JSONL.parseChunk(merged)
|
|
204
|
+
pending = merged.subarray(read)
|
|
205
|
+
|
|
206
|
+
for(const item of values) {
|
|
207
|
+
batch.push(item as T)
|
|
208
|
+
if(batch.length === Fylo.MAX_CPUS) {
|
|
209
|
+
await flush(batch)
|
|
210
|
+
batch = []
|
|
211
|
+
if(limit && count >= limit) return count
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if(isJsonArray) {
|
|
217
|
+
|
|
218
|
+
// Reassemble buffered chunks into a single Uint8Array and parse as JSON.
|
|
219
|
+
const body = new Uint8Array(jsonArrayLength)
|
|
220
|
+
let offset = 0
|
|
221
|
+
for(const c of jsonArrayChunks) { body.set(c, offset); offset += c.length }
|
|
222
|
+
|
|
223
|
+
const data = JSON.parse(new TextDecoder().decode(body))
|
|
224
|
+
const items: T[] = Array.isArray(data) ? data : [data]
|
|
225
|
+
|
|
226
|
+
for(let i = 0; i < items.length; i += Fylo.MAX_CPUS) {
|
|
227
|
+
if(limit && count >= limit) break
|
|
228
|
+
await flush(items.slice(i, i + Fylo.MAX_CPUS))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
} else {
|
|
232
|
+
|
|
233
|
+
// Flush the in-progress batch and any final line that had no trailing newline.
|
|
234
|
+
if(pending.length > 0) {
|
|
235
|
+
const { values } = Bun.JSONL.parseChunk(pending)
|
|
236
|
+
for(const item of values) batch.push(item as T)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if(batch.length > 0) await flush(batch)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return count
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Exports data from a collection to a URL.
|
|
247
|
+
* @param collection The name of the collection.
|
|
248
|
+
* @returns The current data exported from the collection.
|
|
249
|
+
*/
|
|
250
|
+
static async *exportBulkData<T extends Record<string, any>>(collection: string) {
|
|
251
|
+
|
|
252
|
+
// Kick off the first S3 list immediately so there is no idle time at the start.
|
|
253
|
+
let listPromise: Promise<Bun.S3ListObjectsResponse> | null = S3.list(collection, { delimiter: '/' })
|
|
254
|
+
|
|
255
|
+
while(listPromise !== null) {
|
|
256
|
+
|
|
257
|
+
const data: Bun.S3ListObjectsResponse = await listPromise
|
|
258
|
+
|
|
259
|
+
if(!data.commonPrefixes?.length) break
|
|
260
|
+
|
|
261
|
+
const ids = data.commonPrefixes
|
|
262
|
+
.map(item => item.prefix!.split('/')[0]!)
|
|
263
|
+
.filter(key => TTID.isTTID(key)) as _ttid[]
|
|
264
|
+
|
|
265
|
+
// Start fetching the next page immediately — before awaiting doc reads —
|
|
266
|
+
// so the S3 list round-trip overlaps with document reconstruction.
|
|
267
|
+
listPromise = data.isTruncated && data.nextContinuationToken
|
|
268
|
+
? S3.list(collection, { delimiter: '/', continuationToken: data.nextContinuationToken })
|
|
269
|
+
: null
|
|
270
|
+
|
|
271
|
+
const results = await Promise.allSettled(ids.map(id => this.getDoc<T>(collection, id).once()))
|
|
272
|
+
|
|
273
|
+
for(const result of results) {
|
|
274
|
+
if(result.status === 'fulfilled') {
|
|
275
|
+
for(const id in result.value) yield result.value[id]
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Gets a document from a collection.
|
|
283
|
+
* @param collection The name of the collection.
|
|
284
|
+
* @param _id The ID of the document.
|
|
285
|
+
* @param onlyId Whether to only return the ID of the document.
|
|
286
|
+
* @returns The document or the ID of the document.
|
|
287
|
+
*/
|
|
288
|
+
static getDoc<T extends Record<string, any>>(collection: string, _id: _ttid, onlyId: boolean = false) {
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Async iterator (listener) for the document.
|
|
294
|
+
*/
|
|
295
|
+
async *[Symbol.asyncIterator]() {
|
|
296
|
+
|
|
297
|
+
const doc = await this.once()
|
|
298
|
+
|
|
299
|
+
if(Object.keys(doc).length > 0) yield doc
|
|
300
|
+
|
|
301
|
+
let finished = false
|
|
302
|
+
|
|
303
|
+
const iter = Dir.searchDocs<T>(collection, `**/${_id.split('-')[0]}*`, {}, { listen: true, skip: true })
|
|
304
|
+
|
|
305
|
+
do {
|
|
306
|
+
|
|
307
|
+
const { value, done } = await iter.next({ count: 0 })
|
|
308
|
+
|
|
309
|
+
if(value === undefined && !done) continue
|
|
310
|
+
|
|
311
|
+
if(done) {
|
|
312
|
+
finished = true
|
|
313
|
+
break
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const doc = value as Record<_ttid, T>
|
|
317
|
+
|
|
318
|
+
const keys = Object.keys(doc)
|
|
319
|
+
|
|
320
|
+
if(onlyId && keys.length > 0) {
|
|
321
|
+
yield keys.shift()!
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
else if(keys.length > 0) {
|
|
325
|
+
yield doc
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
} while(!finished)
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Gets the document once.
|
|
334
|
+
*/
|
|
335
|
+
async once() {
|
|
336
|
+
|
|
337
|
+
const items = await Walker.getDocData(collection, _id)
|
|
338
|
+
|
|
339
|
+
if(items.length === 0) return {}
|
|
340
|
+
|
|
341
|
+
const data = await Dir.reconstructData(collection, items)
|
|
342
|
+
|
|
343
|
+
return { [_id]: data } as Record<_ttid, T>
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Async iterator (listener) for the document's deletion.
|
|
348
|
+
*/
|
|
349
|
+
async *onDelete() {
|
|
350
|
+
|
|
351
|
+
let finished = false
|
|
352
|
+
|
|
353
|
+
const iter = Dir.searchDocs<T>(collection, `**/${_id.split('-')[0]}*`, {}, { listen: true, skip: true }, true)
|
|
354
|
+
|
|
355
|
+
do {
|
|
356
|
+
|
|
357
|
+
const { value, done } = await iter.next({ count: 0 })
|
|
358
|
+
|
|
359
|
+
if(value === undefined && !done) continue
|
|
360
|
+
|
|
361
|
+
if(done) {
|
|
362
|
+
finished = true
|
|
363
|
+
break
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
yield value as _ttid
|
|
367
|
+
|
|
368
|
+
} while(!finished)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Puts multiple documents into a collection.
|
|
375
|
+
* @param collection The name of the collection.
|
|
376
|
+
* @param batch The documents to put.
|
|
377
|
+
* @returns The IDs of the documents.
|
|
378
|
+
*/
|
|
379
|
+
async batchPutData<T extends Record<string, any>>(collection: string, batch: Array<T>) {
|
|
380
|
+
|
|
381
|
+
const batches: Array<Array<T>> = []
|
|
382
|
+
const ids: _ttid[] = []
|
|
383
|
+
|
|
384
|
+
if(batch.length > navigator.hardwareConcurrency) {
|
|
385
|
+
|
|
386
|
+
for(let i = 0; i < batch.length; i += navigator.hardwareConcurrency) {
|
|
387
|
+
batches.push(batch.slice(i, i + navigator.hardwareConcurrency))
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
} else batches.push(batch)
|
|
391
|
+
|
|
392
|
+
for(const batch of batches) {
|
|
393
|
+
|
|
394
|
+
const res = await Promise.allSettled(batch.map(data => this.putData(collection, data)))
|
|
395
|
+
|
|
396
|
+
for(const _id of res.filter(item => item.status === 'fulfilled').map(item => item.value)) {
|
|
397
|
+
ids.push(_id)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return ids
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Puts a document into a collection.
|
|
406
|
+
* @param collection The name of the collection.
|
|
407
|
+
* @param data The document to put.
|
|
408
|
+
* @returns The ID of the document.
|
|
409
|
+
*/
|
|
410
|
+
private static async uniqueTTID(existingId?: string): Promise<_ttid> {
|
|
411
|
+
|
|
412
|
+
// Serialize TTID generation so concurrent callers (e.g. batchPutData)
|
|
413
|
+
// never invoke TTID.generate() at the same sub-millisecond instant.
|
|
414
|
+
let _id!: _ttid
|
|
415
|
+
const prev = Fylo.ttidLock
|
|
416
|
+
Fylo.ttidLock = prev.then(async () => {
|
|
417
|
+
_id = existingId ? TTID.generate(existingId) : TTID.generate()
|
|
418
|
+
// Claim in Redis for cross-process uniqueness (no-op if Redis unavailable)
|
|
419
|
+
if(!(await Dir.claimTTID(_id))) throw new Error('TTID collision — retry')
|
|
420
|
+
})
|
|
421
|
+
await Fylo.ttidLock
|
|
422
|
+
|
|
423
|
+
return _id
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async putData<T extends Record<string, any>>(collection: string, data: Record<_ttid, T> | T) {
|
|
427
|
+
|
|
428
|
+
await Fylo.loadEncryption(collection)
|
|
429
|
+
|
|
430
|
+
const currId = Object.keys(data).shift()!
|
|
431
|
+
|
|
432
|
+
const _id = TTID.isTTID(currId) ? await Fylo.uniqueTTID(currId) : await Fylo.uniqueTTID()
|
|
433
|
+
|
|
434
|
+
let doc = TTID.isTTID(currId) ? Object.values(data).shift() as T : data as T
|
|
435
|
+
|
|
436
|
+
if(Fylo.STRICT) doc = await Gen.validateData(collection, doc) as T
|
|
437
|
+
|
|
438
|
+
const keys = await Dir.extractKeys(collection, _id, doc)
|
|
439
|
+
|
|
440
|
+
const results = await Promise.allSettled(keys.data.map((item, i) => this.dir.putKeys(collection, { dataKey: item, indexKey: keys.indexes[i] })))
|
|
441
|
+
|
|
442
|
+
if(results.some(res => res.status === "rejected")) {
|
|
443
|
+
await this.dir.executeRollback()
|
|
444
|
+
throw new Error(`Unable to write to ${collection} collection`)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if(Fylo.LOGGING) console.log(`Finished Writing ${_id}`)
|
|
448
|
+
|
|
449
|
+
return _id
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Patches a document in a collection.
|
|
454
|
+
* @param collection The name of the collection.
|
|
455
|
+
* @param newDoc The new document data.
|
|
456
|
+
* @param oldDoc The old document data.
|
|
457
|
+
* @returns The number of documents patched.
|
|
458
|
+
*/
|
|
459
|
+
async patchDoc<T extends Record<string, any>>(collection: string, newDoc: Record<_ttid, Partial<T>>, oldDoc: Record<_ttid, T> = {}) {
|
|
460
|
+
|
|
461
|
+
await Fylo.loadEncryption(collection)
|
|
462
|
+
|
|
463
|
+
const _id = Object.keys(newDoc).shift() as _ttid
|
|
464
|
+
|
|
465
|
+
let _newId = _id
|
|
466
|
+
|
|
467
|
+
if(!_id) throw new Error("this document does not contain an TTID")
|
|
468
|
+
|
|
469
|
+
// Fetch data keys once — needed for deletion and, when oldDoc is absent, reconstruction.
|
|
470
|
+
// Previously, delDoc re-fetched these internally, causing a redundant S3 list call per document.
|
|
471
|
+
const dataKeys = await Walker.getDocData(collection, _id)
|
|
472
|
+
|
|
473
|
+
if(dataKeys.length === 0) return _newId
|
|
474
|
+
|
|
475
|
+
if(Object.keys(oldDoc).length === 0) {
|
|
476
|
+
|
|
477
|
+
const data = await Dir.reconstructData(collection, dataKeys)
|
|
478
|
+
|
|
479
|
+
oldDoc = { [_id]: data } as Record<_ttid, T>
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if(Object.keys(oldDoc).length === 0) return _newId
|
|
483
|
+
|
|
484
|
+
const currData = { ...oldDoc[_id] }
|
|
485
|
+
|
|
486
|
+
for(const field in newDoc[_id]) currData[field] = newDoc[_id][field]!
|
|
487
|
+
|
|
488
|
+
// Generate new TTID upfront so that delete and write can proceed in parallel.
|
|
489
|
+
_newId = await Fylo.uniqueTTID(_id)
|
|
490
|
+
|
|
491
|
+
let docToWrite: T = currData as T
|
|
492
|
+
|
|
493
|
+
if(Fylo.STRICT) docToWrite = await Gen.validateData(collection, currData) as T
|
|
494
|
+
|
|
495
|
+
const newKeys = await Dir.extractKeys(collection, _newId, docToWrite)
|
|
496
|
+
|
|
497
|
+
const [deleteResults, putResults] = await Promise.all([
|
|
498
|
+
Promise.allSettled(dataKeys.map(key => this.dir.deleteKeys(collection, key))),
|
|
499
|
+
Promise.allSettled(newKeys.data.map((item, i) => this.dir.putKeys(collection, { dataKey: item, indexKey: newKeys.indexes[i] })))
|
|
500
|
+
])
|
|
501
|
+
|
|
502
|
+
if(deleteResults.some(r => r.status === 'rejected') || putResults.some(r => r.status === 'rejected')) {
|
|
503
|
+
await this.dir.executeRollback()
|
|
504
|
+
throw new Error(`Unable to update ${collection} collection`)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if(Fylo.LOGGING) console.log(`Finished Updating ${_id} to ${_newId}`)
|
|
508
|
+
|
|
509
|
+
return _newId
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Patches documents in a collection.
|
|
514
|
+
* @param collection The name of the collection.
|
|
515
|
+
* @param updateSchema The update schema.
|
|
516
|
+
* @returns The number of documents patched.
|
|
517
|
+
*/
|
|
518
|
+
async patchDocs<T extends Record<string, any>>(collection: string, updateSchema: _storeUpdate<T>) {
|
|
519
|
+
|
|
520
|
+
await Fylo.loadEncryption(collection)
|
|
521
|
+
|
|
522
|
+
const processDoc = (doc: Record<_ttid, T>, updateSchema: _storeUpdate<T>) => {
|
|
523
|
+
|
|
524
|
+
for(const _id in doc)
|
|
525
|
+
return this.patchDoc(collection, { [_id]: updateSchema.$set }, doc)
|
|
526
|
+
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let count = 0
|
|
531
|
+
|
|
532
|
+
const promises: Promise<_ttid>[] = []
|
|
533
|
+
|
|
534
|
+
let finished = false
|
|
535
|
+
|
|
536
|
+
const exprs = await Query.getExprs(collection, updateSchema.$where ?? {})
|
|
537
|
+
|
|
538
|
+
if(exprs.length === 1 && exprs[0] === `**/*`) {
|
|
539
|
+
|
|
540
|
+
for(const doc of await Fylo.allDocs<T>(collection, updateSchema.$where)) {
|
|
541
|
+
|
|
542
|
+
const promise = processDoc(doc, updateSchema)
|
|
543
|
+
|
|
544
|
+
if(promise) {
|
|
545
|
+
promises.push(promise)
|
|
546
|
+
count++
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
} else {
|
|
551
|
+
|
|
552
|
+
const iter = Dir.searchDocs<T>(collection, exprs, { updated: updateSchema?.$where?.$updated, created: updateSchema?.$where?.$created }, { listen: false, skip: false })
|
|
553
|
+
|
|
554
|
+
do {
|
|
555
|
+
|
|
556
|
+
const { value, done } = await iter.next({ count })
|
|
557
|
+
|
|
558
|
+
if(value === undefined && !done) continue
|
|
559
|
+
|
|
560
|
+
if(done) {
|
|
561
|
+
finished = true
|
|
562
|
+
break
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const promise = processDoc(value as Record<_ttid, T>, updateSchema)
|
|
566
|
+
|
|
567
|
+
if(promise) {
|
|
568
|
+
promises.push(promise)
|
|
569
|
+
count++
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
} while(!finished)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
await Promise.all(promises)
|
|
576
|
+
|
|
577
|
+
return count
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Deletes a document from a collection.
|
|
582
|
+
* @param collection The name of the collection.
|
|
583
|
+
* @param _id The ID of the document.
|
|
584
|
+
* @returns The number of documents deleted.
|
|
585
|
+
*/
|
|
586
|
+
async delDoc(collection: string, _id: _ttid) {
|
|
587
|
+
|
|
588
|
+
const keys = await Walker.getDocData(collection, _id)
|
|
589
|
+
|
|
590
|
+
const results = await Promise.allSettled(keys.map(key => this.dir.deleteKeys(collection, key)))
|
|
591
|
+
|
|
592
|
+
if(results.some(res => res.status === "rejected")) {
|
|
593
|
+
await this.dir.executeRollback()
|
|
594
|
+
throw new Error(`Unable to delete from ${collection} collection`)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if(Fylo.LOGGING) console.log(`Finished Deleting ${_id}`)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Deletes documents from a collection.
|
|
602
|
+
* @param collection The name of the collection.
|
|
603
|
+
* @param deleteSchema The delete schema.
|
|
604
|
+
* @returns The number of documents deleted.
|
|
605
|
+
*/
|
|
606
|
+
async delDocs<T extends Record<string, any>>(collection: string, deleteSchema?: _storeDelete<T>) {
|
|
607
|
+
|
|
608
|
+
await Fylo.loadEncryption(collection)
|
|
609
|
+
|
|
610
|
+
const processDoc = (doc: Record<_ttid, T>) => {
|
|
611
|
+
|
|
612
|
+
for(const _id in doc) {
|
|
613
|
+
|
|
614
|
+
if(TTID.isTTID(_id)) {
|
|
615
|
+
return this.delDoc(collection, _id)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
let count = 0
|
|
623
|
+
|
|
624
|
+
const promises: Promise<void>[] = []
|
|
625
|
+
|
|
626
|
+
let finished = false
|
|
627
|
+
|
|
628
|
+
const exprs = await Query.getExprs(collection, deleteSchema ?? {})
|
|
629
|
+
|
|
630
|
+
if(exprs.length === 1 && exprs[0] === `**/*`) {
|
|
631
|
+
|
|
632
|
+
for(const doc of await Fylo.allDocs<T>(collection, deleteSchema)) {
|
|
633
|
+
|
|
634
|
+
const promise = processDoc(doc)
|
|
635
|
+
|
|
636
|
+
if(promise) {
|
|
637
|
+
promises.push(promise)
|
|
638
|
+
count++
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
} else {
|
|
643
|
+
|
|
644
|
+
const iter = Dir.searchDocs<T>(collection, exprs, { updated: deleteSchema?.$updated, created: deleteSchema?.$created }, { listen: false, skip: false })
|
|
645
|
+
|
|
646
|
+
do {
|
|
647
|
+
|
|
648
|
+
const { value, done } = await iter.next({ count })
|
|
649
|
+
|
|
650
|
+
if(value === undefined && !done) continue
|
|
651
|
+
|
|
652
|
+
if(done) {
|
|
653
|
+
finished = true
|
|
654
|
+
break
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const promise = processDoc(value as Record<_ttid, T>)
|
|
658
|
+
|
|
659
|
+
if(promise) {
|
|
660
|
+
promises.push(promise)
|
|
661
|
+
count++
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
} while(!finished)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
await Promise.all(promises)
|
|
668
|
+
|
|
669
|
+
return count
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
private static selectValues<T extends Record<string, any>>(selection: Array<keyof T>, data: T) {
|
|
673
|
+
|
|
674
|
+
for(const field in data) {
|
|
675
|
+
if(!selection.includes(field as keyof T)) delete data[field]
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return data
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private static renameFields<T extends Record<string, any>>(rename: Record<keyof T, string>, data: T) {
|
|
682
|
+
|
|
683
|
+
for(const field in data) {
|
|
684
|
+
if(rename[field]) {
|
|
685
|
+
data[rename[field]] = data[field]
|
|
686
|
+
delete data[field]
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return data
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Joins documents from two collections.
|
|
695
|
+
* @param join The join schema.
|
|
696
|
+
* @returns The joined documents.
|
|
697
|
+
*/
|
|
698
|
+
static async joinDocs<T extends Record<string, any>, U extends Record<string, any>>(join: _join<T, U>) {
|
|
699
|
+
|
|
700
|
+
const docs: Record<`${_ttid}, ${_ttid}`, T | U | T & U | Partial<T> & Partial<U>> = {}
|
|
701
|
+
|
|
702
|
+
const compareFields = async (leftField: keyof T, rightField: keyof U, compare: (leftVal: string, rightVal: string) => boolean) => {
|
|
703
|
+
|
|
704
|
+
if(join.$leftCollection === join.$rightCollection) throw new Error("Left and right collections cannot be the same")
|
|
705
|
+
|
|
706
|
+
let leftToken: string | undefined
|
|
707
|
+
const leftFieldIndexes: string[] = []
|
|
708
|
+
|
|
709
|
+
do {
|
|
710
|
+
|
|
711
|
+
const leftData = await S3.list(join.$leftCollection, {
|
|
712
|
+
prefix: String(leftField)
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
if(!leftData.contents) break
|
|
716
|
+
|
|
717
|
+
leftFieldIndexes.push(...leftData.contents!.map(content => content.key!))
|
|
718
|
+
|
|
719
|
+
leftToken = leftData.nextContinuationToken
|
|
720
|
+
|
|
721
|
+
} while(leftToken !== undefined)
|
|
722
|
+
|
|
723
|
+
let rightToken: string | undefined
|
|
724
|
+
const rightFieldIndexes: string[] = []
|
|
725
|
+
|
|
726
|
+
do {
|
|
727
|
+
|
|
728
|
+
const rightData = await S3.list(join.$rightCollection, {
|
|
729
|
+
prefix: String(rightField)
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
if(!rightData.contents) break
|
|
733
|
+
|
|
734
|
+
rightFieldIndexes.push(...rightData.contents!.map(content => content.key!))
|
|
735
|
+
|
|
736
|
+
rightToken = rightData.nextContinuationToken
|
|
737
|
+
|
|
738
|
+
} while(rightToken !== undefined)
|
|
739
|
+
|
|
740
|
+
for(const leftIdx of leftFieldIndexes) {
|
|
741
|
+
|
|
742
|
+
const leftSegs = leftIdx.split('/')
|
|
743
|
+
const left_id = leftSegs.pop()! as _ttid
|
|
744
|
+
const leftVal = leftSegs.pop()!
|
|
745
|
+
|
|
746
|
+
const leftCollection = join.$leftCollection
|
|
747
|
+
|
|
748
|
+
const allVals = new Set<string>()
|
|
749
|
+
|
|
750
|
+
for(const rightIdx of rightFieldIndexes) {
|
|
751
|
+
|
|
752
|
+
const rightSegs = rightIdx.split('/')
|
|
753
|
+
const right_id = rightSegs.pop()! as _ttid
|
|
754
|
+
const rightVal = rightSegs.pop()!
|
|
755
|
+
|
|
756
|
+
const rightCollection = join.$rightCollection
|
|
757
|
+
|
|
758
|
+
if(compare(rightVal, leftVal) && !allVals.has(rightVal)) {
|
|
759
|
+
|
|
760
|
+
allVals.add(rightVal)
|
|
761
|
+
|
|
762
|
+
switch(join.$mode) {
|
|
763
|
+
case "inner":
|
|
764
|
+
docs[`${left_id}, ${right_id}`] = { [leftField]: Dir.parseValue(leftVal), [rightField]: Dir.parseValue(rightVal) } as Partial<T> & Partial<U>
|
|
765
|
+
break
|
|
766
|
+
case "left":
|
|
767
|
+
const leftDoc = await this.getDoc<T>(leftCollection, left_id).once()
|
|
768
|
+
if(Object.keys(leftDoc).length > 0) {
|
|
769
|
+
let leftData = leftDoc[left_id]
|
|
770
|
+
if(join.$select) leftData = this.selectValues<T>(join.$select as Array<keyof T>, leftData)
|
|
771
|
+
if(join.$rename) leftData = this.renameFields<T>(join.$rename, leftData)
|
|
772
|
+
docs[`${left_id}, ${right_id}`] = leftData as T
|
|
773
|
+
}
|
|
774
|
+
break
|
|
775
|
+
case "right":
|
|
776
|
+
const rightDoc = await this.getDoc<U>(rightCollection, right_id).once()
|
|
777
|
+
if(Object.keys(rightDoc).length > 0) {
|
|
778
|
+
let rightData = rightDoc[right_id]
|
|
779
|
+
if(join.$select) rightData = this.selectValues<U>(join.$select as Array<keyof U>, rightData)
|
|
780
|
+
if(join.$rename) rightData = this.renameFields<U>(join.$rename, rightData)
|
|
781
|
+
docs[`${left_id}, ${right_id}`] = rightData as U
|
|
782
|
+
}
|
|
783
|
+
break
|
|
784
|
+
case "outer":
|
|
785
|
+
|
|
786
|
+
let leftFullData: T = {} as T
|
|
787
|
+
let rightFullData: U = {} as U
|
|
788
|
+
|
|
789
|
+
const leftFullDoc = await this.getDoc<T>(leftCollection, left_id).once()
|
|
790
|
+
|
|
791
|
+
if(Object.keys(leftFullDoc).length > 0) {
|
|
792
|
+
let leftData = leftFullDoc[left_id]
|
|
793
|
+
if(join.$select) leftData = this.selectValues<T>(join.$select as Array<keyof T>, leftData)
|
|
794
|
+
if(join.$rename) leftData = this.renameFields<T>(join.$rename, leftData)
|
|
795
|
+
leftFullData = { ...leftData, ...leftFullData } as T
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const rightFullDoc = await this.getDoc<U>(rightCollection, right_id).once()
|
|
799
|
+
|
|
800
|
+
if(Object.keys(rightFullDoc).length > 0) {
|
|
801
|
+
let rightData = rightFullDoc[right_id]
|
|
802
|
+
if(join.$select) rightData = this.selectValues<U>(join.$select as Array<keyof U>, rightData)
|
|
803
|
+
if(join.$rename) rightData = this.renameFields<U>(join.$rename, rightData)
|
|
804
|
+
rightFullData = { ...rightData, ...rightFullData } as U
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
docs[`${left_id}, ${right_id}`] = { ...leftFullData, ...rightFullData } as T & U
|
|
808
|
+
break
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if(join.$limit && Object.keys(docs).length === join.$limit) break
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if(join.$limit && Object.keys(docs).length === join.$limit) break
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
for(const field in join.$on) {
|
|
820
|
+
|
|
821
|
+
if(join.$on[field]!.$eq) await compareFields(field, join.$on[field]!.$eq, (leftVal, rightVal) => leftVal === rightVal)
|
|
822
|
+
|
|
823
|
+
if(join.$on[field]!.$ne) await compareFields(field, join.$on[field]!.$ne, (leftVal, rightVal) => leftVal !== rightVal)
|
|
824
|
+
|
|
825
|
+
if(join.$on[field]!.$gt) await compareFields(field, join.$on[field]!.$gt, (leftVal, rightVal) => Number(leftVal) > Number(rightVal))
|
|
826
|
+
|
|
827
|
+
if(join.$on[field]!.$lt) await compareFields(field, join.$on[field]!.$lt, (leftVal, rightVal) => Number(leftVal) < Number(rightVal))
|
|
828
|
+
|
|
829
|
+
if(join.$on[field]!.$gte) await compareFields(field, join.$on[field]!.$gte, (leftVal, rightVal) => Number(leftVal) >= Number(rightVal))
|
|
830
|
+
|
|
831
|
+
if(join.$on[field]!.$lte) await compareFields(field, join.$on[field]!.$lte, (leftVal, rightVal) => Number(leftVal) <= Number(rightVal))
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if(join.$groupby) {
|
|
835
|
+
|
|
836
|
+
const groupedDocs: Record<string, Record<string, Partial<T | U>>> = {} as Record<string, Record<string, Partial<T | U>>>
|
|
837
|
+
|
|
838
|
+
for(const ids in docs) {
|
|
839
|
+
|
|
840
|
+
const data = docs[ids as `${_ttid}, ${_ttid}`]
|
|
841
|
+
|
|
842
|
+
// @ts-expect-error - Object.groupBy not yet in TS lib types
|
|
843
|
+
const grouping = Object.groupBy([data], elem => elem[join.$groupby!])
|
|
844
|
+
|
|
845
|
+
for(const group in grouping) {
|
|
846
|
+
|
|
847
|
+
if(groupedDocs[group]) groupedDocs[group][ids] = data
|
|
848
|
+
else groupedDocs[group] = { [ids]: data }
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if(join.$onlyIds) {
|
|
853
|
+
|
|
854
|
+
const groupedIds: Record<string, _ttid[]> = {}
|
|
855
|
+
|
|
856
|
+
for(const group in groupedDocs) {
|
|
857
|
+
const doc = groupedDocs[group]
|
|
858
|
+
groupedIds[group] = Object.keys(doc).flat()
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
return groupedIds
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
return groupedDocs
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if(join.$onlyIds) return Array.from(new Set(Object.keys(docs).flat()))
|
|
868
|
+
|
|
869
|
+
return docs
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
private static async allDocs<T extends Record<string, any>>(collection: string, query?: _storeQuery<T>) {
|
|
873
|
+
|
|
874
|
+
const res = await S3.list(collection, {
|
|
875
|
+
delimiter: '/',
|
|
876
|
+
maxKeys: !query || !query.$limit ? undefined : query.$limit
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
const ids = res.commonPrefixes?.map(item => item.prefix!.split('/')[0]!).filter(key => TTID.isTTID(key)) as _ttid[] ?? [] as _ttid[]
|
|
880
|
+
|
|
881
|
+
const docs = await Promise.allSettled(ids.map(id => Fylo.getDoc<T>(collection, id).once()))
|
|
882
|
+
|
|
883
|
+
return docs.filter(item => item.status === 'fulfilled').map(item => item.value).filter(doc => Object.keys(doc).length > 0)
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Finds documents in a collection.
|
|
888
|
+
* @param collection The name of the collection.
|
|
889
|
+
* @param query The query schema.
|
|
890
|
+
* @returns The found documents.
|
|
891
|
+
*/
|
|
892
|
+
static findDocs<T extends Record<string, any>>(collection: string, query?: _storeQuery<T>) {
|
|
893
|
+
|
|
894
|
+
const processDoc = (doc: Record<_ttid, T>, query?: _storeQuery<T>) => {
|
|
895
|
+
|
|
896
|
+
if(Object.keys(doc).length > 0) {
|
|
897
|
+
|
|
898
|
+
// Post-filter for operators that cannot be expressed as globs ($ne, $gt, $gte, $lt, $lte).
|
|
899
|
+
// $ops use OR semantics: a document passes if it matches at least one op.
|
|
900
|
+
if(query?.$ops) {
|
|
901
|
+
for(const [_id, data] of Object.entries(doc)) {
|
|
902
|
+
let matchesAny = false
|
|
903
|
+
for(const op of query.$ops) {
|
|
904
|
+
let opMatches = true
|
|
905
|
+
for(const col in op) {
|
|
906
|
+
const val = (data as Record<string, unknown>)[col]
|
|
907
|
+
const cond = op[col as keyof T]!
|
|
908
|
+
if(cond.$ne !== undefined && val == cond.$ne) { opMatches = false; break }
|
|
909
|
+
if(cond.$gt !== undefined && !(Number(val) > cond.$gt)) { opMatches = false; break }
|
|
910
|
+
if(cond.$gte !== undefined && !(Number(val) >= cond.$gte)) { opMatches = false; break }
|
|
911
|
+
if(cond.$lt !== undefined && !(Number(val) < cond.$lt)) { opMatches = false; break }
|
|
912
|
+
if(cond.$lte !== undefined && !(Number(val) <= cond.$lte)) { opMatches = false; break }
|
|
913
|
+
}
|
|
914
|
+
if(opMatches) { matchesAny = true; break }
|
|
915
|
+
}
|
|
916
|
+
if(!matchesAny) delete doc[_id as _ttid]
|
|
917
|
+
}
|
|
918
|
+
if(Object.keys(doc).length === 0) return
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
for(let [_id, data] of Object.entries(doc)) {
|
|
922
|
+
|
|
923
|
+
if(query && query.$select && query.$select.length > 0) {
|
|
924
|
+
|
|
925
|
+
data = this.selectValues<T>(query.$select as Array<keyof T>, data)
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if(query && query.$rename) data = this.renameFields<T>(query.$rename, data)
|
|
929
|
+
|
|
930
|
+
doc[_id] = data
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if(query && query.$groupby) {
|
|
934
|
+
|
|
935
|
+
const docGroup: Record<string, Record<string, Partial<T>>> = {}
|
|
936
|
+
|
|
937
|
+
for(const [id, data] of Object.entries(doc)) {
|
|
938
|
+
|
|
939
|
+
const groupValue = data[query.$groupby] as string
|
|
940
|
+
|
|
941
|
+
if(groupValue) {
|
|
942
|
+
|
|
943
|
+
delete data[query.$groupby]
|
|
944
|
+
|
|
945
|
+
docGroup[groupValue] = {
|
|
946
|
+
[id]: data as Partial<T>
|
|
947
|
+
} as Record<_ttid, Partial<T>>
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if(query && query.$onlyIds) {
|
|
952
|
+
|
|
953
|
+
for(const [groupValue, doc] of Object.entries(docGroup)) {
|
|
954
|
+
|
|
955
|
+
for(const id in doc as Record<_ttid, T>) {
|
|
956
|
+
|
|
957
|
+
// @ts-expect-error - dynamic key assignment on grouped object
|
|
958
|
+
docGroup[groupValue][id] = null
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return docGroup
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return docGroup
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if(query && query.$onlyIds) {
|
|
969
|
+
return Object.keys(doc).shift()
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return doc
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
return
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
return {
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Async iterator (listener) for the documents.
|
|
982
|
+
*/
|
|
983
|
+
async *[Symbol.asyncIterator]() {
|
|
984
|
+
|
|
985
|
+
await Fylo.loadEncryption(collection)
|
|
986
|
+
|
|
987
|
+
const expression = await Query.getExprs(collection, query ?? {})
|
|
988
|
+
|
|
989
|
+
if(expression.length === 1 && expression[0] === `**/*`) {
|
|
990
|
+
for(const doc of await Fylo.allDocs<T>(collection, query)) yield processDoc(doc, query)
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
let count = 0
|
|
994
|
+
let finished = false
|
|
995
|
+
|
|
996
|
+
const iter = Dir.searchDocs<T>(collection, expression, { updated: query?.$updated, created: query?.$created }, { listen: true, skip: true })
|
|
997
|
+
|
|
998
|
+
do {
|
|
999
|
+
|
|
1000
|
+
const { value, done } = await iter.next({ count, limit: query?.$limit })
|
|
1001
|
+
|
|
1002
|
+
if(value === undefined && !done) continue
|
|
1003
|
+
|
|
1004
|
+
if(done) {
|
|
1005
|
+
finished = true
|
|
1006
|
+
break
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const result = processDoc(value as Record<_ttid, T>, query)
|
|
1010
|
+
if(result !== undefined) {
|
|
1011
|
+
count++
|
|
1012
|
+
yield result
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
} while(!finished)
|
|
1016
|
+
},
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Async iterator for the documents.
|
|
1020
|
+
*/
|
|
1021
|
+
async *collect() {
|
|
1022
|
+
|
|
1023
|
+
await Fylo.loadEncryption(collection)
|
|
1024
|
+
|
|
1025
|
+
const expression = await Query.getExprs(collection, query ?? {})
|
|
1026
|
+
|
|
1027
|
+
if(expression.length === 1 && expression[0] === `**/*`) {
|
|
1028
|
+
|
|
1029
|
+
for(const doc of await Fylo.allDocs<T>(collection, query)) yield processDoc(doc, query)
|
|
1030
|
+
|
|
1031
|
+
} else {
|
|
1032
|
+
|
|
1033
|
+
let count = 0
|
|
1034
|
+
let finished = false
|
|
1035
|
+
|
|
1036
|
+
const iter = Dir.searchDocs<T>(collection, expression, { updated: query?.$updated, created: query?.$created }, { listen: false, skip: false })
|
|
1037
|
+
|
|
1038
|
+
do {
|
|
1039
|
+
|
|
1040
|
+
const { value, done } = await iter.next({ count, limit: query?.$limit })
|
|
1041
|
+
|
|
1042
|
+
if(value === undefined && !done) continue
|
|
1043
|
+
|
|
1044
|
+
if(done) {
|
|
1045
|
+
finished = true
|
|
1046
|
+
break
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const result = processDoc(value as Record<_ttid, T>, query)
|
|
1050
|
+
if(result !== undefined) {
|
|
1051
|
+
count++
|
|
1052
|
+
yield result
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
} while(!finished)
|
|
1056
|
+
}
|
|
1057
|
+
},
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Async iterator (listener) for the document's deletion.
|
|
1061
|
+
*/
|
|
1062
|
+
async *onDelete() {
|
|
1063
|
+
|
|
1064
|
+
await Fylo.loadEncryption(collection)
|
|
1065
|
+
|
|
1066
|
+
let count = 0
|
|
1067
|
+
let finished = false
|
|
1068
|
+
|
|
1069
|
+
const iter = Dir.searchDocs<T>(collection, await Query.getExprs(collection, query ?? {}), {}, { listen: true, skip: true }, true)
|
|
1070
|
+
|
|
1071
|
+
do {
|
|
1072
|
+
|
|
1073
|
+
const { value, done } = await iter.next({ count })
|
|
1074
|
+
|
|
1075
|
+
if(value === undefined && !done) continue
|
|
1076
|
+
|
|
1077
|
+
if(done) {
|
|
1078
|
+
finished = true
|
|
1079
|
+
break
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if(value) yield value as _ttid
|
|
1083
|
+
|
|
1084
|
+
} while(!finished)
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|