@delma/fylo 1.1.1 → 2.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.
Files changed (74) hide show
  1. package/.github/copilot-instructions.md +1 -1
  2. package/.github/prompts/release.prompt.md +4 -43
  3. package/AGENTS.md +1 -1
  4. package/CLAUDE.md +1 -1
  5. package/README.md +141 -62
  6. package/eslint.config.js +8 -4
  7. package/package.json +9 -7
  8. package/src/CLI +16 -14
  9. package/src/adapters/cipher.ts +12 -6
  10. package/src/adapters/redis.ts +193 -123
  11. package/src/adapters/s3.ts +6 -12
  12. package/src/core/collection.ts +5 -0
  13. package/src/core/directory.ts +120 -151
  14. package/src/core/extensions.ts +4 -2
  15. package/src/core/format.ts +390 -419
  16. package/src/core/parser.ts +167 -142
  17. package/src/core/query.ts +31 -26
  18. package/src/core/walker.ts +68 -61
  19. package/src/core/write-queue.ts +7 -4
  20. package/src/engines/s3-files.ts +888 -0
  21. package/src/engines/types.ts +21 -0
  22. package/src/index.ts +754 -378
  23. package/src/migrate-cli.ts +22 -0
  24. package/src/migrate.ts +74 -0
  25. package/src/types/bun-runtime.d.ts +73 -0
  26. package/src/types/fylo.d.ts +115 -27
  27. package/src/types/node-runtime.d.ts +61 -0
  28. package/src/types/query.d.ts +6 -2
  29. package/src/types/vendor-modules.d.ts +8 -7
  30. package/src/worker.ts +7 -1
  31. package/src/workers/write-worker.ts +25 -24
  32. package/tests/collection/truncate.test.js +35 -0
  33. package/tests/{data.ts → data.js} +8 -21
  34. package/tests/{index.ts → index.js} +4 -9
  35. package/tests/integration/aws-s3-files.canary.test.js +22 -0
  36. package/tests/integration/{create.test.ts → create.test.js} +13 -31
  37. package/tests/integration/delete.test.js +95 -0
  38. package/tests/integration/{edge-cases.test.ts → edge-cases.test.js} +50 -124
  39. package/tests/integration/{encryption.test.ts → encryption.test.js} +20 -65
  40. package/tests/integration/{export.test.ts → export.test.js} +8 -23
  41. package/tests/integration/{join-modes.test.ts → join-modes.test.js} +37 -104
  42. package/tests/integration/migration.test.js +38 -0
  43. package/tests/integration/nested.test.js +142 -0
  44. package/tests/integration/operators.test.js +122 -0
  45. package/tests/integration/{queue.test.ts → queue.test.js} +24 -40
  46. package/tests/integration/read.test.js +119 -0
  47. package/tests/integration/rollback.test.js +60 -0
  48. package/tests/integration/s3-files.test.js +108 -0
  49. package/tests/integration/update.test.js +99 -0
  50. package/tests/mocks/{cipher.ts → cipher.js} +11 -26
  51. package/tests/mocks/redis.js +123 -0
  52. package/tests/mocks/{s3.ts → s3.js} +24 -58
  53. package/tests/schemas/album.json +1 -1
  54. package/tests/schemas/comment.json +1 -1
  55. package/tests/schemas/photo.json +1 -1
  56. package/tests/schemas/post.json +1 -1
  57. package/tests/schemas/tip.json +1 -1
  58. package/tests/schemas/todo.json +1 -1
  59. package/tests/schemas/user.d.ts +12 -12
  60. package/tests/schemas/user.json +1 -1
  61. package/tsconfig.json +4 -2
  62. package/tsconfig.typecheck.json +31 -0
  63. package/.github/prompts/issue.prompt.md +0 -19
  64. package/.github/prompts/pr.prompt.md +0 -18
  65. package/.github/prompts/review-pr.prompt.md +0 -19
  66. package/.github/prompts/sync-main.prompt.md +0 -14
  67. package/tests/collection/truncate.test.ts +0 -56
  68. package/tests/integration/delete.test.ts +0 -147
  69. package/tests/integration/nested.test.ts +0 -212
  70. package/tests/integration/operators.test.ts +0 -167
  71. package/tests/integration/read.test.ts +0 -203
  72. package/tests/integration/rollback.test.ts +0 -105
  73. package/tests/integration/update.test.ts +0 -130
  74. package/tests/mocks/redis.ts +0 -169
package/src/index.ts CHANGED
@@ -1,21 +1,22 @@
1
1
  /* eslint-disable @typescript-eslint/explicit-function-return-type */
2
2
  import { Query } from './core/query'
3
3
  import { Parser } from './core/parser'
4
- import { Dir } from "./core/directory";
5
- import TTID from '@delma/ttid';
6
- import Gen from "@delma/chex"
7
- import { Walker } from './core/walker';
8
- import { S3 } from "./adapters/s3"
9
- import { Cipher } from "./adapters/cipher"
10
- import { Redis } from "./adapters/redis"
4
+ import { Dir } from './core/directory'
5
+ import TTID from '@delma/ttid'
6
+ import Gen from '@delma/chex'
7
+ import { Walker } from './core/walker'
8
+ import { S3 } from './adapters/s3'
9
+ import { Cipher } from './adapters/cipher'
10
+ import { Redis } from './adapters/redis'
11
11
  import { WriteQueue } from './core/write-queue'
12
+ import { S3FilesEngine } from './engines/s3-files'
12
13
  import './core/format'
13
14
  import './core/extensions'
14
15
  import type { QueueStats, QueuedWriteResult, WriteJob } from './types/write-queue'
15
16
  import type { StreamJobEntry } from './types/write-queue'
17
+ import type { FyloStorageEngineKind } from './engines/types'
16
18
 
