@delma/fylo 2.0.1 → 2.1.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 (43) hide show
  1. package/README.md +185 -267
  2. package/package.json +2 -5
  3. package/src/core/directory.ts +22 -354
  4. package/src/engines/s3-files/documents.ts +65 -0
  5. package/src/engines/s3-files/filesystem.ts +172 -0
  6. package/src/engines/s3-files/query.ts +291 -0
  7. package/src/engines/s3-files/types.ts +42 -0
  8. package/src/engines/s3-files.ts +391 -690
  9. package/src/engines/types.ts +1 -1
  10. package/src/index.ts +142 -1237
  11. package/src/sync.ts +58 -0
  12. package/src/types/fylo.d.ts +66 -161
  13. package/src/types/node-runtime.d.ts +1 -0
  14. package/tests/collection/truncate.test.js +11 -10
  15. package/tests/helpers/root.js +7 -0
  16. package/tests/integration/create.test.js +9 -9
  17. package/tests/integration/delete.test.js +16 -14
  18. package/tests/integration/edge-cases.test.js +29 -25
  19. package/tests/integration/encryption.test.js +47 -30
  20. package/tests/integration/export.test.js +11 -11
  21. package/tests/integration/join-modes.test.js +16 -16
  22. package/tests/integration/nested.test.js +26 -24
  23. package/tests/integration/operators.test.js +43 -29
  24. package/tests/integration/read.test.js +25 -21
  25. package/tests/integration/rollback.test.js +21 -51
  26. package/tests/integration/s3-files.performance.test.js +75 -0
  27. package/tests/integration/s3-files.test.js +57 -44
  28. package/tests/integration/sync.test.js +154 -0
  29. package/tests/integration/update.test.js +24 -18
  30. package/src/adapters/redis.ts +0 -487
  31. package/src/adapters/s3.ts +0 -61
  32. package/src/core/walker.ts +0 -174
  33. package/src/core/write-queue.ts +0 -59
  34. package/src/migrate-cli.ts +0 -22
  35. package/src/migrate.ts +0 -74
  36. package/src/types/write-queue.ts +0 -42
  37. package/src/worker.ts +0 -18
  38. package/src/workers/write-worker.ts +0 -120
  39. package/tests/index.js +0 -14
  40. package/tests/integration/migration.test.js +0 -38
  41. package/tests/integration/queue.test.js +0 -83
  42. package/tests/mocks/redis.js +0 -123
  43. package/tests/mocks/s3.js +0 -80
@@ -1,275 +1,256 @@
1
- import { mkdir, readFile, readdir, rm, stat, writeFile, open } from 'node:fs/promises'
1
+ import { rename, writeFile } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import { createHash } from 'node:crypto'
4
- import { Database } from 'bun:sqlite'
5
- import type { SQLQueryBindings } from 'bun:sqlite'
6
4
  import TTID from '@delma/ttid'
7
5
  import { Dir } from '../core/directory'
8
6
  import { validateCollectionName } from '../core/collection'
9
7
  import { Cipher } from '../adapters/cipher'
8
+ import {
9
+ FyloSyncError,
10
+ resolveSyncMode,
11
+ type FyloDeleteSyncEvent,
12
+ type FyloSyncHooks,
13
+ type FyloSyncMode,
14
+ type FyloWriteSyncEvent
15
+ } from '../sync'
10
16
  import type { EventBus, FyloStorageEngineKind, LockManager, StorageEngine } from './types'
17
+ import {
18
+ type CollectionIndexCache,
19
+ type FyloRecord,
20
+ type StoredCollectionIndex,
21
+ type StoredIndexEntry
22
+ } from './s3-files/types'
23
+ import { FilesystemEventBus, FilesystemLockManager, FilesystemStorage } from './s3-files/filesystem'
24
+ import { S3FilesDocuments } from './s3-files/documents'
25
+ import { S3FilesQueryEngine } from './s3-files/query'
11
26
 
