@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.
- package/README.md +185 -267
- package/package.json +2 -5
- package/src/core/directory.ts +22 -354
- package/src/engines/s3-files/documents.ts +65 -0
- package/src/engines/s3-files/filesystem.ts +172 -0
- package/src/engines/s3-files/query.ts +291 -0
- package/src/engines/s3-files/types.ts +42 -0
- package/src/engines/s3-files.ts +391 -690
- package/src/engines/types.ts +1 -1
- package/src/index.ts +142 -1237
- package/src/sync.ts +58 -0
- package/src/types/fylo.d.ts +66 -161
- package/src/types/node-runtime.d.ts +1 -0
- package/tests/collection/truncate.test.js +11 -10
- package/tests/helpers/root.js +7 -0
- package/tests/integration/create.test.js +9 -9
- package/tests/integration/delete.test.js +16 -14
- package/tests/integration/edge-cases.test.js +29 -25
- package/tests/integration/encryption.test.js +47 -30
- package/tests/integration/export.test.js +11 -11
- package/tests/integration/join-modes.test.js +16 -16
- package/tests/integration/nested.test.js +26 -24
- package/tests/integration/operators.test.js +43 -29
- package/tests/integration/read.test.js +25 -21
- package/tests/integration/rollback.test.js +21 -51
- package/tests/integration/s3-files.performance.test.js +75 -0
- package/tests/integration/s3-files.test.js +57 -44
- package/tests/integration/sync.test.js +154 -0
- package/tests/integration/update.test.js +24 -18
- package/src/adapters/redis.ts +0 -487
- package/src/adapters/s3.ts +0 -61
- package/src/core/walker.ts +0 -174
- package/src/core/write-queue.ts +0 -59
- package/src/migrate-cli.ts +0 -22
- package/src/migrate.ts +0 -74
- package/src/types/write-queue.ts +0 -42
- package/src/worker.ts +0 -18
- package/src/workers/write-worker.ts +0 -120
- package/tests/index.js +0 -14
- package/tests/integration/migration.test.js +0 -38
- package/tests/integration/queue.test.js +0 -83
- package/tests/mocks/redis.js +0 -123
- package/tests/mocks/s3.js +0 -80
package/src/engines/s3-files.ts
CHANGED
|
@@ -1,275 +1,256 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
69
|
+
private collectionRoot(collection: string) {
|
|
70
|
+
validateCollectionName(collection)
|
|
71
|
+
return path.join(this.root, collection)
|
|
47
72
|
}
|
|
48
73
|
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
70
|
-
|
|
78
|
+
private metaRoot(collection: string) {
|
|
79
|
+
return path.join(this.collectionRoot(collection), '.fylo')
|
|
71
80
|
}
|
|
72
81
|
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
private indexesRoot(collection: string) {
|
|
83
|
+
return path.join(this.metaRoot(collection), 'indexes')
|
|
75
84
|
}
|
|
76
85
|
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
|
94
|
+
private async runSyncTask(
|
|
99
95
|
collection: string,
|
|
100
96
|
docId: _ttid,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
154
|
-
|
|
136
|
+
private async syncDelete(event: FyloDeleteSyncEvent) {
|
|
137
|
+
if (!this.sync?.onDelete) return
|
|
138
|
+
await this.sync.onDelete(event)
|
|
155
139
|
}
|
|
156
140
|
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
222
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
223
|
+
const payload: StoredCollectionIndex = {
|
|
224
|
+
version: 1,
|
|
225
|
+
docs: Object.fromEntries(cache.docs)
|
|
226
|
+
}
|
|
236
227
|
|
|
237
|
-
|
|
238
|
-
|
|
228
|
+
await writeFile(temp, JSON.stringify(payload), 'utf8')
|
|
229
|
+
await rename(temp, target)
|
|
239
230
|
}
|
|
240
231
|
|
|
241
|
-
private
|
|
242
|
-
const
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
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
|
-
|
|
457
|
+
_doc: T
|
|
811
458
|
) {
|
|
812
|
-
const
|
|
813
|
-
const
|
|
814
|
-
const
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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
|
|