@delma/fylo 2.0.0 → 2.0.1
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 +1 -1
- package/package.json +1 -1
- package/src/engines/s3-files.ts +181 -1
- package/tests/integration/s3-files.test.js +84 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
NoSQL document store with SQL parsing, real-time listeners, and Bun-first workflows.
|
|
4
4
|
|
|
5
|
-
Fylo `2.0.
|
|
5
|
+
Fylo `2.0.1` supports two storage engines:
|
|
6
6
|
|
|
7
7
|
- `legacy-s3`: the existing S3 + Redis architecture with queued writes, bucket-per-collection storage, and Redis-backed pub/sub/locks.
|
|
8
8
|
- `s3-files`: a new AWS S3 Files mode that stores canonical documents on a mounted S3 Files filesystem, keeps query indexes in a collection-level SQLite database under `.fylo/index.db`, and uses filesystem locks plus an append-only event journal instead of Redis.
|
package/package.json
CHANGED
package/src/engines/s3-files.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdir, readFile, readdir, rm, stat, writeFile, open } from 'node:fs/pro
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import { Database } from 'bun:sqlite'
|
|
5
|
+
import type { SQLQueryBindings } from 'bun:sqlite'
|
|
5
6
|
import TTID from '@delma/ttid'
|
|
6
7
|
import { Dir } from '../core/directory'
|
|
7
8
|
import { validateCollectionName } from '../core/collection'
|
|
@@ -440,6 +441,7 @@ export class S3FilesEngine {
|
|
|
440
441
|
|
|
441
442
|
private getValueByPath(target: Record<string, any>, fieldPath: string) {
|
|
442
443
|
return fieldPath
|
|
444
|
+
.replaceAll('/', '.')
|
|
443
445
|
.split('.')
|
|
444
446
|
.reduce<any>(
|
|
445
447
|
(acc, key) => (acc === undefined || acc === null ? undefined : acc[key]),
|
|
@@ -447,6 +449,10 @@ export class S3FilesEngine {
|
|
|
447
449
|
)
|
|
448
450
|
}
|
|
449
451
|
|
|
452
|
+
private normalizeFieldPath(fieldPath: string) {
|
|
453
|
+
return fieldPath.replaceAll('.', '/')
|
|
454
|
+
}
|
|
455
|
+
|
|
450
456
|
private matchesTimestamp(docId: _ttid, query?: _storeQuery<Record<string, any>>) {
|
|
451
457
|
if (!query?.$created && !query?.$updated) return true
|
|
452
458
|
const { createdAt, updatedAt } = TTID.decodeTime(docId)
|
|
@@ -491,6 +497,179 @@ export class S3FilesEngine {
|
|
|
491
497
|
return true
|
|
492
498
|
}
|
|
493
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
|
+
|
|
494
673
|
private matchesQuery<T extends Record<string, any>>(
|
|
495
674
|
docId: _ttid,
|
|
496
675
|
doc: T,
|
|
@@ -576,7 +755,8 @@ export class S3FilesEngine {
|
|
|
576
755
|
collection: string,
|
|
577
756
|
query?: _storeQuery<T>
|
|
578
757
|
) {
|
|
579
|
-
const
|
|
758
|
+
const candidateIds = await this.candidateDocIdsForQuery(collection, query)
|
|
759
|
+
const ids = candidateIds ? Array.from(candidateIds) : await this.listDocIds(collection)
|
|
580
760
|
const limit = query?.$limit
|
|
581
761
|
const results: Array<FyloRecord<T>> = []
|
|
582
762
|
|
|
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
|
2
2
|
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
|
3
3
|
import os from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
+
import { Database } from 'bun:sqlite'
|
|
5
6
|
import Fylo from '../../src'
|
|
6
7
|
const root = await mkdtemp(path.join(os.tmpdir(), 'fylo-s3files-'))
|
|
7
8
|
const fylo = new Fylo({ engine: 's3-files', s3FilesRoot: root })
|
|
@@ -64,6 +65,89 @@ describe('s3-files engine', () => {
|
|
|
64
65
|
expect(dbStat.isFile()).toBe(true)
|
|
65
66
|
await expect(stat(path.join(root, POSTS, '.fylo', 'indexes'))).rejects.toThrow()
|
|
66
67
|
})
|
|
68
|
+
test('uses SQLite index rows to support exact, range, and contains queries', async () => {
|
|
69
|
+
const queryCollection = 's3files-query'
|
|
70
|
+
await fylo.createCollection(queryCollection)
|
|
71
|
+
|
|
72
|
+
const bunId = await fylo.putData(queryCollection, {
|
|
73
|
+
title: 'Bun launch',
|
|
74
|
+
tags: ['bun', 'aws'],
|
|
75
|
+
meta: { score: 10 }
|
|
76
|
+
})
|
|
77
|
+
const nodeId = await fylo.putData(queryCollection, {
|
|
78
|
+
title: 'Node launch',
|
|
79
|
+
tags: ['node'],
|
|
80
|
+
meta: { score: 2 }
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
let eqResults = {}
|
|
84
|
+
for await (const data of fylo
|
|
85
|
+
.findDocs(queryCollection, {
|
|
86
|
+
$ops: [{ title: { $eq: 'Bun launch' } }]
|
|
87
|
+
})
|
|
88
|
+
.collect()) {
|
|
89
|
+
eqResults = { ...eqResults, ...data }
|
|
90
|
+
}
|
|
91
|
+
expect(Object.keys(eqResults)).toEqual([bunId])
|
|
92
|
+
|
|
93
|
+
let rangeResults = {}
|
|
94
|
+
for await (const data of fylo
|
|
95
|
+
.findDocs(queryCollection, {
|
|
96
|
+
$ops: [{ ['meta.score']: { $gte: 5 } }]
|
|
97
|
+
})
|
|
98
|
+
.collect()) {
|
|
99
|
+
rangeResults = { ...rangeResults, ...data }
|
|
100
|
+
}
|
|
101
|
+
expect(Object.keys(rangeResults)).toEqual([bunId])
|
|
102
|
+
|
|
103
|
+
let containsResults = {}
|
|
104
|
+
for await (const data of fylo
|
|
105
|
+
.findDocs(queryCollection, {
|
|
106
|
+
$ops: [{ tags: { $contains: 'aws' } }]
|
|
107
|
+
})
|
|
108
|
+
.collect()) {
|
|
109
|
+
containsResults = { ...containsResults, ...data }
|
|
110
|
+
}
|
|
111
|
+
expect(Object.keys(containsResults)).toEqual([bunId])
|
|
112
|
+
expect(containsResults[nodeId]).toBeUndefined()
|
|
113
|
+
|
|
114
|
+
const db = new Database(path.join(root, queryCollection, '.fylo', 'index.db'))
|
|
115
|
+
const rows = db
|
|
116
|
+
.query(
|
|
117
|
+
`SELECT doc_id, field_path, raw_value, value_type, numeric_value
|
|
118
|
+
FROM doc_index_entries
|
|
119
|
+
WHERE doc_id = ?
|
|
120
|
+
ORDER BY field_path, raw_value`
|
|
121
|
+
)
|
|
122
|
+
.all(bunId)
|
|
123
|
+
db.close()
|
|
124
|
+
|
|
125
|
+
expect(rows).toEqual(
|
|
126
|
+
expect.arrayContaining([
|
|
127
|
+
expect.objectContaining({
|
|
128
|
+
doc_id: bunId,
|
|
129
|
+
field_path: 'title',
|
|
130
|
+
raw_value: 'Bun launch',
|
|
131
|
+
value_type: 'string',
|
|
132
|
+
numeric_value: null
|
|
133
|
+
}),
|
|
134
|
+
expect.objectContaining({
|
|
135
|
+
doc_id: bunId,
|
|
136
|
+
field_path: 'meta/score',
|
|
137
|
+
raw_value: '10',
|
|
138
|
+
value_type: 'number',
|
|
139
|
+
numeric_value: 10
|
|
140
|
+
}),
|
|
141
|
+
expect.objectContaining({
|
|
142
|
+
doc_id: bunId,
|
|
143
|
+
field_path: 'tags/1',
|
|
144
|
+
raw_value: 'aws',
|
|
145
|
+
value_type: 'string',
|
|
146
|
+
numeric_value: null
|
|
147
|
+
})
|
|
148
|
+
])
|
|
149
|
+
)
|
|
150
|
+
})
|
|
67
151
|
test('joins work in s3-files mode', async () => {
|
|
68
152
|
const userId = await fylo.putData(USERS, { id: 42, name: 'Ada' })
|
|
69
153
|
const postId = await fylo.putData(POSTS, { id: 42, title: 'Shared', content: 'join me' })
|