@delma/fylo 1.1.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/copilot-instructions.md +1 -1
- package/.github/prompts/release.prompt.md +4 -43
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/README.md +141 -62
- package/eslint.config.js +8 -4
- package/package.json +9 -7
- package/src/CLI +16 -14
- package/src/adapters/cipher.ts +12 -6
- package/src/adapters/redis.ts +193 -123
- package/src/adapters/s3.ts +6 -12
- package/src/core/collection.ts +5 -0
- package/src/core/directory.ts +120 -151
- package/src/core/extensions.ts +4 -2
- package/src/core/format.ts +390 -419
- package/src/core/parser.ts +167 -142
- package/src/core/query.ts +31 -26
- package/src/core/walker.ts +68 -61
- package/src/core/write-queue.ts +7 -4
- package/src/engines/s3-files.ts +888 -0
- package/src/engines/types.ts +21 -0
- package/src/index.ts +754 -378
- package/src/migrate-cli.ts +22 -0
- package/src/migrate.ts +74 -0
- package/src/types/bun-runtime.d.ts +73 -0
- package/src/types/fylo.d.ts +115 -27
- package/src/types/node-runtime.d.ts +61 -0
- package/src/types/query.d.ts +6 -2
- package/src/types/vendor-modules.d.ts +8 -7
- package/src/worker.ts +7 -1
- package/src/workers/write-worker.ts +25 -24
- package/tests/collection/truncate.test.js +35 -0
- package/tests/{data.ts → data.js} +8 -21
- package/tests/{index.ts → index.js} +4 -9
- package/tests/integration/aws-s3-files.canary.test.js +22 -0
- package/tests/integration/{create.test.ts → create.test.js} +13 -31
- package/tests/integration/delete.test.js +95 -0
- package/tests/integration/{edge-cases.test.ts → edge-cases.test.js} +50 -124
- package/tests/integration/{encryption.test.ts → encryption.test.js} +20 -65
- package/tests/integration/{export.test.ts → export.test.js} +8 -23
- package/tests/integration/{join-modes.test.ts → join-modes.test.js} +37 -104
- package/tests/integration/migration.test.js +38 -0
- package/tests/integration/nested.test.js +142 -0
- package/tests/integration/operators.test.js +122 -0
- package/tests/integration/{queue.test.ts → queue.test.js} +24 -40
- package/tests/integration/read.test.js +119 -0
- package/tests/integration/rollback.test.js +60 -0
- package/tests/integration/s3-files.test.js +108 -0
- package/tests/integration/update.test.js +99 -0
- package/tests/mocks/{cipher.ts → cipher.js} +11 -26
- package/tests/mocks/redis.js +123 -0
- package/tests/mocks/{s3.ts → s3.js} +24 -58
- package/tests/schemas/album.json +1 -1
- package/tests/schemas/comment.json +1 -1
- package/tests/schemas/photo.json +1 -1
- package/tests/schemas/post.json +1 -1
- package/tests/schemas/tip.json +1 -1
- package/tests/schemas/todo.json +1 -1
- package/tests/schemas/user.d.ts +12 -12
- package/tests/schemas/user.json +1 -1
- package/tsconfig.json +4 -2
- package/tsconfig.typecheck.json +31 -0
- package/.github/prompts/issue.prompt.md +0 -19
- package/.github/prompts/pr.prompt.md +0 -18
- package/.github/prompts/review-pr.prompt.md +0 -19
- package/.github/prompts/sync-main.prompt.md +0 -14
- package/tests/collection/truncate.test.ts +0 -56
- package/tests/integration/delete.test.ts +0 -147
- package/tests/integration/nested.test.ts +0 -212
- package/tests/integration/operators.test.ts +0 -167
- package/tests/integration/read.test.ts +0 -203
- package/tests/integration/rollback.test.ts +0 -105
- package/tests/integration/update.test.ts +0 -130
- package/tests/mocks/redis.ts +0 -169
|
@@ -3,95 +3,68 @@ import Fylo from '../../src'
|
|
|
3
3
|
import S3Mock from '../mocks/s3'
|
|
4
4
|
import RedisMock from '../mocks/redis'
|
|
5
5
|
import { CipherMock } from '../mocks/cipher'
|
|
6
|
-
|
|
7
6
|
const COLLECTION = 'encrypted-test'
|
|
8
|
-
|
|
9
7
|
const fylo = new Fylo()
|
|
10
|
-
|
|
11
8
|
mock.module('../../src/adapters/s3', () => ({ S3: S3Mock }))
|
|
12
9
|
mock.module('../../src/adapters/redis', () => ({ Redis: RedisMock }))
|
|
13
10
|
mock.module('../../src/adapters/cipher', () => ({ Cipher: CipherMock }))
|
|
14
|
-
|
|
15
11
|
beforeAll(async () => {
|
|
16
12
|
await Fylo.createCollection(COLLECTION)
|
|
17
13
|
await CipherMock.configure('test-secret-key')
|
|
18
14
|
CipherMock.registerFields(COLLECTION, ['email', 'ssn', 'address'])
|
|
19
15
|
})
|
|
20
|
-
|
|
21
16
|
afterAll(async () => {
|
|
22
17
|
CipherMock.reset()
|
|
23
18
|
await Fylo.dropCollection(COLLECTION)
|
|
24
19
|
})
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
let docId: _ttid
|
|
29
|
-
|
|
30
|
-
test("PUT encrypted document", async () => {
|
|
31
|
-
|
|
20
|
+
describe('Encryption', () => {
|
|
21
|
+
let docId
|
|
22
|
+
test('PUT encrypted document', async () => {
|
|
32
23
|
docId = await fylo.putData(COLLECTION, {
|
|
33
24
|
name: 'Alice',
|
|
34
25
|
email: 'alice@example.com',
|
|
35
26
|
ssn: '123-45-6789',
|
|
36
27
|
age: 30
|
|
37
28
|
})
|
|
38
|
-
|
|
39
29
|
expect(docId).toBeDefined()
|
|
40
30
|
})
|
|
41
|
-
|
|
42
|
-
test("GET decrypts fields transparently", async () => {
|
|
43
|
-
|
|
31
|
+
test('GET decrypts fields transparently', async () => {
|
|
44
32
|
const result = await Fylo.getDoc(COLLECTION, docId).once()
|
|
45
33
|
const doc = Object.values(result)[0]
|
|
46
|
-
|
|
47
34
|
expect(doc.name).toBe('Alice')
|
|
48
35
|
expect(doc.email).toBe('alice@example.com')
|
|
49
36
|
expect(doc.ssn).toBe('123-45-6789')
|
|
50
37
|
expect(doc.age).toBe(30)
|
|
51
38
|
})
|
|
52
|
-
|
|
53
|
-
test("encrypted values stored in S3 keys are not plaintext", () => {
|
|
54
|
-
|
|
39
|
+
test('encrypted values stored in S3 keys are not plaintext', () => {
|
|
55
40
|
const bucket = S3Mock.getBucketFormat(COLLECTION)
|
|
56
|
-
|
|
57
|
-
// Verify the bucket was created and doc round-trip works
|
|
58
|
-
// (plaintext values should not appear as raw key segments)
|
|
59
41
|
expect(bucket).toBeDefined()
|
|
60
42
|
})
|
|
61
|
-
|
|
62
|
-
test("$eq query works on encrypted field", async () => {
|
|
63
|
-
|
|
43
|
+
test('$eq query works on encrypted field', async () => {
|
|
64
44
|
let found = false
|
|
65
|
-
|
|
66
45
|
for await (const data of Fylo.findDocs(COLLECTION, {
|
|
67
46
|
$ops: [{ email: { $eq: 'alice@example.com' } }]
|
|
68
47
|
}).collect()) {
|
|
69
|
-
|
|
70
|
-
if(typeof data === 'object') {
|
|
48
|
+
if (typeof data === 'object') {
|
|
71
49
|
const doc = Object.values(data)[0]
|
|
72
50
|
expect(doc.email).toBe('alice@example.com')
|
|
73
51
|
found = true
|
|
74
52
|
}
|
|
75
53
|
}
|
|
76
|
-
|
|
77
54
|
expect(found).toBe(true)
|
|
78
55
|
})
|
|
79
|
-
|
|
80
|
-
test("$ne throws on encrypted field", async () => {
|
|
81
|
-
|
|
56
|
+
test('$ne throws on encrypted field', async () => {
|
|
82
57
|
try {
|
|
83
58
|
const iter = Fylo.findDocs(COLLECTION, {
|
|
84
59
|
$ops: [{ email: { $ne: 'bob@example.com' } }]
|
|
85
60
|
}).collect()
|
|
86
61
|
await iter.next()
|
|
87
|
-
expect(true).toBe(false)
|
|
62
|
+
expect(true).toBe(false)
|
|
88
63
|
} catch (e) {
|
|
89
|
-
expect(
|
|
64
|
+
expect(e.message).toContain('not supported on encrypted field')
|
|
90
65
|
}
|
|
91
66
|
})
|
|
92
|
-
|
|
93
|
-
test("$gt throws on encrypted field", async () => {
|
|
94
|
-
|
|
67
|
+
test('$gt throws on encrypted field', async () => {
|
|
95
68
|
try {
|
|
96
69
|
const iter = Fylo.findDocs(COLLECTION, {
|
|
97
70
|
$ops: [{ ssn: { $gt: 0 } }]
|
|
@@ -99,12 +72,10 @@ describe("Encryption", () => {
|
|
|
99
72
|
await iter.next()
|
|
100
73
|
expect(true).toBe(false)
|
|
101
74
|
} catch (e) {
|
|
102
|
-
expect(
|
|
75
|
+
expect(e.message).toContain('not supported on encrypted field')
|
|
103
76
|
}
|
|
104
77
|
})
|
|
105
|
-
|
|
106
|
-
test("$like throws on encrypted field", async () => {
|
|
107
|
-
|
|
78
|
+
test('$like throws on encrypted field', async () => {
|
|
108
79
|
try {
|
|
109
80
|
const iter = Fylo.findDocs(COLLECTION, {
|
|
110
81
|
$ops: [{ email: { $like: '%@example.com' } }]
|
|
@@ -112,30 +83,23 @@ describe("Encryption", () => {
|
|
|
112
83
|
await iter.next()
|
|
113
84
|
expect(true).toBe(false)
|
|
114
85
|
} catch (e) {
|
|
115
|
-
expect(
|
|
86
|
+
expect(e.message).toContain('not supported on encrypted field')
|
|
116
87
|
}
|
|
117
88
|
})
|
|
118
|
-
|
|
119
|
-
test("non-encrypted fields remain queryable with all operators", async () => {
|
|
120
|
-
|
|
89
|
+
test('non-encrypted fields remain queryable with all operators', async () => {
|
|
121
90
|
let found = false
|
|
122
|
-
|
|
123
91
|
for await (const data of Fylo.findDocs(COLLECTION, {
|
|
124
92
|
$ops: [{ name: { $eq: 'Alice' } }]
|
|
125
93
|
}).collect()) {
|
|
126
|
-
|
|
127
|
-
if(typeof data === 'object') {
|
|
94
|
+
if (typeof data === 'object') {
|
|
128
95
|
const doc = Object.values(data)[0]
|
|
129
96
|
expect(doc.name).toBe('Alice')
|
|
130
97
|
found = true
|
|
131
98
|
}
|
|
132
99
|
}
|
|
133
|
-
|
|
134
100
|
expect(found).toBe(true)
|
|
135
101
|
})
|
|
136
|
-
|
|
137
|
-
test("nested encrypted field (address.city)", async () => {
|
|
138
|
-
|
|
102
|
+
test('nested encrypted field (address.city)', async () => {
|
|
139
103
|
const id = await fylo.putData(COLLECTION, {
|
|
140
104
|
name: 'Bob',
|
|
141
105
|
email: 'bob@example.com',
|
|
@@ -143,34 +107,25 @@ describe("Encryption", () => {
|
|
|
143
107
|
age: 25,
|
|
144
108
|
address: { city: 'Toronto', zip: 'M5V 2T6' }
|
|
145
109
|
})
|
|
146
|
-
|
|
147
110
|
const result = await Fylo.getDoc(COLLECTION, id).once()
|
|
148
111
|
const doc = Object.values(result)[0]
|
|
149
|
-
|
|
150
112
|
expect(doc.address.city).toBe('Toronto')
|
|
151
113
|
expect(doc.address.zip).toBe('M5V 2T6')
|
|
152
114
|
})
|
|
153
|
-
|
|
154
|
-
test("UPDATE preserves encryption", async () => {
|
|
155
|
-
|
|
115
|
+
test('UPDATE preserves encryption', async () => {
|
|
156
116
|
await fylo.patchDoc(COLLECTION, {
|
|
157
117
|
[docId]: { email: 'alice-new@example.com' }
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Find the updated doc (patchDoc generates new TTID)
|
|
118
|
+
})
|
|
161
119
|
let found = false
|
|
162
|
-
|
|
163
120
|
for await (const data of Fylo.findDocs(COLLECTION, {
|
|
164
121
|
$ops: [{ email: { $eq: 'alice-new@example.com' } }]
|
|
165
122
|
}).collect()) {
|
|
166
|
-
|
|
167
|
-
if(typeof data === 'object') {
|
|
123
|
+
if (typeof data === 'object') {
|
|
168
124
|
const doc = Object.values(data)[0]
|
|
169
125
|
expect(doc.email).toBe('alice-new@example.com')
|
|
170
126
|
found = true
|
|
171
127
|
}
|
|
172
128
|
}
|
|
173
|
-
|
|
174
129
|
expect(found).toBe(true)
|
|
175
130
|
})
|
|
176
131
|
})
|
|
@@ -3,56 +3,41 @@ import Fylo from '../../src'
|
|
|
3
3
|
import { postsURL } from '../data'
|
|
4
4
|
import S3Mock from '../mocks/s3'
|
|
5
5
|
import RedisMock from '../mocks/redis'
|
|
6
|
-
|
|
7
6
|
const POSTS = 'exp-post'
|
|
8
7
|
const IMPORT_LIMIT = 20
|
|
9
|
-
|
|
10
8
|
let importedCount = 0
|
|
11
|
-
|
|
12
9
|
const fylo = new Fylo()
|
|
13
|
-
|
|
14
10
|
mock.module('../../src/adapters/s3', () => ({ S3: S3Mock }))
|
|
15
11
|
mock.module('../../src/adapters/redis', () => ({ Redis: RedisMock }))
|
|
16
|
-
|
|
17
12
|
beforeAll(async () => {
|
|
18
13
|
await Fylo.createCollection(POSTS)
|
|
19
14
|
try {
|
|
20
|
-
importedCount = await fylo.importBulkData
|
|
15
|
+
importedCount = await fylo.importBulkData(POSTS, new URL(postsURL), IMPORT_LIMIT)
|
|
21
16
|
} catch {
|
|
22
17
|
await fylo.rollback()
|
|
23
18
|
}
|
|
24
19
|
})
|
|
25
|
-
|
|
26
20
|
afterAll(async () => {
|
|
27
21
|
await Fylo.dropCollection(POSTS)
|
|
28
22
|
})
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
test("EXPORT count matches import", async () => {
|
|
33
|
-
|
|
23
|
+
describe('NO-SQL', () => {
|
|
24
|
+
test('EXPORT count matches import', async () => {
|
|
34
25
|
let exported = 0
|
|
35
|
-
|
|
36
|
-
for await (const _doc of Fylo.exportBulkData<_post>(POSTS)) {
|
|
26
|
+
for await (const _doc of Fylo.exportBulkData(POSTS)) {
|
|
37
27
|
exported++
|
|
38
28
|
}
|
|
39
|
-
|
|
40
29
|
expect(exported).toBe(importedCount)
|
|
41
30
|
})
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
for await (const doc of Fylo.exportBulkData<_post>(POSTS)) {
|
|
31
|
+
test('EXPORT document shape', async () => {
|
|
32
|
+
for await (const doc of Fylo.exportBulkData(POSTS)) {
|
|
46
33
|
expect(doc).toHaveProperty('title')
|
|
47
34
|
expect(doc).toHaveProperty('userId')
|
|
48
35
|
expect(doc).toHaveProperty('body')
|
|
49
36
|
break
|
|
50
37
|
}
|
|
51
38
|
})
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
for await (const doc of Fylo.exportBulkData<_post>(POSTS)) {
|
|
39
|
+
test('EXPORT all documents are valid posts', async () => {
|
|
40
|
+
for await (const doc of Fylo.exportBulkData(POSTS)) {
|
|
56
41
|
expect(typeof doc.title).toBe('string')
|
|
57
42
|
expect(typeof doc.userId).toBe('number')
|
|
58
43
|
expect(doc.userId).toBeGreaterThan(0)
|
|
@@ -3,218 +3,151 @@ import Fylo from '../../src'
|
|
|
3
3
|
import { albumURL, postsURL } from '../data'
|
|
4
4
|
import S3Mock from '../mocks/s3'
|
|
5
5
|
import RedisMock from '../mocks/redis'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Albums (userId 1–10) and posts (userId 1–10) share a userId field,
|
|
9
|
-
* making them a natural fit for join tests across all four join modes.
|
|
10
|
-
*
|
|
11
|
-
* Join semantics in Fylo:
|
|
12
|
-
* inner → only the join field values
|
|
13
|
-
* left → full left-collection document
|
|
14
|
-
* right → full right-collection document
|
|
15
|
-
* outer → merged left + right documents
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
6
|
const ALBUMS = 'jm-album'
|
|
19
|
-
const POSTS
|
|
20
|
-
|
|
7
|
+
const POSTS = 'jm-post'
|
|
21
8
|
const fylo = new Fylo()
|
|
22
|
-
|
|
23
9
|
mock.module('../../src/adapters/s3', () => ({ S3: S3Mock }))
|
|
24
10
|
mock.module('../../src/adapters/redis', () => ({ Redis: RedisMock }))
|
|
25
|
-
|
|
26
11
|
beforeAll(async () => {
|
|
27
12
|
await Promise.all([Fylo.createCollection(ALBUMS), Fylo.createCollection(POSTS)])
|
|
28
13
|
try {
|
|
29
14
|
await Promise.all([
|
|
30
|
-
fylo.importBulkData
|
|
31
|
-
fylo.importBulkData
|
|
15
|
+
fylo.importBulkData(ALBUMS, new URL(albumURL), 100),
|
|
16
|
+
fylo.importBulkData(POSTS, new URL(postsURL), 100)
|
|
32
17
|
])
|
|
33
18
|
} catch {
|
|
34
19
|
await fylo.rollback()
|
|
35
20
|
}
|
|
36
21
|
})
|
|
37
|
-
|
|
38
22
|
afterAll(async () => {
|
|
39
23
|
await Promise.all([Fylo.dropCollection(ALBUMS), Fylo.dropCollection(POSTS)])
|
|
40
24
|
})
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
test("INNER JOIN — returns only join field values", async () => {
|
|
45
|
-
|
|
46
|
-
const results = await Fylo.joinDocs<_album, _post>({
|
|
25
|
+
describe('NO-SQL', async () => {
|
|
26
|
+
test('INNER JOIN — returns only join field values', async () => {
|
|
27
|
+
const results = await Fylo.joinDocs({
|
|
47
28
|
$leftCollection: ALBUMS,
|
|
48
29
|
$rightCollection: POSTS,
|
|
49
30
|
$mode: 'inner',
|
|
50
31
|
$on: { userId: { $eq: 'userId' } }
|
|
51
|
-
})
|
|
52
|
-
|
|
32
|
+
})
|
|
53
33
|
const pairs = Object.values(results)
|
|
54
|
-
|
|
55
34
|
expect(pairs.length).toBeGreaterThan(0)
|
|
56
|
-
|
|
57
|
-
// inner mode returns only the join fields, not full documents
|
|
58
35
|
for (const pair of pairs) {
|
|
59
36
|
expect(pair).toHaveProperty('userId')
|
|
60
37
|
expect(typeof pair.userId).toBe('number')
|
|
61
38
|
}
|
|
62
39
|
})
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const results = await Fylo.joinDocs<_album, _post>({
|
|
40
|
+
test('LEFT JOIN — returns full left-collection document', async () => {
|
|
41
|
+
const results = await Fylo.joinDocs({
|
|
67
42
|
$leftCollection: ALBUMS,
|
|
68
43
|
$rightCollection: POSTS,
|
|
69
44
|
$mode: 'left',
|
|
70
45
|
$on: { userId: { $eq: 'userId' } }
|
|
71
|
-
})
|
|
72
|
-
|
|
46
|
+
})
|
|
73
47
|
const docs = Object.values(results)
|
|
74
|
-
|
|
75
48
|
expect(docs.length).toBeGreaterThan(0)
|
|
76
|
-
|
|
77
|
-
// left mode returns the full album (left collection) document
|
|
78
49
|
for (const doc of docs) {
|
|
79
50
|
expect(doc).toHaveProperty('title')
|
|
80
51
|
expect(doc).toHaveProperty('userId')
|
|
81
52
|
}
|
|
82
53
|
})
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const results = await Fylo.joinDocs<_album, _post>({
|
|
54
|
+
test('RIGHT JOIN — returns full right-collection document', async () => {
|
|
55
|
+
const results = await Fylo.joinDocs({
|
|
87
56
|
$leftCollection: ALBUMS,
|
|
88
57
|
$rightCollection: POSTS,
|
|
89
58
|
$mode: 'right',
|
|
90
59
|
$on: { userId: { $eq: 'userId' } }
|
|
91
|
-
})
|
|
92
|
-
|
|
60
|
+
})
|
|
93
61
|
const docs = Object.values(results)
|
|
94
|
-
|
|
95
62
|
expect(docs.length).toBeGreaterThan(0)
|
|
96
|
-
|
|
97
|
-
// right mode returns the full post (right collection) document
|
|
98
63
|
for (const doc of docs) {
|
|
99
64
|
expect(doc).toHaveProperty('title')
|
|
100
65
|
expect(doc).toHaveProperty('body')
|
|
101
66
|
expect(doc).toHaveProperty('userId')
|
|
102
67
|
}
|
|
103
68
|
})
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const results = await Fylo.joinDocs<_album, _post>({
|
|
69
|
+
test('OUTER JOIN — returns merged left + right document', async () => {
|
|
70
|
+
const results = await Fylo.joinDocs({
|
|
108
71
|
$leftCollection: ALBUMS,
|
|
109
72
|
$rightCollection: POSTS,
|
|
110
73
|
$mode: 'outer',
|
|
111
74
|
$on: { userId: { $eq: 'userId' } }
|
|
112
|
-
})
|
|
113
|
-
|
|
75
|
+
})
|
|
114
76
|
const docs = Object.values(results)
|
|
115
|
-
|
|
116
77
|
expect(docs.length).toBeGreaterThan(0)
|
|
117
|
-
|
|
118
|
-
// outer mode merges both documents; both should have fields present
|
|
119
78
|
for (const doc of docs) {
|
|
120
79
|
expect(doc).toHaveProperty('userId')
|
|
121
80
|
}
|
|
122
81
|
})
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const results = await Fylo.joinDocs<_album, _post>({
|
|
82
|
+
test('JOIN with $limit — respects the result cap', async () => {
|
|
83
|
+
const results = await Fylo.joinDocs({
|
|
127
84
|
$leftCollection: ALBUMS,
|
|
128
85
|
$rightCollection: POSTS,
|
|
129
86
|
$mode: 'inner',
|
|
130
87
|
$on: { userId: { $eq: 'userId' } },
|
|
131
88
|
$limit: 5
|
|
132
89
|
})
|
|
133
|
-
|
|
134
90
|
expect(Object.keys(results).length).toBe(5)
|
|
135
91
|
})
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const results = await Fylo.joinDocs<_album, _post>({
|
|
92
|
+
test('JOIN with $select — only requested fields are returned', async () => {
|
|
93
|
+
const results = await Fylo.joinDocs({
|
|
140
94
|
$leftCollection: ALBUMS,
|
|
141
95
|
$rightCollection: POSTS,
|
|
142
96
|
$mode: 'left',
|
|
143
97
|
$on: { userId: { $eq: 'userId' } },
|
|
144
98
|
$select: ['title'],
|
|
145
99
|
$limit: 10
|
|
146
|
-
})
|
|
147
|
-
|
|
100
|
+
})
|
|
148
101
|
const docs = Object.values(results)
|
|
149
|
-
|
|
150
102
|
expect(docs.length).toBeGreaterThan(0)
|
|
151
|
-
|
|
152
103
|
for (const doc of docs) {
|
|
153
104
|
expect(doc).toHaveProperty('title')
|
|
154
105
|
expect(doc).not.toHaveProperty('userId')
|
|
155
106
|
}
|
|
156
107
|
})
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const results = await Fylo.joinDocs<_album, _post>({
|
|
108
|
+
test('JOIN with $groupby — groups results by field value', async () => {
|
|
109
|
+
const results = await Fylo.joinDocs({
|
|
161
110
|
$leftCollection: ALBUMS,
|
|
162
111
|
$rightCollection: POSTS,
|
|
163
112
|
$mode: 'inner',
|
|
164
113
|
$on: { userId: { $eq: 'userId' } },
|
|
165
114
|
$groupby: 'userId'
|
|
166
|
-
})
|
|
167
|
-
|
|
115
|
+
})
|
|
168
116
|
expect(Object.keys(results).length).toBeGreaterThan(0)
|
|
169
117
|
})
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const results = await Fylo.joinDocs<_album, _post>({
|
|
118
|
+
test('JOIN with $onlyIds — returns IDs only', async () => {
|
|
119
|
+
const results = await Fylo.joinDocs({
|
|
174
120
|
$leftCollection: ALBUMS,
|
|
175
121
|
$rightCollection: POSTS,
|
|
176
122
|
$mode: 'inner',
|
|
177
123
|
$on: { userId: { $eq: 'userId' } },
|
|
178
124
|
$onlyIds: true,
|
|
179
125
|
$limit: 10
|
|
180
|
-
})
|
|
181
|
-
|
|
126
|
+
})
|
|
182
127
|
expect(Array.isArray(results)).toBe(true)
|
|
183
128
|
expect(results.length).toBeGreaterThan(0)
|
|
184
129
|
})
|
|
185
130
|
})
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
test("INNER JOIN", async () => {
|
|
190
|
-
|
|
191
|
-
const results = await fylo.executeSQL<_album>(
|
|
131
|
+
describe('SQL', async () => {
|
|
132
|
+
test('INNER JOIN', async () => {
|
|
133
|
+
const results = await fylo.executeSQL(
|
|
192
134
|
`SELECT * FROM ${ALBUMS} INNER JOIN ${POSTS} ON userId = userId`
|
|
193
|
-
)
|
|
194
|
-
|
|
135
|
+
)
|
|
195
136
|
expect(Object.keys(results).length).toBeGreaterThan(0)
|
|
196
137
|
})
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const results = await fylo.executeSQL<_album>(
|
|
138
|
+
test('LEFT JOIN', async () => {
|
|
139
|
+
const results = await fylo.executeSQL(
|
|
201
140
|
`SELECT * FROM ${ALBUMS} LEFT JOIN ${POSTS} ON userId = userId`
|
|
202
|
-
)
|
|
203
|
-
|
|
141
|
+
)
|
|
204
142
|
const docs = Object.values(results)
|
|
205
|
-
|
|
206
143
|
expect(docs.length).toBeGreaterThan(0)
|
|
207
144
|
expect(docs[0]).toHaveProperty('title')
|
|
208
145
|
})
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const results = await fylo.executeSQL<_post>(
|
|
146
|
+
test('RIGHT JOIN', async () => {
|
|
147
|
+
const results = await fylo.executeSQL(
|
|
213
148
|
`SELECT * FROM ${ALBUMS} RIGHT JOIN ${POSTS} ON userId = userId`
|
|
214
|
-
)
|
|
215
|
-
|
|
149
|
+
)
|
|
216
150
|
const docs = Object.values(results)
|
|
217
|
-
|
|
218
151
|
expect(docs.length).toBeGreaterThan(0)
|
|
219
152
|
expect(docs[0]).toHaveProperty('body')
|
|
220
153
|
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import S3Mock from '../mocks/s3'
|
|
6
|
+
import RedisMock from '../mocks/redis'
|
|
7
|
+
mock.module('../../src/adapters/s3', () => ({ S3: S3Mock }))
|
|
8
|
+
mock.module('../../src/adapters/redis', () => ({ Redis: RedisMock }))
|
|
9
|
+
const { default: Fylo, migrateLegacyS3ToS3Files } = await import('../../src')
|
|
10
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'fylo-migrate-'))
|
|
11
|
+
const legacyFylo = new Fylo({ engine: 'legacy-s3' })
|
|
12
|
+
const s3FilesFylo = new Fylo({ engine: 's3-files', s3FilesRoot: root })
|
|
13
|
+
const COLLECTION = 'migration-posts'
|
|
14
|
+
describe('legacy-s3 to s3-files migration', () => {
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
await Fylo.createCollection(COLLECTION)
|
|
17
|
+
await legacyFylo.putData(COLLECTION, { id: 1, title: 'Alpha' })
|
|
18
|
+
await legacyFylo.putData(COLLECTION, { id: 2, title: 'Beta' })
|
|
19
|
+
})
|
|
20
|
+
afterAll(async () => {
|
|
21
|
+
await rm(root, { recursive: true, force: true })
|
|
22
|
+
})
|
|
23
|
+
test('migrates legacy data and verifies parity', async () => {
|
|
24
|
+
const summary = await migrateLegacyS3ToS3Files({
|
|
25
|
+
collections: [COLLECTION],
|
|
26
|
+
s3FilesRoot: root,
|
|
27
|
+
verify: true
|
|
28
|
+
})
|
|
29
|
+
expect(summary[COLLECTION].migrated).toBe(2)
|
|
30
|
+
expect(summary[COLLECTION].verified).toBe(true)
|
|
31
|
+
const migrated = await s3FilesFylo.executeSQL(`SELECT * FROM ${COLLECTION}`)
|
|
32
|
+
expect(
|
|
33
|
+
Object.values(migrated)
|
|
34
|
+
.map((item) => item.title)
|
|
35
|
+
.sort()
|
|
36
|
+
).toEqual(['Alpha', 'Beta'])
|
|
37
|
+
})
|
|
38
|
+
})
|