@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 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.0` supports two storage engines:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delma/fylo",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/types/index.d.ts",
6
6
  "bin": {
@@ -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 ids = await this.listDocIds(collection)
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' })