17
19
  export default class Fylo {
18
-
19
20
  private static LOGGING = process.env.LOGGING
20
21
 
21
22
  private static MAX_CPUS = navigator.hardwareConcurrency
@@ -37,62 +38,98 @@ export default class Fylo {
37
38
 
38
39
  private static readonly WRITE_RETRY_BASE_MS = Number(process.env.FYLO_WRITE_RETRY_BASE_MS ?? 10)
39
40
 
40
- private dir: Dir;
41
+ private readonly dir: Dir
42
+
43
+ private readonly engineKind: FyloStorageEngineKind
41
44
 
42
- constructor() {
45
+ private readonly s3Files?: S3FilesEngine
46
+
47
+ constructor(options: { engine?: FyloStorageEngineKind; s3FilesRoot?: string } = {}) {
43
48
  this.dir = new Dir()
49
+ this.engineKind = options.engine ?? Fylo.defaultEngineKind()
50
+ if (this.engineKind === 's3-files') this.s3Files = new S3FilesEngine(options.s3FilesRoot)
44
51
  }
45
52
 
46
53
  private static get queueRedis(): Redis {
47
- if(!Fylo._queueRedis) Fylo._queueRedis = new Redis()
54
+ if (!Fylo._queueRedis) Fylo._queueRedis = new Redis()
48
55
  return Fylo._queueRedis
49
56
  }
50
57
 
58
+ private static defaultEngineKind(): FyloStorageEngineKind {
59
+ return process.env.FYLO_STORAGE_ENGINE === 's3-files' ? 's3-files' : 'legacy-s3'
60
+ }
61
+
62
+ private static get defaultS3Files(): S3FilesEngine {
63
+ return new S3FilesEngine(process.env.FYLO_S3FILES_ROOT)
64
+ }
65
+
66
+ private assertS3FilesEngine(): S3FilesEngine {
67
+ if (!this.s3Files) throw new Error('S3 Files engine is not configured')
68
+ return this.s3Files
69
+ }
70
+
71
+ private ensureLegacyQueue(feature: string): Redis {
72
+ if (this.engineKind === 's3-files')
73
+ throw new Error(`${feature} is not supported in s3-files engine`)
74
+ return Fylo.queueRedis
75
+ }
76
+
51
77
  /**
52
78
  * Executes a SQL query and returns the results.
53
79
  * @param SQL The SQL query to execute.
54
80
  * @returns The results of the query.
55
81
  */
56
- async executeSQL<T extends Record<string, any>, U extends Record<string, any> = Record<string, unknown>>(SQL: string) {
57
-
82
+ async executeSQL<
83
+ T extends Record<string, any>,
84
+ U extends Record<string, any> = Record<string, unknown>
85
+ >(SQL: string) {
58
86
  const op = SQL.match(/^(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP)/i)
59
87
 
60
- if(!op) throw new Error("Missing SQL Operation")
88
+ if (!op) throw new Error('Missing SQL Operation')
61
89
 
62
- switch(op.shift()) {
63
- case "CREATE":
64
- return await Fylo.createCollection((Parser.parse(SQL) as _storeDelete<T>).$collection!)
65
- case "DROP":
66
- return await Fylo.dropCollection((Parser.parse(SQL) as _storeDelete<T>).$collection!)
67
- case "SELECT":
90
+ switch (op.shift()) {
91
+ case 'CREATE':
92
+ return await this.createCollection(
93
+ (Parser.parse(SQL) as _storeDelete<T>).$collection!
94
+ )
95
+ case 'DROP':
96
+ return await this.dropCollection(
97
+ (Parser.parse(SQL) as _storeDelete<T>).$collection!
98
+ )
99
+ case 'SELECT':
68
100
  const query = Parser.parse<T>(SQL) as _storeQuery<T>
69
- if(SQL.includes('JOIN')) return await Fylo.joinDocs(query as _join<T, U>)
101
+ if (SQL.includes('JOIN')) return await this.joinDocs(query as _join<T, U>)
70
102
  const selCol = (query as _storeQuery<T>).$collection
71
103
  delete (query as _storeQuery<T>).$collection
72
- let docs: Record<string, unknown> | Array<_ttid> = query.$onlyIds ? new Array<_ttid> : {}
73
- for await (const data of Fylo.findDocs(selCol! as string, query as _storeQuery<T>).collect()) {
74
- if(typeof data === 'object') {
104
+ let docs: Record<string, unknown> | Array<_ttid> = query.$onlyIds
105
+ ? new Array<_ttid>()
106
+ : {}
107
+ for await (const data of this.findDocs(
108
+ selCol! as string,
109
+ query as _storeQuery<T>
110
+ ).collect()) {
111
+ if (typeof data === 'object') {
75
112
  docs = Object.appendGroup(docs, data)
76
113
  } else (docs as Array<_ttid>).push(data as _ttid)
77
114
  }
78
115
  return docs
79
- case "INSERT":
116
+ case 'INSERT':
80
117
  const insert = Parser.parse<T>(SQL) as _storeInsert<T>
81
118
  const insCol = insert.$collection
82
119
  delete insert.$collection
83
120
  return await this.putData(insCol!, insert.$values)
84
- case "UPDATE":
121
+ case 'UPDATE':
85
122
  const update = Parser.parse<T>(SQL) as _storeUpdate<T>
86
123
  const updateCol = update.$collection
87
124
  delete update.$collection
88
125
  return await this.patchDocs(updateCol!, update)
89
- case "DELETE":
126
+ case 'DELETE':
90
127
  const del = Parser.parse<T>(SQL) as _storeDelete<T>
91
128
  const delCol = del.$collection
92
129
  delete del.$collection
93
130
  return await this.delDocs(delCol!, del)
94
131
  default:
95
- throw new Error("Invalid Operation")
132
+ throw new Error('Invalid Operation')
96
133
  }
97
134
  }
98
135
 
@@ -101,7 +138,10 @@ export default class Fylo {
101
138
  * @param collection The name of the collection.
102
139
  */
103
140
  static async createCollection(collection: string) {
104
-
141
+ if (Fylo.defaultEngineKind() === 's3-files') {
142
+ await Fylo.defaultS3Files.createCollection(collection)
143
+ return
144
+ }
105
145
  await S3.createBucket(collection)
106
146
  }
107
147
 
@@ -110,10 +150,25 @@ export default class Fylo {
110
150
  * @param collection The name of the collection.
111
151
  */
112
152
  static async dropCollection(collection: string) {
113
-
153
+ if (Fylo.defaultEngineKind() === 's3-files') {
154
+ await Fylo.defaultS3Files.dropCollection(collection)
155
+ return
156
+ }
114
157
  await S3.deleteBucket(collection)
115
158
  }
116
159
 
160
+ async createCollection(collection: string) {
161
+ if (this.engineKind === 's3-files')
162
+ return await this.assertS3FilesEngine().createCollection(collection)
163
+ return await Fylo.createCollection(collection)
164
+ }
165
+
166
+ async dropCollection(collection: string) {
167
+ if (this.engineKind === 's3-files')
168
+ return await this.assertS3FilesEngine().dropCollection(collection)
169
+ return await Fylo.dropCollection(collection)
170
+ }
171
+
117
172
  /**
118
173
  * Loads encrypted field config from a collection's JSON schema if not already loaded.
119
174
  * Reads the `$encrypted` array from the schema and registers fields with Cipher.
@@ -133,8 +188,12 @@ export default class Fylo {
133
188
  if (Array.isArray(encrypted) && encrypted.length > 0) {
134
189
  if (!Cipher.isConfigured()) {
135
190
  const secret = process.env.ENCRYPTION_KEY
136
- if (!secret) throw new Error('Schema declares $encrypted fields but ENCRYPTION_KEY env var is not set')
137
- if (secret.length < 32) throw new Error('ENCRYPTION_KEY must be at least 32 characters long')
191
+ if (!secret)
192
+ throw new Error(
193
+ 'Schema declares $encrypted fields but ENCRYPTION_KEY env var is not set'
194
+ )
195
+ if (secret.length < 32)
196
+ throw new Error('ENCRYPTION_KEY must be at least 32 characters long')
138
197
  await Cipher.configure(secret)
139
198
  }
140
199
  Cipher.registerFields(collection, encrypted as string[])
@@ -144,47 +203,68 @@ export default class Fylo {
144
203
  }
145
204
  }
146
205
 
206
+ getDoc<T extends Record<string, any>>(collection: string, _id: _ttid, onlyId: boolean = false) {
207
+ if (this.engineKind === 's3-files')
208
+ return this.assertS3FilesEngine().getDoc<T>(collection, _id, onlyId)
209
+ return Fylo.getDoc<T>(collection, _id, onlyId)
210
+ }
211
+
212
+ findDocs<T extends Record<string, any>>(collection: string, query?: _storeQuery<T>) {
213
+ if (this.engineKind === 's3-files')
214
+ return this.assertS3FilesEngine().findDocs<T>(collection, query)
215
+ return Fylo.findDocs<T>(collection, query)
216
+ }
217
+
218
+ async joinDocs<T extends Record<string, any>, U extends Record<string, any>>(
219
+ join: _join<T, U>
220
+ ) {
221
+ if (this.engineKind === 's3-files') return await this.assertS3FilesEngine().joinDocs(join)
222
+ return await Fylo.joinDocs(join)
223
+ }
224
+
147
225
  /**
148
226
  * Rolls back all transcations in current instance
149
227
  * @deprecated Prefer queued write recovery, retries, dead letters, or compensating writes.
150
228
  */
151
229
  async rollback() {
152
- if(!Fylo.rollbackWarningShown) {
230
+ if (this.engineKind === 's3-files') return
231
+ if (!Fylo.rollbackWarningShown) {
153
232
  Fylo.rollbackWarningShown = true
154
- console.warn('[FYLO] rollback() is deprecated for queued-write flows. Prefer job recovery, dead letters, or compensating writes.')
233
+ console.warn(
234
+ '[FYLO] rollback() is deprecated for queued-write flows. Prefer job recovery, dead letters, or compensating writes.'
235
+ )
155
236
  }
156
237
  await this.dir.executeRollback()
157
238
  }
158
239
 
159
240
  async getJobStatus(jobId: string) {
160
- return await Fylo.queueRedis.getJob(jobId)
241
+ return await this.ensureLegacyQueue('getJobStatus').getJob(jobId)
161
242
  }
162
243
 
163
244
  async getDocStatus(collection: string, docId: _ttid) {
164
- return await Fylo.queueRedis.getDocStatus(collection, docId)
245
+ return await this.ensureLegacyQueue('getDocStatus').getDocStatus(collection, docId)
165
246
  }
166
247
 
167
248
  async getDeadLetters(count: number = 10) {
168
- return await Fylo.queueRedis.readDeadLetters(count)
249
+ return await this.ensureLegacyQueue('getDeadLetters').readDeadLetters(count)
169
250
  }
170
251
 
171
252
  async getQueueStats(): Promise<QueueStats> {
172
- return await Fylo.queueRedis.getQueueStats()
253
+ return await this.ensureLegacyQueue('getQueueStats').getQueueStats()
173
254
  }
174
255
 
175
256
  async replayDeadLetter(streamId: string) {
176
- return await Fylo.queueRedis.replayDeadLetter(streamId)
257
+ return await this.ensureLegacyQueue('replayDeadLetter').replayDeadLetter(streamId)
177
258
  }
178
259
 
179
260
  private async waitForJob(jobId: string, timeoutMs: number = 5_000, intervalMs: number = 50) {
180
-
181
261
  const start = Date.now()
182
262
 
183
263
  do {
184
264
  const job = await this.getJobStatus(jobId)
185
- if(job && (job.status === 'committed' || job.status === 'failed')) return job
265
+ if (job && (job.status === 'committed' || job.status === 'failed')) return job
186
266
  await Bun.sleep(intervalMs)
187
- } while(Date.now() - start < timeoutMs)
267
+ } while (Date.now() - start < timeoutMs)
188
268
 
189
269
  throw new Error(`Timed out waiting for job ${jobId}`)
190
270
  }
@@ -199,16 +279,20 @@ export default class Fylo {
199
279
  } = {},
200
280
  resolveValue?: () => Promise<T> | T
201
281
  ): Promise<T | QueuedWriteResult> {
282
+ if (this.engineKind === 's3-files') {
283
+ if (!wait) return queued
284
+ return resolveValue ? await resolveValue() : queued
285
+ }
202
286
 
203
- if(!wait) return queued
287
+ if (!wait) return queued
204
288
 
205
289
  const processed = await this.processQueuedWrites(1)
206
290
 
207
- if(processed === 0) throw new Error(`No worker available to process job ${queued.jobId}`)
291
+ if (processed === 0) throw new Error(`No worker available to process job ${queued.jobId}`)
208
292
 
209
293
  const job = await this.getJobStatus(queued.jobId)
210
294
 
211
- if(job?.status === 'failed') {
295
+ if (job?.status === 'failed') {
212
296
  throw new Error(job.error ?? `Queued job ${queued.jobId} failed`)
213
297
  }
214
298
 
@@ -216,24 +300,25 @@ export default class Fylo {
216
300
  }
217
301
 
218
302
  async processQueuedWrites(count: number = 1, recover: boolean = false) {
303
+ this.ensureLegacyQueue('processQueuedWrites')
219
304
  const jobs = recover
220
305
  ? await Fylo.queueRedis.claimPendingJobs(Bun.randomUUIDv7(), 30_000, count)
221
306
  : await Fylo.queueRedis.readWriteJobs(Bun.randomUUIDv7(), count)
222
307
 
223
308
  let processed = 0
224
309
 
225
- for(const job of jobs) {
226
- if(await this.processQueuedJob(job)) processed++
310
+ for (const job of jobs) {
311
+ if (await this.processQueuedJob(job)) processed++
227
312
  }
228
313
 
229
314
  return processed
230
315
  }
231
316
 
232
317
  private async processQueuedJob({ streamId, job }: StreamJobEntry) {
233
- if(job.nextAttemptAt && job.nextAttemptAt > Date.now()) return false
318
+ if (job.nextAttemptAt && job.nextAttemptAt > Date.now()) return false
234
319
 
235
320
  const locked = await Fylo.queueRedis.acquireDocLock(job.collection, job.docId, job.jobId)
236
- if(!locked) return false
321
+ if (!locked) return false
237
322
 
238
323
  try {
239
324
  await Fylo.queueRedis.setJobStatus(job.jobId, 'processing', {
@@ -247,27 +332,36 @@ export default class Fylo {
247
332
  await Fylo.queueRedis.setDocStatus(job.collection, job.docId, 'committed', job.jobId)
248
333
  await Fylo.queueRedis.ackWriteJob(streamId)
249
334
  return true
250
-
251
- } catch(err) {
335
+ } catch (err) {
252
336
  const attempts = job.attempts + 1
253
337
  const message = err instanceof Error ? err.message : String(err)
254
338
 
255
- if(attempts >= Fylo.MAX_WRITE_ATTEMPTS) {
339
+ if (attempts >= Fylo.MAX_WRITE_ATTEMPTS) {
256
340
  await Fylo.queueRedis.setJobStatus(job.jobId, 'dead-letter', {
257
341
  error: message,
258
342
  attempts
259
343
  })
260
- await Fylo.queueRedis.setDocStatus(job.collection, job.docId, 'dead-letter', job.jobId)
261
- await Fylo.queueRedis.deadLetterWriteJob(streamId, {
262
- ...job,
263
- attempts,
264
- status: 'dead-letter',
265
- error: message
266
- }, message)
344
+ await Fylo.queueRedis.setDocStatus(
345
+ job.collection,
346
+ job.docId,
347
+ 'dead-letter',
348
+ job.jobId
349
+ )
350
+ await Fylo.queueRedis.deadLetterWriteJob(
351
+ streamId,
352
+ {
353
+ ...job,
354
+ attempts,
355
+ status: 'dead-letter',
356
+ error: message
357
+ },
358
+ message
359
+ )
267
360
  return false
268
361
  }
269
362
 
270
- const nextAttemptAt = Date.now() + (Fylo.WRITE_RETRY_BASE_MS * Math.max(1, 2 ** (attempts - 1)))
363
+ const nextAttemptAt =
364
+ Date.now() + Fylo.WRITE_RETRY_BASE_MS * Math.max(1, 2 ** (attempts - 1))
271
365
 
272
366
  await Fylo.queueRedis.setJobStatus(job.jobId, 'failed', {
273
367
  error: message,
@@ -276,7 +370,6 @@ export default class Fylo {
276
370
  })
277
371
  await Fylo.queueRedis.setDocStatus(job.collection, job.docId, 'failed', job.jobId)
278
372
  return false
279
-
280
373
  } finally {
281
374
  await Fylo.queueRedis.releaseDocLock(job.collection, job.docId, job.jobId)
282
375
  }
@@ -288,20 +381,24 @@ export default class Fylo {
288
381
  * @param url The URL of the data to import.
289
382
  * @param limit The maximum number of documents to import.
290
383
  */
291
- async importBulkData<T extends Record<string, any>>(collection: string, url: URL, limit?: number) {
292
-
384
+ async importBulkData<T extends Record<string, any>>(
385
+ collection: string,
386
+ url: URL,
387
+ limit?: number
388
+ ) {
293
389
  const res = await fetch(url)
294
390
 
295
- if(!res.headers.get('content-type')?.includes('application/json')) throw new Error('Response is not JSON')
391
+ if (!res.headers.get('content-type')?.includes('application/json'))
392
+ throw new Error('Response is not JSON')
296
393
 
297
394
  let count = 0
298
395
  let batchNum = 0
299
396
 
300
397
  const flush = async (batch: T[]) => {
398
+ if (!batch.length) return
301
399
 
302
- if(!batch.length) return
303
-
304
- const items = limit && count + batch.length > limit ? batch.slice(0, limit - count) : batch
400
+ const items =
401
+ limit && count + batch.length > limit ? batch.slice(0, limit - count) : batch
305
402
 
306
403
  batchNum++
307
404
 
@@ -309,13 +406,15 @@ export default class Fylo {
309
406
  await this.batchPutData(collection, items)
310
407
  count += items.length
311
408
 
312
- if(count % 10000 === 0) console.log("Count:", count)
409
+ if (count % 10000 === 0) console.log('Count:', count)
313
410
 
314
- if(Fylo.LOGGING) {
411
+ if (Fylo.LOGGING) {
315
412
  const bytes = JSON.stringify(items).length
316
413
  const elapsed = Date.now() - start
317
414
  const bytesPerSec = (bytes / (elapsed / 1000)).toFixed(2)
318
- console.log(`Batch ${batchNum} of ${bytes} bytes took ${elapsed === Infinity ? 'Infinity' : elapsed}ms (${bytesPerSec} bytes/sec)`)
415
+ console.log(
416
+ `Batch ${batchNum} of ${bytes} bytes took ${elapsed === Infinity ? 'Infinity' : elapsed}ms (${bytesPerSec} bytes/sec)`
417
+ )
319
418
  }
320
419
  }
321
420
 
@@ -332,11 +431,10 @@ export default class Fylo {
332
431
  let pending = new Uint8Array(0)
333
432
  let batch: T[] = []
334
433
 
335
- for await (const chunk of res.body as AsyncIterable<Uint8Array>) {
336
-
337
- if(isJsonArray === null) isJsonArray = chunk[0] === 0x5b
434
+ for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
435
+ if (isJsonArray === null) isJsonArray = chunk[0] === 0x5b
338
436
 
339
- if(isJsonArray) {
437
+ if (isJsonArray) {
340
438
  jsonArrayChunks.push(chunk)
341
439
  jsonArrayLength += chunk.length
342
440
  continue
@@ -351,40 +449,40 @@ export default class Fylo {
351
449
  const { values, read } = Bun.JSONL.parseChunk(merged)
352
450
  pending = merged.subarray(read)
353
451
 
354
- for(const item of values) {
452
+ for (const item of values) {
355
453
  batch.push(item as T)
356
- if(batch.length === Fylo.MAX_CPUS) {
454
+ if (batch.length === Fylo.MAX_CPUS) {
357
455
  await flush(batch)
358
456
  batch = []
359
- if(limit && count >= limit) return count
457
+ if (limit && count >= limit) return count
360
458
  }
361
459
  }
362
460
  }
363
461
 
364
- if(isJsonArray) {
365
-
462
+ if (isJsonArray) {
366
463
  // Reassemble buffered chunks into a single Uint8Array and parse as JSON.
367
464
  const body = new Uint8Array(jsonArrayLength)
368
465
  let offset = 0
369
- for(const c of jsonArrayChunks) { body.set(c, offset); offset += c.length }
466
+ for (const c of jsonArrayChunks) {
467
+ body.set(c, offset)
468
+ offset += c.length
469
+ }
370
470
 
371
471
  const data = JSON.parse(new TextDecoder().decode(body))
372
472
  const items: T[] = Array.isArray(data) ? data : [data]
373
473
 
374
- for(let i = 0; i < items.length; i += Fylo.MAX_CPUS) {
375
- if(limit && count >= limit) break
474
+ for (let i = 0; i < items.length; i += Fylo.MAX_CPUS) {
475
+ if (limit && count >= limit) break
376
476
  await flush(items.slice(i, i + Fylo.MAX_CPUS))
377
477
  }
378
-
379
478
  } else {
380
-
381
479
  // Flush the in-progress batch and any final line that had no trailing newline.
382
- if(pending.length > 0) {
480
+ if (pending.length > 0) {
383
481
  const { values } = Bun.JSONL.parseChunk(pending)
384
- for(const item of values) batch.push(item as T)
482
+ for (const item of values) batch.push(item as T)
385
483
  }
386
484
 
387
- if(batch.length > 0) await flush(batch)
485
+ if (batch.length > 0) await flush(batch)
388
486
  }
389
487
 
390
488
  return count
@@ -396,31 +494,42 @@ export default class Fylo {
396
494
  * @returns The current data exported from the collection.
397
495
  */
398
496
  static async *exportBulkData<T extends Record<string, any>>(collection: string) {
497
+ if (Fylo.defaultEngineKind() === 's3-files') {
498
+ yield* Fylo.defaultS3Files.exportBulkData<T>(collection)
499
+ return
500
+ }
399
501
 
400
502
  // Kick off the first S3 list immediately so there is no idle time at the start.
401
- let listPromise: Promise<Bun.S3ListObjectsResponse> | null = S3.list(collection, { delimiter: '/' })
402
-
403
- while(listPromise !== null) {
503
+ let listPromise: Promise<Bun.S3ListObjectsResponse> | null = S3.list(collection, {
504
+ delimiter: '/'
505
+ })
404
506
 
507
+ while (listPromise !== null) {
405
508
  const data: Bun.S3ListObjectsResponse = await listPromise
406
509
 
407
- if(!data.commonPrefixes?.length) break
510
+ if (!data.commonPrefixes?.length) break
408
511
 
409
512
  const ids = data.commonPrefixes
410
- .map(item => item.prefix!.split('/')[0]!)
411
- .filter(key => TTID.isTTID(key)) as _ttid[]
513
+ .map((item) => item.prefix!.split('/')[0]!)
514
+ .filter((key) => TTID.isTTID(key)) as _ttid[]
412
515
 
413
516
  // Start fetching the next page immediately — before awaiting doc reads —
414
517
  // so the S3 list round-trip overlaps with document reconstruction.
415
- listPromise = data.isTruncated && data.nextContinuationToken
416
- ? S3.list(collection, { delimiter: '/', continuationToken: data.nextContinuationToken })
417
- : null
418
-
419
- const results = await Promise.allSettled(ids.map(id => this.getDoc<T>(collection, id).once()))
420
-
421
- for(const result of results) {
422
- if(result.status === 'fulfilled') {
423
- for(const id in result.value) yield result.value[id]
518
+ listPromise =
519
+ data.isTruncated && data.nextContinuationToken
520
+ ? S3.list(collection, {
521
+ delimiter: '/',
522
+ continuationToken: data.nextContinuationToken
523
+ })
524
+ : null
525
+
526
+ const results = await Promise.allSettled(
527
+ ids.map((id) => this.getDoc<T>(collection, id).once())
528
+ )
529
+
530
+ for (const result of results) {
531
+ if (result.status === 'fulfilled') {
532
+ for (const id in result.value) yield result.value[id]
424
533
  }
425
534
  }
426
535
  }
@@ -433,30 +542,39 @@ export default class Fylo {
433
542
  * @param onlyId Whether to only return the ID of the document.
434
543
  * @returns The document or the ID of the document.
435
544
  */
436
- static getDoc<T extends Record<string, any>>(collection: string, _id: _ttid, onlyId: boolean = false) {
437
-
438
- return {
545
+ static getDoc<T extends Record<string, any>>(
546
+ collection: string,
547
+ _id: _ttid,
548
+ onlyId: boolean = false
549
+ ) {
550
+ if (Fylo.defaultEngineKind() === 's3-files') {
551
+ return Fylo.defaultS3Files.getDoc<T>(collection, _id, onlyId)
552
+ }
439
553
 
554
+ return {
440
555
  /**
441
556
  * Async iterator (listener) for the document.
442
557
  */
443
558
  async *[Symbol.asyncIterator]() {
444
-
445
559
  const doc = await this.once()
446
560
 
447
- if(Object.keys(doc).length > 0) yield doc
561
+ if (Object.keys(doc).length > 0) yield doc
448
562
 
449
563
  let finished = false
450
564
 
451
- const iter = Dir.searchDocs<T>(collection, `**/${_id.split('-')[0]}*`, {}, { listen: true, skip: true })
565
+ const iter = Dir.searchDocs<T>(
566
+ collection,
567
+ `**/${_id.split('-')[0]}*`,
568
+ {},
569
+ { listen: true, skip: true }
570
+ )
452
571
 
453
572
  do {
454
-
455
573
  const { value, done } = await iter.next({ count: 0 })
456
574
 
457
- if(value === undefined && !done) continue
575
+ if (value === undefined && !done) continue
458
576
 
459
- if(done) {
577
+ if (done) {
460
578
  finished = true
461
579
  break
462
580
  }
@@ -465,26 +583,23 @@ export default class Fylo {
465
583
 
466
584
  const keys = Object.keys(doc)
467
585
 
468
- if(onlyId && keys.length > 0) {
586
+ if (onlyId && keys.length > 0) {
469
587
  yield keys.shift()!
470
588
  continue
471
- }
472
- else if(keys.length > 0) {
589
+ } else if (keys.length > 0) {
473
590
  yield doc
474
591
  continue
475
592
  }
476
-
477
- } while(!finished)
593
+ } while (!finished)
478
594
  },
479
595
 
480
596
  /**
481
597
  * Gets the document once.
482
598
  */
483
599
  async once() {
484
-
485
600
  const items = await Walker.getDocData(collection, _id)
486
601
 
487
- if(items.length === 0) return {}
602
+ if (items.length === 0) return {}
488
603
 
489
604
  const data = await Dir.reconstructData(collection, items)
490
605
 
@@ -495,25 +610,28 @@ export default class Fylo {
495
610
  * Async iterator (listener) for the document's deletion.
496
611
  */
497
612
  async *onDelete() {
498
-
499
613
  let finished = false
500
614
 
501
- const iter = Dir.searchDocs<T>(collection, `**/${_id.split('-')[0]}*`, {}, { listen: true, skip: true }, true)
615
+ const iter = Dir.searchDocs<T>(
616
+ collection,
617
+ `**/${_id.split('-')[0]}*`,
618
+ {},
619
+ { listen: true, skip: true },
620
+ true
621
+ )
502
622
 
503
623
  do {
504
-
505
624
  const { value, done } = await iter.next({ count: 0 })
506
625
 
507
- if(value === undefined && !done) continue
626
+ if (value === undefined && !done) continue
508
627
 
509
- if(done) {
628
+ if (done) {
510
629
  finished = true
511
630
  break
512
631
  }
513
632
 
514
633
  yield value as _ttid
515
-
516
- } while(!finished)
634
+ } while (!finished)
517
635
  }
518
636
  }
519
637
  }
@@ -525,23 +643,23 @@ export default class Fylo {
525
643
  * @returns The IDs of the documents.
526
644
  */
527
645
  async batchPutData<T extends Record<string, any>>(collection: string, batch: Array<T>) {
528
-
529
646
  const batches: Array<Array<T>> = []
530
647
  const ids: _ttid[] = []
531
648
 
532
- if(batch.length > navigator.hardwareConcurrency) {
533
-
534
- for(let i = 0; i < batch.length; i += navigator.hardwareConcurrency) {
649
+ if (batch.length > navigator.hardwareConcurrency) {
650
+ for (let i = 0; i < batch.length; i += navigator.hardwareConcurrency) {
535
651
  batches.push(batch.slice(i, i + navigator.hardwareConcurrency))
536
652
  }
537
-
538
653
  } else batches.push(batch)
539
-
540
- for(const batch of batches) {
541
654
 
542
- const res = await Promise.allSettled(batch.map(data => this.putData(collection, data)))
655
+ for (const batch of batches) {
656
+ const res = await Promise.allSettled(
657
+ batch.map((data) => this.putData(collection, data))
658
+ )
543
659
 
544
- for(const _id of res.filter(item => item.status === 'fulfilled').map(item => item.value)) {
660
+ for (const _id of res
661
+ .filter((item) => item.status === 'fulfilled')
662
+ .map((item) => item.value)) {
545
663
  ids.push(_id)
546
664
  }
547
665
  }
@@ -549,7 +667,12 @@ export default class Fylo {
549
667
  return ids
550
668
  }
551
669
 
552
- async queuePutData<T extends Record<string, any>>(collection: string, data: Record<_ttid, T> | T): Promise<QueuedWriteResult> {
670
+ async queuePutData<T extends Record<string, any>>(
671
+ collection: string,
672
+ data: Record<_ttid, T> | T
673
+ ): Promise<QueuedWriteResult> {
674
+ if (this.engineKind === 's3-files')
675
+ throw new Error('queuePutData is not supported in s3-files engine')
553
676
 
554
677
  const { _id, doc } = await this.prepareInsert(collection, data)
555
678
  const job = WriteQueue.createInsertJob(collection, _id, doc)
@@ -568,10 +691,12 @@ export default class Fylo {
568
691
  newDoc: Record<_ttid, Partial<T>>,
569
692
  oldDoc: Record<_ttid, T> = {}
570
693
  ): Promise<QueuedWriteResult> {
694
+ if (this.engineKind === 's3-files')
695
+ throw new Error('queuePatchDoc is not supported in s3-files engine')
571
696
 
572
697
  const docId = Object.keys(newDoc).shift() as _ttid
573
698
 
574
- if(!docId) throw new Error("this document does not contain an TTID")
699
+ if (!docId) throw new Error('this document does not contain an TTID')
575
700
 
576
701
  const job = WriteQueue.createUpdateJob(collection, docId, { newDoc, oldDoc })
577
702
 
@@ -585,6 +710,8 @@ export default class Fylo {
585
710
  }
586
711
 
587
712
  async queueDelDoc(collection: string, _id: _ttid): Promise<QueuedWriteResult> {
713
+ if (this.engineKind === 's3-files')
714
+ throw new Error('queueDelDoc is not supported in s3-files engine')
588
715
 
589
716
  const job = WriteQueue.createDeleteJob(collection, _id)
590
717
 
@@ -603,8 +730,10 @@ export default class Fylo {
603
730
  * @param data The document to put.
604
731
  * @returns The ID of the document.
605
732
  */
606
- private static async uniqueTTID(existingId?: string): Promise<_ttid> {
607
-
733
+ private static async uniqueTTID(
734
+ existingId?: string,
735
+ claimInRedis: boolean = true
736
+ ): Promise<_ttid> {
608
737
  // Serialize TTID generation so concurrent callers (e.g. batchPutData)
609
738
  // never invoke TTID.generate() at the same sub-millisecond instant.
610
739
  let _id!: _ttid
@@ -612,118 +741,198 @@ export default class Fylo {
612
741
  Fylo.ttidLock = prev.then(async () => {
613
742
  _id = existingId ? TTID.generate(existingId) : TTID.generate()
614
743
  // Claim in Redis for cross-process uniqueness (no-op if Redis unavailable)
615
- if(!(await Dir.claimTTID(_id))) throw new Error('TTID collision — retry')
744
+ if (claimInRedis && !(await Dir.claimTTID(_id)))
745
+ throw new Error('TTID collision — retry')
616
746
  })
617
747
  await Fylo.ttidLock
618
748
 
619
749
  return _id
620
750
  }
621
751
 
622
- private async prepareInsert<T extends Record<string, any>>(collection: string, data: Record<_ttid, T> | T) {
623
-
752
+ private async prepareInsert<T extends Record<string, any>>(
753
+ collection: string,
754
+ data: Record<_ttid, T> | T
755
+ ) {
624
756
  await Fylo.loadEncryption(collection)
625
757
 
626
758
  const currId = Object.keys(data).shift()!
627
- const _id = TTID.isTTID(currId) ? await Fylo.uniqueTTID(currId) : await Fylo.uniqueTTID()
759
+ const claimInRedis = this.engineKind !== 's3-files'
760
+ const _id = TTID.isTTID(currId)
761
+ ? await Fylo.uniqueTTID(currId, claimInRedis)
762
+ : await Fylo.uniqueTTID(undefined, claimInRedis)
628
763
 
629
- let doc = TTID.isTTID(currId) ? Object.values(data).shift() as T : data as T
764
+ let doc = TTID.isTTID(currId) ? (Object.values(data).shift() as T) : (data as T)
630
765
 
631
- if(Fylo.STRICT) doc = await Gen.validateData(collection, doc) as T
766
+ if (Fylo.STRICT) doc = (await Gen.validateData(collection, doc)) as T
632
767
 
633
768
  return { _id, doc }
634
769
  }
635
770
 
636
- private async executePutDataDirect<T extends Record<string, any>>(collection: string, _id: _ttid, doc: T) {
771
+ private async executePutDataDirect<T extends Record<string, any>>(
772
+ collection: string,
773
+ _id: _ttid,
774
+ doc: T
775
+ ) {
776
+ if (this.engineKind === 's3-files') {
777
+ await this.assertS3FilesEngine().putDocument(collection, _id, doc)
778
+ return _id
779
+ }
637
780
 
638
781
  const keys = await Dir.extractKeys(collection, _id, doc)
639
782
 
640
- const results = await Promise.allSettled(keys.data.map((item, i) => this.dir.putKeys(collection, { dataKey: item, indexKey: keys.indexes[i] })))
783
+ const results = await Promise.allSettled(
784
+ keys.data.map((item, i) =>
785
+ this.dir.putKeys(collection, { dataKey: item, indexKey: keys.indexes[i] })
786
+ )
787
+ )
641
788
 
642
- if(results.some(res => res.status === "rejected")) {
789
+ if (results.some((res) => res.status === 'rejected')) {
643
790
  await this.dir.executeRollback()
644
791
  throw new Error(`Unable to write to ${collection} collection`)
645
792
  }
646
793
 
647
- if(Fylo.LOGGING) console.log(`Finished Writing ${_id}`)
794
+ if (Fylo.LOGGING) console.log(`Finished Writing ${_id}`)
648
795
 
649
796
  return _id
650
797
  }
651
798
 
652
- private async executePatchDocDirect<T extends Record<string, any>>(collection: string, newDoc: Record<_ttid, Partial<T>>, oldDoc: Record<_ttid, T> = {}) {
653
-
799
+ private async executePatchDocDirect<T extends Record<string, any>>(
800
+ collection: string,
801
+ newDoc: Record<_ttid, Partial<T>>,
802
+ oldDoc: Record<_ttid, T> = {}
803
+ ) {
654
804
  await Fylo.loadEncryption(collection)
655
805
 
656
806
  const _id = Object.keys(newDoc).shift() as _ttid
657
807
 
658
808
  let _newId = _id
659
809
 
660
- if(!_id) throw new Error("this document does not contain an TTID")
810
+ if (!_id) throw new Error('this document does not contain an TTID')
661
811
 
662
- const dataKeys = await Walker.getDocData(collection, _id)
812
+ if (this.engineKind === 's3-files') {
813
+ let existingDoc = oldDoc[_id]
814
+ if (!existingDoc) {
815
+ const existing = await this.assertS3FilesEngine().getDoc<T>(collection, _id).once()
816
+ existingDoc = existing[_id]
817
+ }
818
+ if (!existingDoc) return _id
819
+
820
+ const currData = { ...existingDoc, ...newDoc[_id] } as T
821
+ let docToWrite: T = currData
822
+ _newId = await Fylo.uniqueTTID(_id, false)
823
+ if (Fylo.STRICT) docToWrite = (await Gen.validateData(collection, currData)) as T
824
+ return await this.assertS3FilesEngine().patchDocument(
825
+ collection,
826
+ _id,
827
+ _newId,
828
+ docToWrite,
829
+ existingDoc
830
+ )
831
+ }
663
832
 
664
- if(dataKeys.length === 0) return _newId
833
+ const dataKeys = await Walker.getDocData(collection, _id)
665
834
 
666
- if(Object.keys(oldDoc).length === 0) {
835
+ if (dataKeys.length === 0) return _newId
667
836
 
837
+ if (Object.keys(oldDoc).length === 0) {
668
838
  const data = await Dir.reconstructData(collection, dataKeys)
669
839
 
670
840
  oldDoc = { [_id]: data } as Record<_ttid, T>
671
841
  }
672
842
 
673
- if(Object.keys(oldDoc).length === 0) return _newId
843
+ if (Object.keys(oldDoc).length === 0) return _newId
674
844
 
675
845
  const currData = { ...oldDoc[_id] }
676
846
 
677
- for(const field in newDoc[_id]) currData[field] = newDoc[_id][field]!
847
+ for (const field in newDoc[_id]) currData[field] = newDoc[_id][field]!
678
848
 
679
- _newId = await Fylo.uniqueTTID(_id)
849
+ _newId = await Fylo.uniqueTTID(_id, this.engineKind === 'legacy-s3')
680
850
 
681
851
  let docToWrite: T = currData as T
682
852
 
683
- if(Fylo.STRICT) docToWrite = await Gen.validateData(collection, currData) as T
853
+ if (Fylo.STRICT) docToWrite = (await Gen.validateData(collection, currData)) as T
684
854
 
685
855
  const newKeys = await Dir.extractKeys(collection, _newId, docToWrite)
686
856
 
687
857
  const [deleteResults, putResults] = await Promise.all([
688
- Promise.allSettled(dataKeys.map(key => this.dir.deleteKeys(collection, key))),
689
- Promise.allSettled(newKeys.data.map((item, i) => this.dir.putKeys(collection, { dataKey: item, indexKey: newKeys.indexes[i] })))
858
+ Promise.allSettled(dataKeys.map((key) => this.dir.deleteKeys(collection, key))),
859
+ Promise.allSettled(
860
+ newKeys.data.map((item, i) =>
861
+ this.dir.putKeys(collection, { dataKey: item, indexKey: newKeys.indexes[i] })
862
+ )
863
+ )
690
864
  ])
691
865
 
692
- if(deleteResults.some(r => r.status === 'rejected') || putResults.some(r => r.status === 'rejected')) {
866
+ if (
867
+ deleteResults.some((r) => r.status === 'rejected') ||
868
+ putResults.some((r) => r.status === 'rejected')
869
+ ) {
693
870
  await this.dir.executeRollback()
694
871
  throw new Error(`Unable to update ${collection} collection`)
695
872
  }
696
873
 
697
- if(Fylo.LOGGING) console.log(`Finished Updating ${_id} to ${_newId}`)
874
+ if (Fylo.LOGGING) console.log(`Finished Updating ${_id} to ${_newId}`)
698
875
 
699
876
  return _newId
700
877
  }
701
878
 
702
879
  private async executeDelDocDirect(collection: string, _id: _ttid) {
880
+ if (this.engineKind === 's3-files') {
881
+ await this.assertS3FilesEngine().deleteDocument(collection, _id)
882
+ return
883
+ }
703
884
 
704
885
  const keys = await Walker.getDocData(collection, _id)
705
886
 
706
- const results = await Promise.allSettled(keys.map(key => this.dir.deleteKeys(collection, key)))
887
+ const results = await Promise.allSettled(
888
+ keys.map((key) => this.dir.deleteKeys(collection, key))
889
+ )
707
890
 
708
- if(results.some(res => res.status === "rejected")) {
891
+ if (results.some((res) => res.status === 'rejected')) {
709
892
  await this.dir.executeRollback()
710
893
  throw new Error(`Unable to delete from ${collection} collection`)
711
894
  }
712
895
 
713
- if(Fylo.LOGGING) console.log(`Finished Deleting ${_id}`)
896
+ if (Fylo.LOGGING) console.log(`Finished Deleting ${_id}`)
714
897
  }
715
898
 
716
899
  async putData<T extends Record<string, any>>(collection: string, data: T): Promise<_ttid>
717
- async putData<T extends Record<string, any>>(collection: string, data: Record<_ttid, T>): Promise<_ttid>
718
- async putData<T extends Record<string, any>>(collection: string, data: T, options: { wait?: true, timeoutMs?: number }): Promise<_ttid>
719
- async putData<T extends Record<string, any>>(collection: string, data: Record<_ttid, T>, options: { wait?: true, timeoutMs?: number }): Promise<_ttid>
720
- async putData<T extends Record<string, any>>(collection: string, data: T, options: { wait: false, timeoutMs?: number }): Promise<QueuedWriteResult>
721
- async putData<T extends Record<string, any>>(collection: string, data: Record<_ttid, T>, options: { wait: false, timeoutMs?: number }): Promise<QueuedWriteResult>
900
+ async putData<T extends Record<string, any>>(
901
+ collection: string,
902
+ data: Record<_ttid, T>
903
+ ): Promise<_ttid>
904
+ async putData<T extends Record<string, any>>(
905
+ collection: string,
906
+ data: T,
907
+ options: { wait?: true; timeoutMs?: number }
908
+ ): Promise<_ttid>
909
+ async putData<T extends Record<string, any>>(
910
+ collection: string,
911
+ data: Record<_ttid, T>,
912
+ options: { wait?: true; timeoutMs?: number }
913
+ ): Promise<_ttid>
914
+ async putData<T extends Record<string, any>>(
915
+ collection: string,
916
+ data: T,
917
+ options: { wait: false; timeoutMs?: number }
918
+ ): Promise<QueuedWriteResult>
919
+ async putData<T extends Record<string, any>>(
920
+ collection: string,
921
+ data: Record<_ttid, T>,
922
+ options: { wait: false; timeoutMs?: number }
923
+ ): Promise<QueuedWriteResult>
722
924
  async putData<T extends Record<string, any>>(
723
925
  collection: string,
724
926
  data: Record<_ttid, T> | T,
725
- options: { wait?: boolean, timeoutMs?: number } = {}
927
+ options: { wait?: boolean; timeoutMs?: number } = {}
726
928
  ): Promise<_ttid | QueuedWriteResult> {
929
+ if (this.engineKind === 's3-files') {
930
+ if (options.wait === false)
931
+ throw new Error('wait:false is not supported in s3-files engine')
932
+ const { _id, doc } = await this.prepareInsert(collection, data)
933
+ await this.executePutDataDirect(collection, _id, doc)
934
+ return _id
935
+ }
727
936
 
728
937
  const queued = await this.queuePutData(collection, data)
729
938
 
@@ -731,8 +940,7 @@ export default class Fylo {
731
940
  }
732
941
 
733
942
  async executeQueuedWrite(job: WriteJob) {
734
-
735
- switch(job.operation) {
943
+ switch (job.operation) {
736
944
  case 'insert':
737
945
  await Fylo.loadEncryption(job.collection)
738
946
  return await this.executePutDataDirect(job.collection, job.docId, job.payload)
@@ -756,15 +964,35 @@ export default class Fylo {
756
964
  * @param oldDoc The old document data.
757
965
  * @returns The number of documents patched.
758
966
  */
759
- async patchDoc<T extends Record<string, any>>(collection: string, newDoc: Record<_ttid, Partial<T>>, oldDoc?: Record<_ttid, T>): Promise<_ttid>
760
- async patchDoc<T extends Record<string, any>>(collection: string, newDoc: Record<_ttid, Partial<T>>, oldDoc: Record<_ttid, T> | undefined, options: { wait?: true, timeoutMs?: number }): Promise<_ttid>
761
- async patchDoc<T extends Record<string, any>>(collection: string, newDoc: Record<_ttid, Partial<T>>, oldDoc: Record<_ttid, T> | undefined, options: { wait: false, timeoutMs?: number }): Promise<QueuedWriteResult>
967
+ async patchDoc<T extends Record<string, any>>(
968
+ collection: string,
969
+ newDoc: Record<_ttid, Partial<T>>,
970
+ oldDoc?: Record<_ttid, T>
971
+ ): Promise<_ttid>
972
+ async patchDoc<T extends Record<string, any>>(
973
+ collection: string,
974
+ newDoc: Record<_ttid, Partial<T>>,
975
+ oldDoc: Record<_ttid, T> | undefined,
976
+ options: { wait?: true; timeoutMs?: number }
977
+ ): Promise<_ttid>
978
+ async patchDoc<T extends Record<string, any>>(
979
+ collection: string,
980
+ newDoc: Record<_ttid, Partial<T>>,
981
+ oldDoc: Record<_ttid, T> | undefined,
982
+ options: { wait: false; timeoutMs?: number }
983
+ ): Promise<QueuedWriteResult>
762
984
  async patchDoc<T extends Record<string, any>>(
763
985
  collection: string,
764
986
  newDoc: Record<_ttid, Partial<T>>,
765
987
  oldDoc: Record<_ttid, T> = {},
766
- options: { wait?: boolean, timeoutMs?: number } = {}
988
+ options: { wait?: boolean; timeoutMs?: number } = {}
767
989
  ): Promise<_ttid | QueuedWriteResult> {
990
+ if (this.engineKind === 's3-files') {
991
+ if (options.wait === false)
992
+ throw new Error('wait:false is not supported in s3-files engine')
993
+ const _id = await this.executePatchDocDirect(collection, newDoc, oldDoc)
994
+ return _id
995
+ }
768
996
  const queued = await this.queuePatchDoc(collection, newDoc, oldDoc)
769
997
 
770
998
  return await this.runQueuedJob(queued, options, async () => {
@@ -779,61 +1007,79 @@ export default class Fylo {
779
1007
  * @param updateSchema The update schema.
780
1008
  * @returns The number of documents patched.
781
1009
  */
782
- async patchDocs<T extends Record<string, any>>(collection: string, updateSchema: _storeUpdate<T>) {
783
-
1010
+ async patchDocs<T extends Record<string, any>>(
1011
+ collection: string,
1012
+ updateSchema: _storeUpdate<T>
1013
+ ) {
784
1014
  await Fylo.loadEncryption(collection)
785
-
786
- const processDoc = (doc: Record<_ttid, T>, updateSchema: _storeUpdate<T>) => {
787
1015
 
788
- for(const _id in doc)
1016
+ const processDoc = (doc: Record<_ttid, T>, updateSchema: _storeUpdate<T>) => {
1017
+ for (const _id in doc)
789
1018
  return this.patchDoc(collection, { [_id]: updateSchema.$set }, doc)
790
1019
 
791
1020
  return
792
1021
  }
793
1022
 
794
1023
  let count = 0
795
-
1024
+
796
1025
  const promises: Promise<_ttid>[] = []
797
1026
 
798
- let finished = false
1027
+ if (this.engineKind === 's3-files') {
1028
+ for await (const value of this.findDocs<T>(collection, updateSchema.$where).collect()) {
1029
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
1030
+ const promise = processDoc(value as Record<_ttid, T>, updateSchema)
1031
+ if (promise) {
1032
+ promises.push(promise)
1033
+ count++
1034
+ }
1035
+ }
1036
+ }
799
1037
 
800
- const exprs = await Query.getExprs(collection, updateSchema.$where ?? {})
1038
+ await Promise.all(promises)
1039
+ return count
1040
+ }
801
1041
 
802
- if(exprs.length === 1 && exprs[0] === `**/*`) {
1042
+ let finished = false
803
1043
 
804
- for(const doc of await Fylo.allDocs<T>(collection, updateSchema.$where)) {
1044
+ const exprs = await Query.getExprs(collection, updateSchema.$where ?? {})
805
1045
 
1046
+ if (exprs.length === 1 && exprs[0] === `**/*`) {
1047
+ for (const doc of await Fylo.allDocs<T>(collection, updateSchema.$where)) {
806
1048
  const promise = processDoc(doc, updateSchema)
807
1049
 
808
- if(promise) {
1050
+ if (promise) {
809
1051
  promises.push(promise)
810
1052
  count++
811
1053
  }
812
1054
  }
813
-
814
1055
  } else {
815
-
816
- const iter = Dir.searchDocs<T>(collection, exprs, { updated: updateSchema?.$where?.$updated, created: updateSchema?.$where?.$created }, { listen: false, skip: false })
1056
+ const iter = Dir.searchDocs<T>(
1057
+ collection,
1058
+ exprs,
1059
+ {
1060
+ updated: updateSchema?.$where?.$updated,
1061
+ created: updateSchema?.$where?.$created
1062
+ },
1063
+ { listen: false, skip: false }
1064
+ )
817
1065
 
818
1066
  do {
819
-
820
1067
  const { value, done } = await iter.next({ count })
821
1068
 
822
- if(value === undefined && !done) continue
1069
+ if (value === undefined && !done) continue
823
1070
 
824
- if(done) {
1071
+ if (done) {
825
1072
  finished = true
826
1073
  break
827
1074
  }
828
1075
 
829
1076
  const promise = processDoc(value as Record<_ttid, T>, updateSchema)
830
1077
 
831
- if(promise) {
1078
+ if (promise) {
832
1079
  promises.push(promise)
833
1080
  count++
834
1081
  }
835
-
836
- } while(!finished)
1082
+ } while (!finished)
837
1083
  }
838
1084
 
839
1085
  await Promise.all(promises)
@@ -848,13 +1094,27 @@ export default class Fylo {
848
1094
  * @returns The number of documents deleted.
849
1095
  */
850
1096
  async delDoc(collection: string, _id: _ttid): Promise<void>
851
- async delDoc(collection: string, _id: _ttid, options: { wait?: true, timeoutMs?: number }): Promise<void>
852
- async delDoc(collection: string, _id: _ttid, options: { wait: false, timeoutMs?: number }): Promise<QueuedWriteResult>
853
1097
  async delDoc(
854
1098
  collection: string,
855
1099
  _id: _ttid,
856
- options: { wait?: boolean, timeoutMs?: number } = {}
1100
+ options: { wait?: true; timeoutMs?: number }
1101
+ ): Promise<void>
1102
+ async delDoc(
1103
+ collection: string,
1104
+ _id: _ttid,
1105
+ options: { wait: false; timeoutMs?: number }
1106
+ ): Promise<QueuedWriteResult>
1107
+ async delDoc(
1108
+ collection: string,
1109
+ _id: _ttid,
1110
+ options: { wait?: boolean; timeoutMs?: number } = {}
857
1111
  ): Promise<void | QueuedWriteResult> {
1112
+ if (this.engineKind === 's3-files') {
1113
+ if (options.wait === false)
1114
+ throw new Error('wait:false is not supported in s3-files engine')
1115
+ await this.executeDelDocDirect(collection, _id)
1116
+ return
1117
+ }
858
1118
  const queued = await this.queueDelDoc(collection, _id)
859
1119
 
860
1120
  await this.runQueuedJob(queued, options, async () => undefined)
@@ -866,15 +1126,15 @@ export default class Fylo {
866
1126
  * @param deleteSchema The delete schema.
867
1127
  * @returns The number of documents deleted.
868
1128
  */
869
- async delDocs<T extends Record<string, any>>(collection: string, deleteSchema?: _storeDelete<T>) {
870
-
1129
+ async delDocs<T extends Record<string, any>>(
1130
+ collection: string,
1131
+ deleteSchema?: _storeDelete<T>
1132
+ ) {
871
1133
  await Fylo.loadEncryption(collection)
872
-
873
- const processDoc = (doc: Record<_ttid, T>) => {
874
-
875
- for(const _id in doc) {
876
1134
 
877
- if(TTID.isTTID(_id)) {
1135
+ const processDoc = (doc: Record<_ttid, T>) => {
1136
+ for (const _id in doc) {
1137
+ if (TTID.isTTID(_id)) {
878
1138
  return this.delDoc(collection, _id)
879
1139
  }
880
1140
  }
@@ -886,45 +1146,59 @@ export default class Fylo {
886
1146
 
887
1147
  const promises: Promise<void>[] = []
888
1148
 
889
- let finished = false
1149
+ if (this.engineKind === 's3-files') {
1150
+ for await (const value of this.findDocs<T>(collection, deleteSchema).collect()) {
1151
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
1152
+ const promise = processDoc(value as Record<_ttid, T>)
1153
+ if (promise) {
1154
+ promises.push(promise)
1155
+ count++
1156
+ }
1157
+ }
1158
+ }
890
1159
 
891
- const exprs = await Query.getExprs(collection, deleteSchema ?? {})
1160
+ await Promise.all(promises)
1161
+ return count
1162
+ }
892
1163
 
893
- if(exprs.length === 1 && exprs[0] === `**/*`) {
1164
+ let finished = false
894
1165
 
895
- for(const doc of await Fylo.allDocs<T>(collection, deleteSchema)) {
1166
+ const exprs = await Query.getExprs(collection, deleteSchema ?? {})
896
1167
 
1168
+ if (exprs.length === 1 && exprs[0] === `**/*`) {
1169
+ for (const doc of await Fylo.allDocs<T>(collection, deleteSchema)) {
897
1170
  const promise = processDoc(doc)
898
1171
 
899
- if(promise) {
1172
+ if (promise) {
900
1173
  promises.push(promise)
901
1174
  count++
902
1175
  }
903
1176
  }
904
-
905
1177
  } else {
906
-
907
- const iter = Dir.searchDocs<T>(collection, exprs, { updated: deleteSchema?.$updated, created: deleteSchema?.$created }, { listen: false, skip: false })
1178
+ const iter = Dir.searchDocs<T>(
1179
+ collection,
1180
+ exprs,
1181
+ { updated: deleteSchema?.$updated, created: deleteSchema?.$created },
1182
+ { listen: false, skip: false }
1183
+ )
908
1184
 
909
1185
  do {
910
-
911
1186
  const { value, done } = await iter.next({ count })
912
1187
 
913
- if(value === undefined && !done) continue
1188
+ if (value === undefined && !done) continue
914
1189
 
915
- if(done) {
1190
+ if (done) {
916
1191
  finished = true
917
1192
  break
918
1193
  }
919
1194
 
920
1195
  const promise = processDoc(value as Record<_ttid, T>)
921
1196
 
922
- if(promise) {
1197
+ if (promise) {
923
1198
  promises.push(promise)
924
1199
  count++
925
1200
  }
926
-
927
- } while(!finished)
1201
+ } while (!finished)
928
1202
  }
929
1203
 
930
1204
  await Promise.all(promises)
@@ -933,18 +1207,19 @@ export default class Fylo {
933
1207
  }
934
1208
 
935
1209
  private static selectValues<T extends Record<string, any>>(selection: Array<keyof T>, data: T) {
936
-
937
- for(const field in data) {
938
- if(!selection.includes(field as keyof T)) delete data[field]
1210
+ for (const field in data) {
1211
+ if (!selection.includes(field as keyof T)) delete data[field]
939
1212
  }
940
1213
 
941
1214
  return data
942
1215
  }
943
1216
 
944
- private static renameFields<T extends Record<string, any>>(rename: Record<keyof T, string>, data: T) {
945
-
946
- for(const field in data) {
947
- if(rename[field]) {
1217
+ private static renameFields<T extends Record<string, any>>(
1218
+ rename: Record<keyof T, string>,
1219
+ data: T
1220
+ ) {
1221
+ for (const field in data) {
1222
+ if (rename[field]) {
948
1223
  data[rename[field]] = data[field]
949
1224
  delete data[field]
950
1225
  }
@@ -958,50 +1233,54 @@ export default class Fylo {
958
1233
  * @param join The join schema.
959
1234
  * @returns The joined documents.
960
1235
  */
961
- static async joinDocs<T extends Record<string, any>, U extends Record<string, any>>(join: _join<T, U>) {
962
-
963
- const docs: Record<`${_ttid}, ${_ttid}`, T | U | T & U | Partial<T> & Partial<U>> = {}
1236
+ static async joinDocs<T extends Record<string, any>, U extends Record<string, any>>(
1237
+ join: _join<T, U>
1238
+ ) {
1239
+ if (Fylo.defaultEngineKind() === 's3-files') {
1240
+ return await Fylo.defaultS3Files.joinDocs(join)
1241
+ }
1242
+
1243
+ const docs: Record<`${_ttid}, ${_ttid}`, T | U | (T & U) | (Partial<T> & Partial<U>)> = {}
964
1244
 
965
- const compareFields = async (leftField: keyof T, rightField: keyof U, compare: (leftVal: string, rightVal: string) => boolean) => {
1245
+ const compareFields = async (
1246
+ leftField: keyof T,
1247
+ rightField: keyof U,
1248
+ compare: (leftVal: string, rightVal: string) => boolean
1249
+ ) => {
1250
+ if (join.$leftCollection === join.$rightCollection)
1251
+ throw new Error('Left and right collections cannot be the same')
966
1252
 
967
- if(join.$leftCollection === join.$rightCollection) throw new Error("Left and right collections cannot be the same")
968
-
969
1253
  let leftToken: string | undefined
970
1254
  const leftFieldIndexes: string[] = []
971
1255
 
972
1256
  do {
973
-
974
1257
  const leftData = await S3.list(join.$leftCollection, {
975
1258
  prefix: String(leftField)
976
1259
  })
977
-
978
- if(!leftData.contents) break
979
1260
 
980
- leftFieldIndexes.push(...leftData.contents!.map(content => content.key!))
1261
+ if (!leftData.contents) break
1262
+
1263
+ leftFieldIndexes.push(...leftData.contents!.map((content) => content.key!))
981
1264
 
982
1265
  leftToken = leftData.nextContinuationToken
1266
+ } while (leftToken !== undefined)
983
1267
 
984
- } while(leftToken !== undefined)
985
-
986
1268
  let rightToken: string | undefined
987
1269
  const rightFieldIndexes: string[] = []
988
1270
 
989
- do {
990
-
1271
+ do {
991
1272
  const rightData = await S3.list(join.$rightCollection, {
992
1273
  prefix: String(rightField)
993
1274
  })
994
-
995
- if(!rightData.contents) break
996
1275
 
997
- rightFieldIndexes.push(...rightData.contents!.map(content => content.key!))
1276
+ if (!rightData.contents) break
998
1277
 
999
- rightToken = rightData.nextContinuationToken
1278
+ rightFieldIndexes.push(...rightData.contents!.map((content) => content.key!))
1000
1279
 
1001
- } while(rightToken !== undefined)
1002
-
1003
- for(const leftIdx of leftFieldIndexes) {
1280
+ rightToken = rightData.nextContinuationToken
1281
+ } while (rightToken !== undefined)
1004
1282
 
1283
+ for (const leftIdx of leftFieldIndexes) {
1005
1284
  const leftSegs = leftIdx.split('/')
1006
1285
  const left_id = leftSegs.pop()! as _ttid
1007
1286
  const leftVal = leftSegs.pop()!
@@ -1010,140 +1289,219 @@ export default class Fylo {
1010
1289
 
1011
1290
  const allVals = new Set<string>()
1012
1291
 
1013
- for(const rightIdx of rightFieldIndexes) {
1014
-
1292
+ for (const rightIdx of rightFieldIndexes) {
1015
1293
  const rightSegs = rightIdx.split('/')
1016
1294
  const right_id = rightSegs.pop()! as _ttid
1017
1295
  const rightVal = rightSegs.pop()!
1018
1296
 
1019
1297
  const rightCollection = join.$rightCollection
1020
1298
 
1021
- if(compare(rightVal, leftVal) && !allVals.has(rightVal)) {
1022
-
1299
+ if (compare(rightVal, leftVal) && !allVals.has(rightVal)) {
1023
1300
  allVals.add(rightVal)
1024
1301
 
1025
- switch(join.$mode) {
1026
- case "inner":
1027
- docs[`${left_id}, ${right_id}`] = { [leftField]: Dir.parseValue(leftVal), [rightField]: Dir.parseValue(rightVal) } as Partial<T> & Partial<U>
1302
+ switch (join.$mode) {
1303
+ case 'inner':
1304
+ docs[`${left_id}, ${right_id}`] = {
1305
+ [leftField]: Dir.parseValue(leftVal),
1306
+ [rightField]: Dir.parseValue(rightVal)
1307
+ } as Partial<T> & Partial<U>
1028
1308
  break
1029
- case "left":
1309
+ case 'left':
1030
1310
  const leftDoc = await this.getDoc<T>(leftCollection, left_id).once()
1031
- if(Object.keys(leftDoc).length > 0) {
1311
+ if (Object.keys(leftDoc).length > 0) {
1032
1312
  let leftData = leftDoc[left_id]
1033
- if(join.$select) leftData = this.selectValues<T>(join.$select as Array<keyof T>, leftData)
1034
- if(join.$rename) leftData = this.renameFields<T>(join.$rename, leftData)
1313
+ if (join.$select)
1314
+ leftData = this.selectValues<T>(
1315
+ join.$select as Array<keyof T>,
1316
+ leftData
1317
+ )
1318
+ if (join.$rename)
1319
+ leftData = this.renameFields<T>(join.$rename, leftData)
1035
1320
  docs[`${left_id}, ${right_id}`] = leftData as T
1036
1321
  }
1037
1322
  break
1038
- case "right":
1039
- const rightDoc = await this.getDoc<U>(rightCollection, right_id).once()
1040
- if(Object.keys(rightDoc).length > 0) {
1323
+ case 'right':
1324
+ const rightDoc = await this.getDoc<U>(
1325
+ rightCollection,
1326
+ right_id
1327
+ ).once()
1328
+ if (Object.keys(rightDoc).length > 0) {
1041
1329
  let rightData = rightDoc[right_id]
1042
- if(join.$select) rightData = this.selectValues<U>(join.$select as Array<keyof U>, rightData)
1043
- if(join.$rename) rightData = this.renameFields<U>(join.$rename, rightData)
1330
+ if (join.$select)
1331
+ rightData = this.selectValues<U>(
1332
+ join.$select as Array<keyof U>,
1333
+ rightData
1334
+ )
1335
+ if (join.$rename)
1336
+ rightData = this.renameFields<U>(join.$rename, rightData)
1044
1337
  docs[`${left_id}, ${right_id}`] = rightData as U
1045
1338
  }
1046
1339
  break
1047
- case "outer":
1048
-
1340
+ case 'outer':
1049
1341
  let leftFullData: T = {} as T
1050
1342
  let rightFullData: U = {} as U
1051
1343
 
1052
- const leftFullDoc = await this.getDoc<T>(leftCollection, left_id).once()
1344
+ const leftFullDoc = await this.getDoc<T>(
1345
+ leftCollection,
1346
+ left_id
1347
+ ).once()
1053
1348
 
1054
- if(Object.keys(leftFullDoc).length > 0) {
1349
+ if (Object.keys(leftFullDoc).length > 0) {
1055
1350
  let leftData = leftFullDoc[left_id]
1056
- if(join.$select) leftData = this.selectValues<T>(join.$select as Array<keyof T>, leftData)
1057
- if(join.$rename) leftData = this.renameFields<T>(join.$rename, leftData)
1351
+ if (join.$select)
1352
+ leftData = this.selectValues<T>(
1353
+ join.$select as Array<keyof T>,
1354
+ leftData
1355
+ )
1356
+ if (join.$rename)
1357
+ leftData = this.renameFields<T>(join.$rename, leftData)
1058
1358
  leftFullData = { ...leftData, ...leftFullData } as T
1059
1359
  }
1060
1360
 
1061
- const rightFullDoc = await this.getDoc<U>(rightCollection, right_id).once()
1361
+ const rightFullDoc = await this.getDoc<U>(
1362
+ rightCollection,
1363
+ right_id
1364
+ ).once()
1062
1365
 
1063
- if(Object.keys(rightFullDoc).length > 0) {
1366
+ if (Object.keys(rightFullDoc).length > 0) {
1064
1367
  let rightData = rightFullDoc[right_id]
1065
- if(join.$select) rightData = this.selectValues<U>(join.$select as Array<keyof U>, rightData)
1066
- if(join.$rename) rightData = this.renameFields<U>(join.$rename, rightData)
1368
+ if (join.$select)
1369
+ rightData = this.selectValues<U>(
1370
+ join.$select as Array<keyof U>,
1371
+ rightData
1372
+ )
1373
+ if (join.$rename)
1374
+ rightData = this.renameFields<U>(join.$rename, rightData)
1067
1375
  rightFullData = { ...rightData, ...rightFullData } as U
1068
1376
  }
1069
1377
 
1070
- docs[`${left_id}, ${right_id}`] = { ...leftFullData, ...rightFullData } as T & U
1378
+ docs[`${left_id}, ${right_id}`] = {
1379
+ ...leftFullData,
1380
+ ...rightFullData
1381
+ } as T & U
1071
1382
  break
1072
1383
  }
1073
1384
 
1074
- if(join.$limit && Object.keys(docs).length === join.$limit) break
1385
+ if (join.$limit && Object.keys(docs).length === join.$limit) break
1075
1386
  }
1076
1387
  }
1077
1388
 
1078
- if(join.$limit && Object.keys(docs).length === join.$limit) break
1389
+ if (join.$limit && Object.keys(docs).length === join.$limit) break
1079
1390
  }
1080
1391
  }
1081
1392
 
1082
- for(const field in join.$on) {
1393
+ for (const field in join.$on) {
1394
+ if (join.$on[field]!.$eq)
1395
+ await compareFields(
1396
+ field,
1397
+ join.$on[field]!.$eq,
1398
+ (leftVal, rightVal) => leftVal === rightVal
1399
+ )
1400
+
1401
+ if (join.$on[field]!.$ne)
1402
+ await compareFields(
1403
+ field,
1404
+ join.$on[field]!.$ne,
1405
+ (leftVal, rightVal) => leftVal !== rightVal
1406
+ )
1083
1407
 
1084
- if(join.$on[field]!.$eq) await compareFields(field, join.$on[field]!.$eq, (leftVal, rightVal) => leftVal === rightVal)
1408
+ if (join.$on[field]!.$gt)
1409
+ await compareFields(
1410
+ field,
1411
+ join.$on[field]!.$gt,
1412
+ (leftVal, rightVal) => Number(leftVal) > Number(rightVal)
1413
+ )
1085
1414
 
1086
- if(join.$on[field]!.$ne) await compareFields(field, join.$on[field]!.$ne, (leftVal, rightVal) => leftVal !== rightVal)
1087
-
1088
- if(join.$on[field]!.$gt) await compareFields(field, join.$on[field]!.$gt, (leftVal, rightVal) => Number(leftVal) > Number(rightVal))
1089
-
1090
- if(join.$on[field]!.$lt) await compareFields(field, join.$on[field]!.$lt, (leftVal, rightVal) => Number(leftVal) < Number(rightVal))
1091
-
1092
- if(join.$on[field]!.$gte) await compareFields(field, join.$on[field]!.$gte, (leftVal, rightVal) => Number(leftVal) >= Number(rightVal))
1093
-
1094
- if(join.$on[field]!.$lte) await compareFields(field, join.$on[field]!.$lte, (leftVal, rightVal) => Number(leftVal) <= Number(rightVal))
1095
- }
1415
+ if (join.$on[field]!.$lt)
1416
+ await compareFields(
1417
+ field,
1418
+ join.$on[field]!.$lt,
1419
+ (leftVal, rightVal) => Number(leftVal) < Number(rightVal)
1420
+ )
1096
1421
 
1097
- if(join.$groupby) {
1422
+ if (join.$on[field]!.$gte)
1423
+ await compareFields(
1424
+ field,
1425
+ join.$on[field]!.$gte,
1426
+ (leftVal, rightVal) => Number(leftVal) >= Number(rightVal)
1427
+ )
1098
1428
 
1099
- const groupedDocs: Record<string, Record<string, Partial<T | U>>> = {} as Record<string, Record<string, Partial<T | U>>>
1429
+ if (join.$on[field]!.$lte)
1430
+ await compareFields(
1431
+ field,
1432
+ join.$on[field]!.$lte,
1433
+ (leftVal, rightVal) => Number(leftVal) <= Number(rightVal)
1434
+ )
1435
+ }
1100
1436
 
1101
- for(const ids in docs) {
1437
+ if (join.$groupby) {
1438
+ const groupedDocs: Record<string, Record<string, Partial<T | U>>> = {} as Record<
1439
+ string,
1440
+ Record<string, Partial<T | U>>
1441
+ >
1102
1442
 
1443
+ for (const ids in docs) {
1103
1444
  const data = docs[ids as `${_ttid}, ${_ttid}`]
1104
1445
 
1105
1446
  // @ts-expect-error - Object.groupBy not yet in TS lib types
1106
- const grouping = Object.groupBy([data], elem => elem[join.$groupby!])
1107
-
1108
- for(const group in grouping) {
1447
+ const grouping = Object.groupBy([data], (elem) => elem[join.$groupby!])
1109
1448
 
1110
- if(groupedDocs[group]) groupedDocs[group][ids] = data
1449
+ for (const group in grouping) {
1450
+ if (groupedDocs[group]) groupedDocs[group][ids] = data
1111
1451
  else groupedDocs[group] = { [ids]: data }
1112
1452
  }
1113
1453
  }
1114
1454
 
1115
- if(join.$onlyIds) {
1116
-
1455
+ if (join.$onlyIds) {
1117
1456
  const groupedIds: Record<string, _ttid[]> = {}
1118
1457
 
1119
- for(const group in groupedDocs) {
1458
+ for (const group in groupedDocs) {
1120
1459
  const doc = groupedDocs[group]
1121
1460
  groupedIds[group] = Object.keys(doc).flat()
1122
1461
  }
1123
1462
 
1124
1463
  return groupedIds
1125
1464
  }
1126
-
1465
+
1127
1466
  return groupedDocs
1128
1467
  }
1129
1468
 
1130
- if(join.$onlyIds) return Array.from(new Set(Object.keys(docs).flat()))
1469
+ if (join.$onlyIds) return Array.from(new Set(Object.keys(docs).flat()))
1131
1470
 
1132
1471
  return docs
1133
1472
  }
1134
1473
 
1135
- private static async allDocs<T extends Record<string, any>>(collection: string, query?: _storeQuery<T>) {
1474
+ private static async allDocs<T extends Record<string, any>>(
1475
+ collection: string,
1476
+ query?: _storeQuery<T>
1477
+ ) {
1478
+ if (Fylo.defaultEngineKind() === 's3-files') {
1479
+ const results: Array<Record<_ttid, T>> = []
1480
+ for await (const data of Fylo.defaultS3Files.findDocs<T>(collection, query).collect()) {
1481
+ if (typeof data === 'object' && !Array.isArray(data))
1482
+ results.push(data as Record<_ttid, T>)
1483
+ }
1484
+ return results
1485
+ }
1136
1486
 
1137
1487
  const res = await S3.list(collection, {
1138
1488
  delimiter: '/',
1139
1489
  maxKeys: !query || !query.$limit ? undefined : query.$limit
1140
1490
  })
1141
-
1142
- const ids = res.commonPrefixes?.map(item => item.prefix!.split('/')[0]!).filter(key => TTID.isTTID(key)) as _ttid[] ?? [] as _ttid[]
1143
-
1144
- const docs = await Promise.allSettled(ids.map(id => Fylo.getDoc<T>(collection, id).once()))
1145
-
1146
- return docs.filter(item => item.status === 'fulfilled').map(item => item.value).filter(doc => Object.keys(doc).length > 0)
1491
+
1492
+ const ids =
1493
+ (res.commonPrefixes
1494
+ ?.map((item) => item.prefix!.split('/')[0]!)
1495
+ .filter((key) => TTID.isTTID(key)) as _ttid[]) ?? ([] as _ttid[])
1496
+
1497
+ const docs = await Promise.allSettled(
1498
+ ids.map((id) => Fylo.getDoc<T>(collection, id).once())
1499
+ )
1500
+
1501
+ return docs
1502
+ .filter((item) => item.status === 'fulfilled')
1503
+ .map((item) => item.value)
1504
+ .filter((doc) => Object.keys(doc).length > 0)
1147
1505
  }
1148
1506
 
1149
1507
  /**
@@ -1153,56 +1511,70 @@ export default class Fylo {
1153
1511
  * @returns The found documents.
1154
1512
  */
1155
1513
  static findDocs<T extends Record<string, any>>(collection: string, query?: _storeQuery<T>) {
1156
-
1157
- const processDoc = (doc: Record<_ttid, T>, query?: _storeQuery<T>) => {
1158
-
1159
- if(Object.keys(doc).length > 0) {
1514
+ if (Fylo.defaultEngineKind() === 's3-files') {
1515
+ return Fylo.defaultS3Files.findDocs<T>(collection, query)
1516
+ }
1160
1517
 
1518
+ const processDoc = (doc: Record<_ttid, T>, query?: _storeQuery<T>) => {
1519
+ if (Object.keys(doc).length > 0) {
1161
1520
  // Post-filter for operators that cannot be expressed as globs ($ne, $gt, $gte, $lt, $lte).
1162
1521
  // $ops use OR semantics: a document passes if it matches at least one op.
1163
- if(query?.$ops) {
1164
- for(const [_id, data] of Object.entries(doc)) {
1522
+ if (query?.$ops) {
1523
+ for (const [_id, data] of Object.entries(doc)) {
1165
1524
  let matchesAny = false
1166
- for(const op of query.$ops) {
1525
+ for (const op of query.$ops) {
1167
1526
  let opMatches = true
1168
- for(const col in op) {
1527
+ for (const col in op) {
1169
1528
  const val = (data as Record<string, unknown>)[col]
1170
1529
  const cond = op[col as keyof T]!
1171
- if(cond.$ne !== undefined && val == cond.$ne) { opMatches = false; break }
1172
- if(cond.$gt !== undefined && !(Number(val) > cond.$gt)) { opMatches = false; break }
1173
- if(cond.$gte !== undefined && !(Number(val) >= cond.$gte)) { opMatches = false; break }
1174
- if(cond.$lt !== undefined && !(Number(val) < cond.$lt)) { opMatches = false; break }
1175
- if(cond.$lte !== undefined && !(Number(val) <= cond.$lte)) { opMatches = false; break }
1530
+ if (cond.$ne !== undefined && val == cond.$ne) {
1531
+ opMatches = false
1532
+ break
1533
+ }
1534
+ if (cond.$gt !== undefined && !(Number(val) > cond.$gt)) {
1535
+ opMatches = false
1536
+ break
1537
+ }
1538
+ if (cond.$gte !== undefined && !(Number(val) >= cond.$gte)) {
1539
+ opMatches = false
1540
+ break
1541
+ }
1542
+ if (cond.$lt !== undefined && !(Number(val) < cond.$lt)) {
1543
+ opMatches = false
1544
+ break
1545
+ }
1546
+ if (cond.$lte !== undefined && !(Number(val) <= cond.$lte)) {
1547
+ opMatches = false
1548
+ break
1549
+ }
1550
+ }
1551
+ if (opMatches) {
1552
+ matchesAny = true
1553
+ break
1176
1554
  }
1177
- if(opMatches) { matchesAny = true; break }
1178
1555
  }
1179
- if(!matchesAny) delete doc[_id as _ttid]
1556
+ if (!matchesAny) delete doc[_id as _ttid]
1180
1557
  }
1181
- if(Object.keys(doc).length === 0) return
1558
+ if (Object.keys(doc).length === 0) return
1182
1559
  }
1183
1560
 
1184
- for(let [_id, data] of Object.entries(doc)) {
1185
-
1186
- if(query && query.$select && query.$select.length > 0) {
1187
-
1561
+ for (let [_id, data] of Object.entries(doc)) {
1562
+ if (query && query.$select && query.$select.length > 0) {
1188
1563
  data = this.selectValues<T>(query.$select as Array<keyof T>, data)
1189
1564
  }
1190
1565
 
1191
- if(query && query.$rename) data = this.renameFields<T>(query.$rename, data)
1566
+ if (query && query.$rename) data = this.renameFields<T>(query.$rename, data)
1192
1567
 
1193
1568
  doc[_id] = data
1194
1569
  }
1195
1570
 
1196
- if(query && query.$groupby) {
1197
-
1571
+ if (query && query.$groupby) {
1198
1572
  const docGroup: Record<string, Record<string, Partial<T>>> = {}
1199
1573
 
1200
- for(const [id, data] of Object.entries(doc)) {
1201
-
1574
+ for (const [id, data] of Object.entries(doc)) {
1202
1575
  const groupValue = data[query.$groupby] as string
1203
1576
 
1204
- if(groupValue) {
1205
-
1577
+ if (groupValue) {
1206
1578
  delete data[query.$groupby]
1207
1579
 
1208
1580
  docGroup[groupValue] = {
@@ -1211,12 +1583,9 @@ export default class Fylo {
1211
1583
  }
1212
1584
  }
1213
1585
 
1214
- if(query && query.$onlyIds) {
1215
-
1216
- for(const [groupValue, doc] of Object.entries(docGroup)) {
1217
-
1218
- for(const id in doc as Record<_ttid, T>) {
1219
-
1586
+ if (query && query.$onlyIds) {
1587
+ for (const [groupValue, doc] of Object.entries(docGroup)) {
1588
+ for (const id in doc as Record<_ttid, T>) {
1220
1589
  // @ts-expect-error - dynamic key assignment on grouped object
1221
1590
  docGroup[groupValue][id] = null
1222
1591
  }
@@ -1228,94 +1597,96 @@ export default class Fylo {
1228
1597
  return docGroup
1229
1598
  }
1230
1599
 
1231
- if(query && query.$onlyIds) {
1600
+ if (query && query.$onlyIds) {
1232
1601
  return Object.keys(doc).shift()
1233
1602
  }
1234
1603
 
1235
1604
  return doc
1236
1605
  }
1237
1606
 
1238
- return
1607
+ return
1239
1608
  }
1240
1609
 
1241
1610
  return {
1242
-
1243
1611
  /**
1244
1612
  * Async iterator (listener) for the documents.
1245
1613
  */
1246
1614
  async *[Symbol.asyncIterator]() {
1247
-
1248
1615
  await Fylo.loadEncryption(collection)
1249
1616
 
1250
1617
  const expression = await Query.getExprs(collection, query ?? {})
1251
1618
 
1252
- if(expression.length === 1 && expression[0] === `**/*`) {
1253
- for(const doc of await Fylo.allDocs<T>(collection, query)) yield processDoc(doc, query)
1254
- }
1619
+ if (expression.length === 1 && expression[0] === `**/*`) {
1620
+ for (const doc of await Fylo.allDocs<T>(collection, query))
1621
+ yield processDoc(doc, query)
1622
+ }
1255
1623
 
1256
1624
  let count = 0
1257
1625
  let finished = false
1258
1626
 
1259
- const iter = Dir.searchDocs<T>(collection, expression, { updated: query?.$updated, created: query?.$created }, { listen: true, skip: true })
1627
+ const iter = Dir.searchDocs<T>(
1628
+ collection,
1629
+ expression,
1630
+ { updated: query?.$updated, created: query?.$created },
1631
+ { listen: true, skip: true }
1632
+ )
1260
1633
 
1261
1634
  do {
1262
-
1263
1635
  const { value, done } = await iter.next({ count, limit: query?.$limit })
1264
1636
 
1265
- if(value === undefined && !done) continue
1637
+ if (value === undefined && !done) continue
1266
1638
 
1267
- if(done) {
1639
+ if (done) {
1268
1640
  finished = true
1269
1641
  break
1270
1642
  }
1271
1643
 
1272
1644
  const result = processDoc(value as Record<_ttid, T>, query)
1273
- if(result !== undefined) {
1645
+ if (result !== undefined) {
1274
1646
  count++
1275
1647
  yield result
1276
1648
  }
1277
-
1278
- } while(!finished)
1649
+ } while (!finished)
1279
1650
  },
1280
-
1651
+
1281
1652
  /**
1282
1653
  * Async iterator for the documents.
1283
1654
  */
1284
1655
  async *collect() {
1285
-
1286
1656
  await Fylo.loadEncryption(collection)
1287
1657
 
1288
1658
  const expression = await Query.getExprs(collection, query ?? {})
1289
1659
 
1290
- if(expression.length === 1 && expression[0] === `**/*`) {
1291
-
1292
- for(const doc of await Fylo.allDocs<T>(collection, query)) yield processDoc(doc, query)
1293
-
1660
+ if (expression.length === 1 && expression[0] === `**/*`) {
1661
+ for (const doc of await Fylo.allDocs<T>(collection, query))
1662
+ yield processDoc(doc, query)
1294
1663
  } else {
1295
-
1296
1664
  let count = 0
1297
1665
  let finished = false
1298
1666
 
1299
- const iter = Dir.searchDocs<T>(collection, expression, { updated: query?.$updated, created: query?.$created }, { listen: false, skip: false })
1667
+ const iter = Dir.searchDocs<T>(
1668
+ collection,
1669
+ expression,
1670
+ { updated: query?.$updated, created: query?.$created },
1671
+ { listen: false, skip: false }
1672
+ )
1300
1673
 
1301
1674
  do {
1302
-
1303
1675
  const { value, done } = await iter.next({ count, limit: query?.$limit })
1304
1676
 
1305
- if(value === undefined && !done) continue
1677
+ if (value === undefined && !done) continue
1306
1678
 
1307
- if(done) {
1679
+ if (done) {
1308
1680
  finished = true
1309
1681
  break
1310
1682
  }
1311
1683
 
1312
1684
  const result = processDoc(value as Record<_ttid, T>, query)
1313
- if(result !== undefined) {
1685
+ if (result !== undefined) {
1314
1686
  count++
1315
1687
  yield result
1316
1688
  }
1317
-
1318
- } while(!finished)
1689
+ } while (!finished)
1319
1690
  }
1320
1691
  },
1321
1692
 
@@ -1323,29 +1694,34 @@ export default class Fylo {
1323
1694
  * Async iterator (listener) for the document's deletion.
1324
1695
  */
1325
1696
  async *onDelete() {
1326
-
1327
1697
  await Fylo.loadEncryption(collection)
1328
1698
 
1329
1699
  let count = 0
1330
1700
  let finished = false
1331
1701
 
1332
- const iter = Dir.searchDocs<T>(collection, await Query.getExprs(collection, query ?? {}), {}, { listen: true, skip: true }, true)
1702
+ const iter = Dir.searchDocs<T>(
1703
+ collection,
1704
+ await Query.getExprs(collection, query ?? {}),
1705
+ {},
1706
+ { listen: true, skip: true },
1707
+ true
1708
+ )
1333
1709
 
1334
1710
  do {
1335
-
1336
1711
  const { value, done } = await iter.next({ count })
1337
1712
 
1338
- if(value === undefined && !done) continue
1713
+ if (value === undefined && !done) continue
1339
1714
 
1340
- if(done) {
1715
+ if (done) {
1341
1716
  finished = true
1342
1717
  break
1343
1718
  }
1344
1719
 
1345
- if(value) yield value as _ttid
1346
-
1347
- } while(!finished)
1720
+ if (value) yield value as _ttid
1721
+ } while (!finished)
1348
1722
  }
1349
1723
  }
1350
1724
  }
1351
1725
  }
1726
+
1727
+ export { migrateLegacyS3ToS3Files } from './migrate'