12
- type FyloRecord<T extends Record<string, any>> = Record<_ttid, T>
13
-
14
- type S3FilesQueryResult<T extends Record<string, any>> =
15
- | _ttid
16
- | FyloRecord<T>
17
- | Record<string, _ttid[]>
18
- | Record<string, Record<_ttid, Partial<T>>>
19
- | Record<_ttid, Partial<T>>
20
-
21
- type S3FilesEvent<T extends Record<string, any>> = {
22
- ts: number
23
- action: 'insert' | 'delete'
24
- id: _ttid
25
- doc?: T
26
- }
27
+ export class S3FilesEngine {
28
+ readonly kind: FyloStorageEngineKind = 's3-files'
27
29
 
28
- type StoredDoc<T extends Record<string, any>> = {
29
- id: _ttid
30
- createdAt: number
31
- updatedAt: number
32
- data: T
33
- }
30
+ private readonly indexes = new Map<string, CollectionIndexCache>()
31
+ private readonly writeLanes = new Map<string, Promise<void>>()
34
32
 
35
- class FilesystemStorage implements StorageEngine {
36
- async read(target: string): Promise<string> {
37
- return await readFile(target, 'utf8')
38
- }
33
+ private readonly storage: StorageEngine
34
+ private readonly locks: LockManager
35
+ private readonly events: EventBus<Record<string, any>>
36
+ private readonly documents: S3FilesDocuments
37
+ private readonly queryEngine: S3FilesQueryEngine
38
+ private readonly sync?: FyloSyncHooks
39
+ private readonly syncMode: FyloSyncMode
39
40
 
40
- async write(target: string, data: string): Promise<void> {
41
- await mkdir(path.dirname(target), { recursive: true })
42
- await writeFile(target, data, 'utf8')
41
+ constructor(
42
+ readonly root: string = process.env.FYLO_ROOT ??
43
+ process.env.FYLO_S3FILES_ROOT ??
44
+ path.join(process.cwd(), '.fylo-data'),
45
+ options: {
46
+ sync?: FyloSyncHooks
47
+ syncMode?: FyloSyncMode
48
+ } = {}
49
+ ) {
50
+ this.sync = options.sync
51
+ this.syncMode = resolveSyncMode(options.syncMode)
52
+ this.storage = new FilesystemStorage()
53
+ this.locks = new FilesystemLockManager(this.root, this.storage)
54
+ this.events = new FilesystemEventBus<Record<string, any>>(this.root, this.storage)
55
+ this.documents = new S3FilesDocuments(
56
+ this.storage,
57
+ this.docsRoot.bind(this),
58
+ this.docPath.bind(this),
59
+ this.ensureCollection.bind(this),
60
+ this.encodeEncrypted.bind(this),
61
+ this.decodeEncrypted.bind(this)
62
+ )
63
+ this.queryEngine = new S3FilesQueryEngine({
64
+ loadIndexCache: this.loadIndexCache.bind(this),
65
+ normalizeIndexValue: this.normalizeIndexValue.bind(this)
66
+ })
43
67
  }
44
68
 
45
- async delete(target: string): Promise<void> {
46
- await rm(target, { recursive: true, force: true })
69
+ private collectionRoot(collection: string) {
70
+ validateCollectionName(collection)
71
+ return path.join(this.root, collection)
47
72
  }
48
73
 
49
- async list(target: string): Promise<string[]> {
50
- const results: string[] = []
51
-
52
- try {
53
- const entries = await readdir(target, { withFileTypes: true })
54
- for (const entry of entries) {
55
- const child = path.join(target, entry.name)
56
- if (entry.isDirectory()) {
57
- results.push(...(await this.list(child)))
58
- } else {
59
- results.push(child)
60
- }
61
- }
62
- } catch (err) {
63
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
64
- }
65
-
66
- return results
74
+ private docsRoot(collection: string) {
75
+ return path.join(this.collectionRoot(collection), '.fylo', 'docs')
67
76
  }
68
77
 
69
- async mkdir(target: string): Promise<void> {
70
- await mkdir(target, { recursive: true })
78
+ private metaRoot(collection: string) {
79
+ return path.join(this.collectionRoot(collection), '.fylo')
71
80
  }
72
81
 
73
- async rmdir(target: string): Promise<void> {
74
- await rm(target, { recursive: true, force: true })
82
+ private indexesRoot(collection: string) {
83
+ return path.join(this.metaRoot(collection), 'indexes')
75
84
  }
76
85
 
77
- async exists(target: string): Promise<boolean> {
78
- try {
79
- await stat(target)
80
- return true
81
- } catch (err) {
82
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false
83
- throw err
84
- }
86
+ private indexFilePath(collection: string) {
87
+ return path.join(this.indexesRoot(collection), `${collection}.idx.json`)
85
88
  }
86
- }
87
89
 
88
- class FilesystemLockManager implements LockManager {
89
- constructor(
90
- private readonly root: string,
91
- private readonly storage: StorageEngine
92
- ) {}
93
-
94
- private lockDir(collection: string, docId: _ttid) {
95
- return path.join(this.root, collection, '.fylo', 'locks', `${docId}.lock`)
90
+ private docPath(collection: string, docId: _ttid) {
91
+ return path.join(this.docsRoot(collection), docId.slice(0, 2), `${docId}.json`)
96
92
  }
97
93
 
98
- async acquire(
94
+ private async runSyncTask(
99
95
  collection: string,
100
96
  docId: _ttid,
101
- owner: string,
102
- ttlMs: number = 30_000
103
- ): Promise<boolean> {
104
- const dir = this.lockDir(collection, docId)
105
- const metaPath = path.join(dir, 'meta.json')
106
- await mkdir(path.dirname(dir), { recursive: true })
107
-
108
- try {
109
- await mkdir(dir, { recursive: false })
110
- await this.storage.write(metaPath, JSON.stringify({ owner, ts: Date.now() }))
111
- return true
112
- } catch (err) {
113
- if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
97
+ operation: string,
98
+ targetPath: string,
99
+ task: () => Promise<void>
100
+ ) {
101
+ if (!this.sync?.onWrite && !this.sync?.onDelete) return
102
+
103
+ if (this.syncMode === 'fire-and-forget') {
104
+ void task().catch((cause) => {
105
+ console.error(
106
+ new FyloSyncError({
107
+ collection,
108
+ docId,
109
+ operation,
110
+ path: targetPath,
111
+ cause
112
+ })
113
+ )
114
+ })
115
+ return
114
116
  }
115
117
 
116
118
  try {
117
- const meta = JSON.parse(await this.storage.read(metaPath)) as { ts?: number }
118
- if (meta.ts && Date.now() - meta.ts > ttlMs) {
119
- await this.storage.rmdir(dir)
120
- await mkdir(dir, { recursive: false })
121
- await this.storage.write(metaPath, JSON.stringify({ owner, ts: Date.now() }))
122
- return true
123
- }
124
- } catch {
125
- await this.storage.rmdir(dir)
126
- await mkdir(dir, { recursive: false })
127
- await this.storage.write(metaPath, JSON.stringify({ owner, ts: Date.now() }))
128
- return true
119
+ await task()
120
+ } catch (cause) {
121
+ throw new FyloSyncError({
122
+ collection,
123
+ docId,
124
+ operation,
125
+ path: targetPath,
126
+ cause
127
+ })
129
128
  }
130
-
131
- return false
132
129
  }
133
130
 
134
- async release(collection: string, docId: _ttid, owner: string): Promise<void> {
135
- const dir = this.lockDir(collection, docId)
136
- const metaPath = path.join(dir, 'meta.json')
137
-
138
- try {
139
- const meta = JSON.parse(await this.storage.read(metaPath)) as { owner?: string }
140
- if (meta.owner === owner) await this.storage.rmdir(dir)
141
- } catch (err) {
142
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
143
- }
131
+ private async syncWrite<T extends Record<string, any>>(event: FyloWriteSyncEvent<T>) {
132
+ if (!this.sync?.onWrite) return
133
+ await this.sync.onWrite(event)
144
134
  }
145
- }
146
-
147
- class FilesystemEventBus<T extends Record<string, any>> implements EventBus<S3FilesEvent<T>> {
148
- constructor(
149
- private readonly root: string,
150
- private readonly storage: StorageEngine
151
- ) {}
152
135
 
153
- private journalPath(collection: string) {
154
- return path.join(this.root, collection, '.fylo', 'events', `${collection}.ndjson`)
136
+ private async syncDelete(event: FyloDeleteSyncEvent) {
137
+ if (!this.sync?.onDelete) return
138
+ await this.sync.onDelete(event)
155
139
  }
156
140
 
157
- async publish(collection: string, event: S3FilesEvent<T>): Promise<void> {
158
- const target = this.journalPath(collection)
159
- await mkdir(path.dirname(target), { recursive: true })
160
- const line = `${JSON.stringify(event)}\n`
161
- const handle = await open(target, 'a')
162
- try {
163
- await handle.write(line)
164
- } finally {
165
- await handle.close()
166
- }
141
+ private hash(value: string) {
142
+ return createHash('sha256').update(value).digest('hex')
167
143
  }
168
144
 
169
- async *listen(collection: string): AsyncGenerator<S3FilesEvent<T>, void, unknown> {
170
- const target = this.journalPath(collection)
171
- let position = 0
172
-
173
- while (true) {
174
- try {
175
- const fileStat = await stat(target)
176
- if (fileStat.size > position) {
177
- const handle = await open(target, 'r')
178
- try {
179
- const size = fileStat.size - position
180
- const buffer = Buffer.alloc(size)
181
- await handle.read(buffer, 0, size, position)
182
- position = fileStat.size
183
-
184
- for (const line of buffer.toString('utf8').split('\n')) {
185
- if (line.trim().length === 0) continue
186
- yield JSON.parse(line) as S3FilesEvent<T>
187
- }
188
- } finally {
189
- await handle.close()
190
- }
191
- }
192
- } catch (err) {
193
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
194
- }
195
-
196
- await Bun.sleep(100)
145
+ private createEmptyIndexCache(): CollectionIndexCache {
146
+ return {
147
+ docs: new Map(),
148
+ fieldHash: new Map(),
149
+ fieldNumeric: new Map(),
150
+ fieldString: new Map()
197
151
  }
198
152
  }
199
- }
200
153
 
201
- export class S3FilesEngine {
202
- readonly kind: FyloStorageEngineKind = 's3-files'
203
-
204
- private readonly databases = new Map<string, Database>()
154
+ private addEntryToCache(cache: CollectionIndexCache, docId: _ttid, entry: StoredIndexEntry) {
155
+ let valueHashBucket = cache.fieldHash.get(entry.fieldPath)
156
+ if (!valueHashBucket) {
157
+ valueHashBucket = new Map()
158
+ cache.fieldHash.set(entry.fieldPath, valueHashBucket)
159
+ }
205
160
 
206
- private readonly storage: StorageEngine
207
- private readonly locks: LockManager
208
- private readonly events: EventBus<Record<string, any>>
161
+ let docsForValue = valueHashBucket.get(entry.valueHash)
162
+ if (!docsForValue) {
163
+ docsForValue = new Set()
164
+ valueHashBucket.set(entry.valueHash, docsForValue)
165
+ }
166
+ docsForValue.add(docId)
209
167
 
210
- constructor(readonly root: string = process.env.FYLO_S3FILES_ROOT ?? '/mnt/fylo') {
211
- this.storage = new FilesystemStorage()
212
- this.locks = new FilesystemLockManager(this.root, this.storage)
213
- this.events = new FilesystemEventBus<Record<string, any>>(this.root, this.storage)
214
- }
168
+ if (entry.numericValue !== null) {
169
+ const numericEntries = cache.fieldNumeric.get(entry.fieldPath) ?? []
170
+ numericEntries.push({ docId, numericValue: entry.numericValue })
171
+ cache.fieldNumeric.set(entry.fieldPath, numericEntries)
172
+ }
215
173
 
216
- private collectionRoot(collection: string) {
217
- validateCollectionName(collection)
218
- return path.join(this.root, collection)
174
+ if (entry.valueType === 'string') {
175
+ const stringEntries = cache.fieldString.get(entry.fieldPath) ?? []
176
+ stringEntries.push({ docId, rawValue: entry.rawValue })
177
+ cache.fieldString.set(entry.fieldPath, stringEntries)
178
+ }
219
179
  }
220
180
 
221
- private docsRoot(collection: string) {
222
- return path.join(this.collectionRoot(collection), '.fylo', 'docs')
223
- }
181
+ private deleteEntryFromCache(
182
+ cache: CollectionIndexCache,
183
+ docId: _ttid,
184
+ entry: StoredIndexEntry
185
+ ) {
186
+ const valueHashBucket = cache.fieldHash.get(entry.fieldPath)
187
+ const docsForValue = valueHashBucket?.get(entry.valueHash)
188
+ docsForValue?.delete(docId)
189
+ if (docsForValue?.size === 0) valueHashBucket?.delete(entry.valueHash)
190
+ if (valueHashBucket?.size === 0) cache.fieldHash.delete(entry.fieldPath)
191
+
192
+ if (entry.numericValue !== null) {
193
+ const numericEntries = cache.fieldNumeric
194
+ .get(entry.fieldPath)
195
+ ?.filter(
196
+ (candidate) =>
197
+ !(
198
+ candidate.docId === docId &&
199
+ candidate.numericValue === entry.numericValue
200
+ )
201
+ )
202
+ if (!numericEntries?.length) cache.fieldNumeric.delete(entry.fieldPath)
203
+ else cache.fieldNumeric.set(entry.fieldPath, numericEntries)
204
+ }
224
205
 
225
- private metaRoot(collection: string) {
226
- return path.join(this.collectionRoot(collection), '.fylo')
206
+ if (entry.valueType === 'string') {
207
+ const stringEntries = cache.fieldString
208
+ .get(entry.fieldPath)
209
+ ?.filter(
210
+ (candidate) =>
211
+ !(candidate.docId === docId && candidate.rawValue === entry.rawValue)
212
+ )
213
+ if (!stringEntries?.length) cache.fieldString.delete(entry.fieldPath)
214
+ else cache.fieldString.set(entry.fieldPath, stringEntries)
215
+ }
227
216
  }
228
217
 
229
- private indexDbPath(collection: string) {
230
- return path.join(this.metaRoot(collection), 'index.db')
231
- }
218
+ private async writeIndexFile(collection: string, cache: CollectionIndexCache) {
219
+ await this.storage.mkdir(this.indexesRoot(collection))
220
+ const target = this.indexFilePath(collection)
221
+ const temp = `${target}.tmp`
232
222
 
233
- private docPath(collection: string, docId: _ttid) {
234
- return path.join(this.docsRoot(collection), docId.slice(0, 2), `${docId}.json`)
235
- }
223
+ const payload: StoredCollectionIndex = {
224
+ version: 1,
225
+ docs: Object.fromEntries(cache.docs)
226
+ }
236
227
 
237
- private hash(value: string) {
238
- return createHash('sha256').update(value).digest('hex')
228
+ await writeFile(temp, JSON.stringify(payload), 'utf8')
229
+ await rename(temp, target)
239
230
  }
240
231
 
241
- private database(collection: string) {
242
- const existing = this.databases.get(collection)
243
- if (existing) return existing
244
-
245
- const db = new Database(this.indexDbPath(collection))
246
- db.exec(`
247
- CREATE TABLE IF NOT EXISTS doc_index_entries (
248
- doc_id TEXT NOT NULL,
249
- field_path TEXT NOT NULL,
250
- value_hash TEXT NOT NULL,
251
- raw_value TEXT NOT NULL,
252
- value_type TEXT NOT NULL,
253
- numeric_value REAL,
254
- PRIMARY KEY (doc_id, field_path, value_hash)
255
- );
256
-
257
- CREATE INDEX IF NOT EXISTS idx_doc_index_entries_field_hash
258
- ON doc_index_entries (field_path, value_hash);
259
-
260
- CREATE INDEX IF NOT EXISTS idx_doc_index_entries_field_numeric
261
- ON doc_index_entries (field_path, numeric_value);
262
- `)
263
- this.databases.set(collection, db)
264
- return db
265
- }
232
+ private async loadIndexCache(collection: string) {
233
+ const cache = this.createEmptyIndexCache()
266
234
 
267
- private closeDatabase(collection: string) {
268
- const db = this.databases.get(collection)
269
- if (db) {
270
- db.close()
271
- this.databases.delete(collection)
235
+ try {
236
+ const raw = JSON.parse(await this.storage.read(this.indexFilePath(collection))) as
237
+ | StoredCollectionIndex
238
+ | undefined
239
+
240
+ if (raw?.version === 1 && raw.docs) {
241
+ for (const [docId, entries] of Object.entries(raw.docs) as Array<
242
+ [_ttid, StoredIndexEntry[]]
243
+ >) {
244
+ cache.docs.set(docId, entries)
245
+ for (const entry of entries) this.addEntryToCache(cache, docId, entry)
246
+ }
247
+ }
248
+ } catch (err) {
249
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
272
250
  }
251
+
252
+ this.indexes.set(collection, cache)
253
+ return cache
273
254
  }
274
255
 
275
256
  private normalizeIndexValue(rawValue: string) {
@@ -287,7 +268,30 @@ export class S3FilesEngine {
287
268
  await this.storage.mkdir(this.collectionRoot(collection))
288
269
  await this.storage.mkdir(this.metaRoot(collection))
289
270
  await this.storage.mkdir(this.docsRoot(collection))
290
- this.database(collection)
271
+ await this.storage.mkdir(this.indexesRoot(collection))
272
+ await this.loadIndexCache(collection)
273
+ }
274
+
275
+ private async withCollectionWriteLock<T>(
276
+ collection: string,
277
+ action: () => Promise<T>
278
+ ): Promise<T> {
279
+ const previous = this.writeLanes.get(collection) ?? Promise.resolve()
280
+ let release!: () => void
281
+ const current = new Promise<void>((resolve) => {
282
+ release = resolve
283
+ })
284
+ const lane = previous.then(() => current)
285
+ this.writeLanes.set(collection, lane)
286
+
287
+ await previous
288
+
289
+ try {
290
+ return await action()
291
+ } finally {
292
+ release()
293
+ if (this.writeLanes.get(collection) === lane) this.writeLanes.delete(collection)
294
+ }
291
295
  }
292
296
 
293
297
  async createCollection(collection: string) {
@@ -295,7 +299,7 @@ export class S3FilesEngine {
295
299
  }
296
300
 
297
301
  async dropCollection(collection: string) {
298
- this.closeDatabase(collection)
302
+ this.indexes.delete(collection)
299
303
  await this.storage.rmdir(this.collectionRoot(collection))
300
304
  }
301
305
 
@@ -393,377 +397,21 @@ export class S3FilesEngine {
393
397
  return value
394
398
  }
395
399
 
396
- private async readStoredDoc<T extends Record<string, any>>(
397
- collection: string,
398
- docId: _ttid
399
- ): Promise<StoredDoc<T> | null> {
400
- const target = this.docPath(collection, docId)
401
-
402
- try {
403
- const raw = JSON.parse(await this.storage.read(target)) as StoredDoc<T>
404
- raw.data = await this.decodeEncrypted(collection, raw.data)
405
- return raw
406
- } catch (err) {
407
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
408
- throw err
409
- }
410
- }
411
-
412
- private async writeStoredDoc<T extends Record<string, any>>(
413
- collection: string,
414
- docId: _ttid,
415
- data: T
416
- ) {
417
- await this.ensureCollection(collection)
418
- const encoded = await this.encodeEncrypted(collection, data)
419
- const { createdAt, updatedAt } = TTID.decodeTime(docId)
420
- const target = this.docPath(collection, docId)
421
- const record: StoredDoc<T> = {
422
- id: docId,
423
- createdAt,
424
- updatedAt: updatedAt ?? createdAt,
425
- data: encoded
426
- }
427
- await this.storage.write(target, JSON.stringify(record))
428
- }
429
-
430
- private async removeStoredDoc(collection: string, docId: _ttid) {
431
- await this.storage.delete(this.docPath(collection, docId))
432
- }
433
-
434
- private async listDocIds(collection: string) {
435
- const files = await this.storage.list(this.docsRoot(collection))
436
- return files
437
- .filter((file) => file.endsWith('.json'))
438
- .map((file) => path.basename(file, '.json'))
439
- .filter((key) => TTID.isTTID(key)) as _ttid[]
440
- }
441
-
442
- private getValueByPath(target: Record<string, any>, fieldPath: string) {
443
- return fieldPath
444
- .replaceAll('/', '.')
445
- .split('.')
446
- .reduce<any>(
447
- (acc, key) => (acc === undefined || acc === null ? undefined : acc[key]),
448
- target
449
- )
450
- }
451
-
452
- private normalizeFieldPath(fieldPath: string) {
453
- return fieldPath.replaceAll('.', '/')
454
- }
455
-
456
- private matchesTimestamp(docId: _ttid, query?: _storeQuery<Record<string, any>>) {
457
- if (!query?.$created && !query?.$updated) return true
458
- const { createdAt, updatedAt } = TTID.decodeTime(docId)
459
- const timestamps = { createdAt, updatedAt: updatedAt ?? createdAt }
460
-
461
- const match = (value: number, range?: _timestamp) => {
462
- if (!range) return true
463
- if (range.$gt !== undefined && !(value > range.$gt)) return false
464
- if (range.$gte !== undefined && !(value >= range.$gte)) return false
465
- if (range.$lt !== undefined && !(value < range.$lt)) return false
466
- if (range.$lte !== undefined && !(value <= range.$lte)) return false
467
- return true
468
- }
469
-
470
- return (
471
- match(timestamps.createdAt, query.$created) &&
472
- match(timestamps.updatedAt, query.$updated)
473
- )
474
- }
475
-
476
- private likeToRegex(pattern: string) {
477
- const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replaceAll('%', '.*')
478
- return new RegExp(`^${escaped}$`)
479
- }
480
-
481
- private matchesOperand(value: unknown, operand: _operand) {
482
- if (operand.$eq !== undefined && value != operand.$eq) return false
483
- if (operand.$ne !== undefined && value == operand.$ne) return false
484
- if (operand.$gt !== undefined && !(Number(value) > operand.$gt)) return false
485
- if (operand.$gte !== undefined && !(Number(value) >= operand.$gte)) return false
486
- if (operand.$lt !== undefined && !(Number(value) < operand.$lt)) return false
487
- if (operand.$lte !== undefined && !(Number(value) <= operand.$lte)) return false
488
- if (
489
- operand.$like !== undefined &&
490
- (typeof value !== 'string' || !this.likeToRegex(operand.$like).test(value))
491
- )
492
- return false
493
- if (operand.$contains !== undefined) {
494
- if (!Array.isArray(value) || !value.some((item) => item == operand.$contains))
495
- return false
496
- }
497
- return true
498
- }
499
-
500
- private async normalizeQueryValue(collection: string, fieldPath: string, value: unknown) {
501
- let rawValue = String(value).replaceAll('/', '%2F')
502
- if (Cipher.isConfigured() && Cipher.isEncryptedField(collection, fieldPath))
503
- rawValue = await Cipher.encrypt(rawValue, true)
504
- return this.normalizeIndexValue(rawValue)
505
- }
506
-
507
- private intersectDocIds(current: Set<_ttid> | null, next: Iterable<_ttid>) {
508
- const nextSet = next instanceof Set ? next : new Set(next)
509
- if (current === null) return new Set(nextSet)
510
-
511
- const intersection = new Set<_ttid>()
512
- for (const docId of current) {
513
- if (nextSet.has(docId)) intersection.add(docId)
514
- }
515
- return intersection
516
- }
517
-
518
- private async queryDocIdsBySql(
519
- collection: string,
520
- sql: string,
521
- ...params: SQLQueryBindings[]
522
- ): Promise<Set<_ttid>> {
523
- const db = this.database(collection)
524
- const rows = db
525
- .query(sql)
526
- .all(...params)
527
- .map((row) => (row as { doc_id: _ttid }).doc_id)
528
-
529
- return new Set(rows)
530
- }
531
-
532
- private async candidateDocIdsForOperand(
533
- collection: string,
534
- fieldPath: string,
535
- operand: _operand
536
- ): Promise<Set<_ttid> | null> {
537
- if (Cipher.isConfigured() && Cipher.isEncryptedField(collection, fieldPath)) return null
538
-
539
- let candidateIds: Set<_ttid> | null = null
540
-
541
- if (operand.$eq !== undefined) {
542
- const normalized = await this.normalizeQueryValue(collection, fieldPath, operand.$eq)
543
- candidateIds = this.intersectDocIds(
544
- candidateIds,
545
- await this.queryDocIdsBySql(
546
- collection,
547
- `SELECT DISTINCT doc_id
548
- FROM doc_index_entries
549
- WHERE field_path = ? AND value_hash = ?`,
550
- fieldPath,
551
- normalized.valueHash
552
- )
553
- )
554
- }
555
-
556
- if (
557
- operand.$gt !== undefined ||
558
- operand.$gte !== undefined ||
559
- operand.$lt !== undefined ||
560
- operand.$lte !== undefined
561
- ) {
562
- const clauses = ['field_path = ?']
563
- const params: SQLQueryBindings[] = [fieldPath]
564
- if (operand.$gt !== undefined) {
565
- clauses.push('numeric_value > ?')
566
- params.push(operand.$gt)
567
- }
568
- if (operand.$gte !== undefined) {
569
- clauses.push('numeric_value >= ?')
570
- params.push(operand.$gte)
571
- }
572
- if (operand.$lt !== undefined) {
573
- clauses.push('numeric_value < ?')
574
- params.push(operand.$lt)
575
- }
576
- if (operand.$lte !== undefined) {
577
- clauses.push('numeric_value <= ?')
578
- params.push(operand.$lte)
579
- }
580
-
581
- candidateIds = this.intersectDocIds(
582
- candidateIds,
583
- await this.queryDocIdsBySql(
584
- collection,
585
- `SELECT DISTINCT doc_id
586
- FROM doc_index_entries
587
- WHERE ${clauses.join(' AND ')}`,
588
- ...params
589
- )
590
- )
591
- }
592
-
593
- if (operand.$like !== undefined) {
594
- candidateIds = this.intersectDocIds(
595
- candidateIds,
596
- await this.queryDocIdsBySql(
597
- collection,
598
- `SELECT DISTINCT doc_id
599
- FROM doc_index_entries
600
- WHERE field_path = ? AND value_type = 'string' AND raw_value LIKE ?`,
601
- fieldPath,
602
- operand.$like.replaceAll('/', '%2F')
603
- )
604
- )
605
- }
606
-
607
- if (operand.$contains !== undefined) {
608
- const normalized = await this.normalizeQueryValue(
609
- collection,
610
- fieldPath,
611
- operand.$contains
612
- )
613
- candidateIds = this.intersectDocIds(
614
- candidateIds,
615
- await this.queryDocIdsBySql(
616
- collection,
617
- `SELECT DISTINCT doc_id
618
- FROM doc_index_entries
619
- WHERE (field_path = ? OR field_path LIKE ?)
620
- AND value_hash = ?`,
621
- fieldPath,
622
- `${fieldPath}/%`,
623
- normalized.valueHash
624
- )
625
- )
626
- }
627
-
628
- return candidateIds
629
- }
630
-
631
- private async candidateDocIdsForOperation<T extends Record<string, any>>(
632
- collection: string,
633
- operation: _op<T>
634
- ): Promise<Set<_ttid> | null> {
635
- let candidateIds: Set<_ttid> | null = null
636
-
637
- for (const [field, operand] of Object.entries(operation) as Array<[keyof T, _operand]>) {
638
- if (!operand) continue
639
-
640
- const fieldPath = this.normalizeFieldPath(String(field))
641
- const fieldCandidates = await this.candidateDocIdsForOperand(
642
- collection,
643
- fieldPath,
644
- operand
645
- )
646
-
647
- if (fieldCandidates === null) continue
648
- candidateIds = this.intersectDocIds(candidateIds, fieldCandidates)
649
- }
650
-
651
- return candidateIds
652
- }
653
-
654
- private async candidateDocIdsForQuery<T extends Record<string, any>>(
655
- collection: string,
656
- query?: _storeQuery<T>
657
- ): Promise<Set<_ttid> | null> {
658
- if (!query?.$ops || query.$ops.length === 0) return null
659
-
660
- const union = new Set<_ttid>()
661
- let usedIndex = false
662
-
663
- for (const operation of query.$ops) {
664
- const candidateIds = await this.candidateDocIdsForOperation(collection, operation)
665
- if (candidateIds === null) return null
666
- usedIndex = true
667
- for (const docId of candidateIds) union.add(docId)
668
- }
669
-
670
- return usedIndex ? union : null
671
- }
672
-
673
- private matchesQuery<T extends Record<string, any>>(
674
- docId: _ttid,
675
- doc: T,
676
- query?: _storeQuery<T>
677
- ) {
678
- if (!this.matchesTimestamp(docId, query as _storeQuery<Record<string, any>> | undefined))
679
- return false
680
- if (!query?.$ops || query.$ops.length === 0) return true
681
-
682
- return query.$ops.some((operation) => {
683
- for (const field in operation) {
684
- const value = this.getValueByPath(doc, field)
685
- if (!this.matchesOperand(value, operation[field as keyof T]!)) return false
686
- }
687
- return true
688
- })
689
- }
690
-
691
- private selectValues<T extends Record<string, any>>(selection: Array<keyof T>, data: T) {
692
- const copy = { ...data }
693
- for (const field in copy) {
694
- if (!selection.includes(field as keyof T)) delete copy[field]
695
- }
696
- return copy
697
- }
698
-
699
- private renameFields<T extends Record<string, any>>(
700
- rename: Record<keyof Partial<T>, string>,
701
- data: T
702
- ) {
703
- const copy = { ...data }
704
- for (const field in copy) {
705
- if (rename[field]) {
706
- copy[rename[field]] = copy[field]
707
- delete copy[field]
708
- }
709
- }
710
- return copy
711
- }
712
-
713
- private processDoc<T extends Record<string, any>>(
714
- doc: FyloRecord<T>,
715
- query?: _storeQuery<T>
716
- ): S3FilesQueryResult<T> | undefined {
717
- if (Object.keys(doc).length === 0) return
718
-
719
- const next = { ...doc }
720
-
721
- for (let [_id, data] of Object.entries(next)) {
722
- if (query?.$select?.length)
723
- data = this.selectValues(query.$select as Array<keyof T>, data)
724
- if (query?.$rename) data = this.renameFields(query.$rename, data)
725
- next[_id as _ttid] = data as T
726
- }
727
-
728
- if (query?.$groupby) {
729
- const docGroup: Record<string, Record<string, Partial<T>>> = {}
730
- for (const [id, data] of Object.entries(next)) {
731
- const groupValue = data[query.$groupby] as string
732
- if (groupValue) {
733
- const groupData = { ...data }
734
- delete groupData[query.$groupby]
735
- docGroup[groupValue] = { [id]: groupData as Partial<T> }
736
- }
737
- }
738
-
739
- if (query.$onlyIds) {
740
- const groupedIds: Record<string, _ttid[]> = {}
741
- for (const group in docGroup)
742
- groupedIds[group] = Object.keys(docGroup[group]) as _ttid[]
743
- return groupedIds
744
- }
745
-
746
- return docGroup
747
- }
748
-
749
- if (query?.$onlyIds) return Object.keys(next).shift() as _ttid
750
-
751
- return next
752
- }
753
-
754
400
  private async docResults<T extends Record<string, any>>(
755
401
  collection: string,
756
402
  query?: _storeQuery<T>
757
403
  ) {
758
- const candidateIds = await this.candidateDocIdsForQuery(collection, query)
759
- const ids = candidateIds ? Array.from(candidateIds) : await this.listDocIds(collection)
404
+ const candidateIds = await this.queryEngine.candidateDocIdsForQuery(collection, query)
405
+ const ids = candidateIds
406
+ ? Array.from(candidateIds)
407
+ : await this.documents.listDocIds(collection)
760
408
  const limit = query?.$limit
761
409
  const results: Array<FyloRecord<T>> = []
762
410
 
763
411
  for (const id of ids) {
764
- const stored = await this.readStoredDoc<T>(collection, id)
412
+ const stored = await this.documents.readStoredDoc<T>(collection, id)
765
413
  if (!stored) continue
766
- if (!this.matchesQuery(id, stored.data, query)) continue
414
+ if (!this.queryEngine.matchesQuery(id, stored.data, query)) continue
767
415
  results.push({ [id]: stored.data } as FyloRecord<T>)
768
416
  if (limit && results.length >= limit) break
769
417
  }
@@ -777,74 +425,74 @@ export class S3FilesEngine {
777
425
  doc: T
778
426
  ) {
779
427
  const keys = await Dir.extractKeys(collection, docId, doc)
780
- const db = this.database(collection)
781
- const insert = db.query(`
782
- INSERT OR REPLACE INTO doc_index_entries
783
- (doc_id, field_path, value_hash, raw_value, value_type, numeric_value)
784
- VALUES (?, ?, ?, ?, ?, ?)
785
- `)
786
-
787
- const transaction = db.transaction((logicalKeys: string[]) => {
788
- for (const logicalKey of logicalKeys) {
789
- const segments = logicalKey.split('/')
790
- const fieldPath = segments.slice(0, -2).join('/')
791
- const rawValue = segments.at(-2) ?? ''
792
- const normalized = this.normalizeIndexValue(rawValue)
793
- insert.run(
794
- docId,
795
- fieldPath,
796
- normalized.valueHash,
797
- normalized.rawValue,
798
- normalized.valueType,
799
- normalized.numericValue
800
- )
801
- }
428
+ const cache = await this.loadIndexCache(collection)
429
+ const entries = keys.indexes.map((logicalKey) => {
430
+ const segments = logicalKey.split('/')
431
+ const fieldPath = segments.slice(0, -2).join('/')
432
+ const rawValue = segments.at(-2) ?? ''
433
+ const normalized = this.normalizeIndexValue(rawValue)
434
+
435
+ return {
436
+ fieldPath,
437
+ rawValue: normalized.rawValue,
438
+ valueHash: normalized.valueHash,
439
+ valueType: normalized.valueType,
440
+ numericValue: normalized.numericValue
441
+ } satisfies StoredIndexEntry
802
442
  })
803
443
 
804
- transaction(keys.indexes)
444
+ const existingEntries = cache.docs.get(docId)
445
+ if (existingEntries) {
446
+ for (const entry of existingEntries) this.deleteEntryFromCache(cache, docId, entry)
447
+ }
448
+
449
+ cache.docs.set(docId, entries)
450
+ for (const entry of entries) this.addEntryToCache(cache, docId, entry)
451
+ await this.writeIndexFile(collection, cache)
805
452
  }
806
453
 
807
454
  private async removeIndexes<T extends Record<string, any>>(
808
455
  collection: string,
809
456
  docId: _ttid,
810
- doc: T
457
+ _doc: T
811
458
  ) {
812
- const keys = await Dir.extractKeys(collection, docId, doc)
813
- const db = this.database(collection)
814
- const remove = db.query(`
815
- DELETE FROM doc_index_entries
816
- WHERE doc_id = ? AND field_path = ? AND value_hash = ?
817
- `)
818
-
819
- const transaction = db.transaction((logicalKeys: string[]) => {
820
- for (const logicalKey of logicalKeys) {
821
- const segments = logicalKey.split('/')
822
- const fieldPath = segments.slice(0, -2).join('/')
823
- const rawValue = segments.at(-2) ?? ''
824
- remove.run(docId, fieldPath, this.hash(rawValue))
825
- }
826
- })
827
-
828
- transaction(keys.indexes)
459
+ const cache = await this.loadIndexCache(collection)
460
+ const existingEntries = cache.docs.get(docId) ?? []
461
+ for (const entry of existingEntries) this.deleteEntryFromCache(cache, docId, entry)
462
+ cache.docs.delete(docId)
463
+ await this.writeIndexFile(collection, cache)
829
464
  }
830
465
 
831
466
  async putDocument<T extends Record<string, any>>(collection: string, docId: _ttid, doc: T) {
832
- const owner = Bun.randomUUIDv7()
833
- if (!(await this.locks.acquire(collection, docId, owner)))
834
- throw new Error(`Unable to acquire filesystem lock for ${docId}`)
467
+ await this.withCollectionWriteLock(collection, async () => {
468
+ const owner = Bun.randomUUIDv7()
469
+ if (!(await this.locks.acquire(collection, docId, owner)))
470
+ throw new Error(`Unable to acquire filesystem lock for ${docId}`)
835
471
 
836
- try {
837
- await this.writeStoredDoc(collection, docId, doc)
838
- await this.rebuildIndexes(collection, docId, doc)
839
- await this.events.publish(collection, {
840
- ts: Date.now(),
841
- action: 'insert',
842
- id: docId,
843
- doc
844
- })
845
- } finally {
846
- await this.locks.release(collection, docId, owner)
847
- }
472
+ const targetPath = this.docPath(collection, docId)
473
+
474
+ try {
475
+ await this.documents.writeStoredDoc(collection, docId, doc)
476
+ await this.rebuildIndexes(collection, docId, doc)
477
+ await this.events.publish(collection, {
478
+ ts: Date.now(),
479
+ action: 'insert',
480
+ id: docId,
481
+ doc
482
+ })
483
+ await this.runSyncTask(collection, docId, 'put', targetPath, async () => {
484
+ await this.syncWrite({
485
+ operation: 'put',
486
+ collection,
487
+ docId,
488
+ path: targetPath,
489
+ data: doc
490
+ })
491
+ })
492
+ } finally {
493
+ await this.locks.release(collection, docId, owner)
494
+ }
495
+ })
848
496
  }
849
497
 
850
498
  async patchDocument<T extends Record<string, any>>(
@@ -854,56 +502,90 @@ export class S3FilesEngine {
854
502
  patch: Partial<T>,
855
503
  oldDoc?: T
856
504
  ) {
857
- const owner = Bun.randomUUIDv7()
858
- if (!(await this.locks.acquire(collection, oldId, owner)))
859
- throw new Error(`Unable to acquire filesystem lock for ${oldId}`)
505
+ return await this.withCollectionWriteLock(collection, async () => {
506
+ const owner = Bun.randomUUIDv7()
507
+ if (!(await this.locks.acquire(collection, oldId, owner)))
508
+ throw new Error(`Unable to acquire filesystem lock for ${oldId}`)
860
509
 
861
- try {
862
- const existing = oldDoc ?? (await this.readStoredDoc<T>(collection, oldId))?.data
863
- if (!existing) return oldId
864
-
865
- const nextDoc = { ...existing, ...patch } as T
866
- await this.removeIndexes(collection, oldId, existing)
867
- await this.removeStoredDoc(collection, oldId)
868
- await this.events.publish(collection, {
869
- ts: Date.now(),
870
- action: 'delete',
871
- id: oldId,
872
- doc: existing
873
- })
874
- await this.writeStoredDoc(collection, newId, nextDoc)
875
- await this.rebuildIndexes(collection, newId, nextDoc)
876
- await this.events.publish(collection, {
877
- ts: Date.now(),
878
- action: 'insert',
879
- id: newId,
880
- doc: nextDoc
881
- })
882
- return newId
883
- } finally {
884
- await this.locks.release(collection, oldId, owner)
885
- }
510
+ const oldPath = this.docPath(collection, oldId)
511
+
512
+ try {
513
+ const existing =
514
+ oldDoc ?? (await this.documents.readStoredDoc<T>(collection, oldId))?.data
515
+ if (!existing) return oldId
516
+
517
+ const nextDoc = { ...existing, ...patch } as T
518
+ const newPath = this.docPath(collection, newId)
519
+ await this.removeIndexes(collection, oldId, existing)
520
+ await this.documents.removeStoredDoc(collection, oldId)
521
+ await this.events.publish(collection, {
522
+ ts: Date.now(),
523
+ action: 'delete',
524
+ id: oldId,
525
+ doc: existing
526
+ })
527
+ await this.documents.writeStoredDoc(collection, newId, nextDoc)
528
+ await this.rebuildIndexes(collection, newId, nextDoc)
529
+ await this.events.publish(collection, {
530
+ ts: Date.now(),
531
+ action: 'insert',
532
+ id: newId,
533
+ doc: nextDoc
534
+ })
535
+ await this.runSyncTask(collection, newId, 'patch', newPath, async () => {
536
+ await this.syncDelete({
537
+ operation: 'patch',
538
+ collection,
539
+ docId: oldId,
540
+ path: oldPath
541
+ })
542
+ await this.syncWrite({
543
+ operation: 'patch',
544
+ collection,
545
+ docId: newId,
546
+ previousDocId: oldId,
547
+ path: newPath,
548
+ data: nextDoc
549
+ })
550
+ })
551
+ return newId
552
+ } finally {
553
+ await this.locks.release(collection, oldId, owner)
554
+ }
555
+ })
886
556
  }
887
557
 
888
558
  async deleteDocument<T extends Record<string, any>>(collection: string, docId: _ttid) {
889
- const owner = Bun.randomUUIDv7()
890
- if (!(await this.locks.acquire(collection, docId, owner)))
891
- throw new Error(`Unable to acquire filesystem lock for ${docId}`)
559
+ await this.withCollectionWriteLock(collection, async () => {
560
+ const owner = Bun.randomUUIDv7()
561
+ if (!(await this.locks.acquire(collection, docId, owner)))
562
+ throw new Error(`Unable to acquire filesystem lock for ${docId}`)
892
563
 
893
- try {
894
- const existing = await this.readStoredDoc<T>(collection, docId)
895
- if (!existing) return
896
- await this.removeIndexes(collection, docId, existing.data)
897
- await this.removeStoredDoc(collection, docId)
898
- await this.events.publish(collection, {
899
- ts: Date.now(),
900
- action: 'delete',
901
- id: docId,
902
- doc: existing.data
903
- })
904
- } finally {
905
- await this.locks.release(collection, docId, owner)
906
- }
564
+ const targetPath = this.docPath(collection, docId)
565
+
566
+ try {
567
+ const existing = await this.documents.readStoredDoc<T>(collection, docId)
568
+ if (!existing) return
569
+ await this.removeIndexes(collection, docId, existing.data)
570
+ await this.documents.removeStoredDoc(collection, docId)
571
+ await this.events.publish(collection, {
572
+ ts: Date.now(),
573
+ action: 'delete',
574
+ id: docId,
575
+ doc: existing.data
576
+ })
577
+ await this.runSyncTask(collection, docId, 'delete', targetPath, async () => {
578
+ await this.syncDelete({
579
+ operation: 'delete',
580
+ collection,
581
+ docId,
582
+ path: targetPath
583
+ })
584
+ })
585
+ } finally {
586
+ await this.locks.release(collection, docId, owner)
587
+ }
588
+ })
907
589
  }
908
590
 
909
591
  getDoc<T extends Record<string, any>>(
@@ -924,7 +606,7 @@ export class S3FilesEngine {
924
606
  }
925
607
  },
926
608
  async once() {
927
- const stored = await engine.readStoredDoc<T>(collection, docId)
609
+ const stored = await engine.documents.readStoredDoc<T>(collection, docId)
928
610
  return stored ? ({ [docId]: stored.data } as FyloRecord<T>) : {}
929
611
  },
930
612
  async *onDelete() {
@@ -941,7 +623,7 @@ export class S3FilesEngine {
941
623
  const collectDocs = async function* () {
942
624
  const docs = await engine.docResults(collection, query)
943
625
  for (const doc of docs) {
944
- const result = engine.processDoc(doc, query)
626
+ const result = engine.queryEngine.processDoc(doc, query)
945
627
  if (result !== undefined) yield result
946
628
  }
947
629
  }
@@ -952,8 +634,8 @@ export class S3FilesEngine {
952
634
 
953
635
  for await (const event of engine.events.listen(collection)) {
954
636
  if (event.action !== 'insert' || !event.doc) continue
955
- if (!engine.matchesQuery(event.id, event.doc as T, query)) continue
956
- const processed = engine.processDoc(
637
+ if (!engine.queryEngine.matchesQuery(event.id, event.doc as T, query)) continue
638
+ const processed = engine.queryEngine.processDoc(
957
639
  { [event.id]: event.doc as T } as FyloRecord<T>,
958
640
  query
959
641
  )
@@ -966,7 +648,7 @@ export class S3FilesEngine {
966
648
  async *onDelete() {
967
649
  for await (const event of engine.events.listen(collection)) {
968
650
  if (event.action !== 'delete' || !event.doc) continue
969
- if (!engine.matchesQuery(event.id, event.doc as T, query)) continue
651
+ if (!engine.queryEngine.matchesQuery(event.id, event.doc as T, query)) continue
970
652
  yield event.id
971
653
  }
972
654
  }
@@ -974,9 +656,9 @@ export class S3FilesEngine {
974
656
  }
975
657
 
976
658
  async *exportBulkData<T extends Record<string, any>>(collection: string) {
977
- const ids = await this.listDocIds(collection)
659
+ const ids = await this.documents.listDocIds(collection)
978
660
  for (const id of ids) {
979
- const stored = await this.readStoredDoc<T>(collection, id)
661
+ const stored = await this.documents.readStoredDoc<T>(collection, id)
980
662
  if (stored) yield stored.data
981
663
  }
982
664
  }
@@ -1009,11 +691,11 @@ export class S3FilesEngine {
1009
691
  for (const opKey of Object.keys(compareMap) as Array<keyof typeof compareMap>) {
1010
692
  const rightField = operand[opKey]
1011
693
  if (!rightField) continue
1012
- const leftValue = this.getValueByPath(
694
+ const leftValue = this.queryEngine.getValueByPath(
1013
695
  leftData as Record<string, any>,
1014
696
  String(field)
1015
697
  )
1016
- const rightValue = this.getValueByPath(
698
+ const rightValue = this.queryEngine.getValueByPath(
1017
699
  rightData as Record<string, any>,
1018
700
  String(rightField)
1019
701
  )
@@ -1038,6 +720,25 @@ export class S3FilesEngine {
1038
720
  break
1039
721
  }
1040
722
 
723
+ let projected = docs[`${leftId}, ${rightId}`] as Record<string, any>
724
+ if (join.$select?.length) {
725
+ projected = this.queryEngine.selectValues(
726
+ join.$select as Array<keyof typeof projected>,
727
+ projected
728
+ )
729
+ }
730
+ if (join.$rename) {
731
+ projected = this.queryEngine.renameFields(
732
+ join.$rename as Record<string, string>,
733
+ projected
734
+ )
735
+ }
736
+ docs[`${leftId}, ${rightId}`] = projected as
737
+ | T
738
+ | U
739
+ | (T & U)
740
+ | (Partial<T> & Partial<U>)
741
+
1041
742
  if (join.$limit && Object.keys(docs).length >= join.$limit) break
1042
743
  }
1043
744