@delma/fylo 1.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/.env.example +16 -0
- package/.github/copilot-instructions.md +113 -0
- package/.github/prompts/issue.prompt.md +19 -0
- package/.github/prompts/pr.prompt.md +18 -0
- package/.github/prompts/release.prompt.md +49 -0
- package/.github/prompts/review-pr.prompt.md +19 -0
- package/.github/prompts/sync-main.prompt.md +14 -0
- package/.github/workflows/ci.yml +37 -0
- package/.github/workflows/publish.yml +101 -0
- package/.prettierrc +7 -0
- package/LICENSE +21 -0
- package/README.md +230 -0
- package/eslint.config.js +28 -0
- package/package.json +51 -0
- package/src/CLI +37 -0
- package/src/adapters/cipher.ts +174 -0
- package/src/adapters/redis.ts +71 -0
- package/src/adapters/s3.ts +67 -0
- package/src/core/directory.ts +418 -0
- package/src/core/extensions.ts +19 -0
- package/src/core/format.ts +486 -0
- package/src/core/parser.ts +876 -0
- package/src/core/query.ts +48 -0
- package/src/core/walker.ts +167 -0
- package/src/index.ts +1088 -0
- package/src/types/fylo.d.ts +139 -0
- package/src/types/index.d.ts +3 -0
- package/src/types/query.d.ts +73 -0
- package/tests/collection/truncate.test.ts +56 -0
- package/tests/data.ts +110 -0
- package/tests/index.ts +19 -0
- package/tests/integration/create.test.ts +57 -0
- package/tests/integration/delete.test.ts +147 -0
- package/tests/integration/edge-cases.test.ts +232 -0
- package/tests/integration/encryption.test.ts +176 -0
- package/tests/integration/export.test.ts +61 -0
- package/tests/integration/join-modes.test.ts +221 -0
- package/tests/integration/nested.test.ts +212 -0
- package/tests/integration/operators.test.ts +167 -0
- package/tests/integration/read.test.ts +203 -0
- package/tests/integration/rollback.test.ts +105 -0
- package/tests/integration/update.test.ts +130 -0
- package/tests/mocks/cipher.ts +55 -0
- package/tests/mocks/redis.ts +13 -0
- package/tests/mocks/s3.ts +114 -0
- package/tests/schemas/album.d.ts +5 -0
- package/tests/schemas/album.json +5 -0
- package/tests/schemas/comment.d.ts +7 -0
- package/tests/schemas/comment.json +7 -0
- package/tests/schemas/photo.d.ts +7 -0
- package/tests/schemas/photo.json +7 -0
- package/tests/schemas/post.d.ts +6 -0
- package/tests/schemas/post.json +6 -0
- package/tests/schemas/tip.d.ts +7 -0
- package/tests/schemas/tip.json +7 -0
- package/tests/schemas/todo.d.ts +6 -0
- package/tests/schemas/todo.json +6 -0
- package/tests/schemas/user.d.ts +23 -0
- package/tests/schemas/user.json +23 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll, mock } from 'bun:test'
|
|
2
|
+
import Fylo from '../../src'
|
|
3
|
+
import TTID from '@vyckr/ttid'
|
|
4
|
+
import S3Mock from '../mocks/s3'
|
|
5
|
+
import RedisMock from '../mocks/redis'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Edge case coverage:
|
|
9
|
+
* - Non-existent ID returns empty object
|
|
10
|
+
* - Values containing forward slashes survive the SLASH_ASCII (%2F) round-trip
|
|
11
|
+
* - Multiple $ops entries act as OR (union across patterns)
|
|
12
|
+
* - $rename renames fields in query output
|
|
13
|
+
* - Versioned putData (existing TTID as key) preserves the creation-time prefix
|
|
14
|
+
* - SQL UPDATE ONE — update a single document by ID via SQL
|
|
15
|
+
* - SQL DELETE ONE — delete a single document by ID via SQL
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const COLLECTION = 'ec-test'
|
|
19
|
+
|
|
20
|
+
const fylo = new Fylo()
|
|
21
|
+
|
|
22
|
+
mock.module('../../src/adapters/s3', () => ({ S3: S3Mock }))
|
|
23
|
+
mock.module('../../src/adapters/redis', () => ({ Redis: RedisMock }))
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
await Fylo.createCollection(COLLECTION)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
await Fylo.dropCollection(COLLECTION)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe("NO-SQL", () => {
|
|
34
|
+
|
|
35
|
+
test("GET ONE — non-existent ID returns empty object", async () => {
|
|
36
|
+
|
|
37
|
+
const fakeId = TTID.generate() as _ttid
|
|
38
|
+
|
|
39
|
+
const result = await Fylo.getDoc(COLLECTION, fakeId).once()
|
|
40
|
+
|
|
41
|
+
expect(Object.keys(result).length).toBe(0)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test("PUT / GET — forward slashes in values round-trip correctly", async () => {
|
|
45
|
+
|
|
46
|
+
const original = {
|
|
47
|
+
userId: 1,
|
|
48
|
+
id: 1,
|
|
49
|
+
title: 'Slash Test',
|
|
50
|
+
body: 'https://example.com/api/v1/resource'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const _id = await fylo.putData<_post>(COLLECTION, original)
|
|
54
|
+
|
|
55
|
+
const result = await Fylo.getDoc<_post>(COLLECTION, _id).once()
|
|
56
|
+
const doc = result[_id]
|
|
57
|
+
|
|
58
|
+
expect(doc.body).toBe(original.body)
|
|
59
|
+
|
|
60
|
+
await fylo.delDoc(COLLECTION, _id)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("PUT / GET — values with multiple consecutive slashes round-trip correctly", async () => {
|
|
64
|
+
|
|
65
|
+
const original = {
|
|
66
|
+
userId: 1,
|
|
67
|
+
id: 2,
|
|
68
|
+
title: 'Double Slash',
|
|
69
|
+
body: 'https://cdn.example.com//assets//image.png'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const _id = await fylo.putData<_post>(COLLECTION, original)
|
|
73
|
+
|
|
74
|
+
const result = await Fylo.getDoc<_post>(COLLECTION, _id).once()
|
|
75
|
+
|
|
76
|
+
expect(result[_id].body).toBe(original.body)
|
|
77
|
+
|
|
78
|
+
await fylo.delDoc(COLLECTION, _id)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("$ops — multiple conditions act as OR union", async () => {
|
|
82
|
+
|
|
83
|
+
const cleanFylo = new Fylo()
|
|
84
|
+
|
|
85
|
+
const id1 = await cleanFylo.putData<_post>(COLLECTION, { userId: 10, id: 100, title: 'Alpha', body: 'first' })
|
|
86
|
+
const id2 = await cleanFylo.putData<_post>(COLLECTION, { userId: 20, id: 200, title: 'Beta', body: 'second' })
|
|
87
|
+
|
|
88
|
+
const results: Record<_ttid, _post> = {}
|
|
89
|
+
|
|
90
|
+
for await (const data of Fylo.findDocs<_post>(COLLECTION, {
|
|
91
|
+
$ops: [
|
|
92
|
+
{ userId: { $eq: 10 } },
|
|
93
|
+
{ userId: { $eq: 20 } }
|
|
94
|
+
]
|
|
95
|
+
}).collect()) {
|
|
96
|
+
Object.assign(results, data)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
expect(results[id1]).toBeDefined()
|
|
100
|
+
expect(results[id2]).toBeDefined()
|
|
101
|
+
|
|
102
|
+
await cleanFylo.delDoc(COLLECTION, id1)
|
|
103
|
+
await cleanFylo.delDoc(COLLECTION, id2)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("$rename — renames fields in query output", async () => {
|
|
107
|
+
|
|
108
|
+
const cleanFylo = new Fylo()
|
|
109
|
+
|
|
110
|
+
const _id = await cleanFylo.putData<_post>(COLLECTION, {
|
|
111
|
+
userId: 1,
|
|
112
|
+
id: 300,
|
|
113
|
+
title: 'Rename Me',
|
|
114
|
+
body: 'some body'
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
let renamed: Partial<_post> & { name?: string } = {}
|
|
118
|
+
|
|
119
|
+
for await (const data of Fylo.findDocs<_post>(COLLECTION, {
|
|
120
|
+
$ops: [{ id: { $eq: 300 } }],
|
|
121
|
+
$rename: { title: 'name' } as Record<keyof Partial<_post>, string>
|
|
122
|
+
}).collect()) {
|
|
123
|
+
renamed = Object.values(data as Record<_ttid, typeof renamed>)[0]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
expect(renamed.name).toBe('Rename Me')
|
|
127
|
+
expect(renamed.title).toBeUndefined()
|
|
128
|
+
|
|
129
|
+
await cleanFylo.delDoc(COLLECTION, _id)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test("versioned putData — preserves creation-time prefix in TTID", async () => {
|
|
133
|
+
|
|
134
|
+
const cleanFylo = new Fylo()
|
|
135
|
+
|
|
136
|
+
const _id1 = await cleanFylo.putData<_post>(COLLECTION, {
|
|
137
|
+
userId: 1,
|
|
138
|
+
id: 400,
|
|
139
|
+
title: 'Original',
|
|
140
|
+
body: 'v1'
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const _id2 = await cleanFylo.putData<_post>(COLLECTION, {
|
|
144
|
+
[_id1]: { userId: 1, id: 400, title: 'Updated', body: 'v2' }
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// The creation-time segment (before the first '-') must be identical
|
|
148
|
+
expect(_id2.split('-')[0]).toBe(_id1.split('-')[0])
|
|
149
|
+
|
|
150
|
+
// The updated doc is retrievable via its own TTID
|
|
151
|
+
const result = await Fylo.getDoc<_post>(COLLECTION, _id2).once()
|
|
152
|
+
const doc = result[_id2]
|
|
153
|
+
|
|
154
|
+
expect(doc).toBeDefined()
|
|
155
|
+
expect(doc.title).toBe('Updated')
|
|
156
|
+
|
|
157
|
+
// Clean up both versions
|
|
158
|
+
await cleanFylo.delDoc(COLLECTION, _id1)
|
|
159
|
+
await cleanFylo.delDoc(COLLECTION, _id2)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test("versioned putData — original version is no longer retrievable by old full TTID", async () => {
|
|
163
|
+
|
|
164
|
+
const cleanFylo = new Fylo()
|
|
165
|
+
|
|
166
|
+
const _id1 = await cleanFylo.putData<_post>(COLLECTION, {
|
|
167
|
+
userId: 1,
|
|
168
|
+
id: 500,
|
|
169
|
+
title: 'Old Version',
|
|
170
|
+
body: 'original'
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const _id2 = await cleanFylo.putData<_post>(COLLECTION, {
|
|
174
|
+
[_id1]: { userId: 1, id: 500, title: 'New Version', body: 'updated' }
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// Both IDs are different (update appended a segment)
|
|
178
|
+
expect(_id1).not.toBe(_id2)
|
|
179
|
+
|
|
180
|
+
await cleanFylo.delDoc(COLLECTION, _id2)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe("SQL", () => {
|
|
185
|
+
|
|
186
|
+
test("UPDATE ONE — update a single document by querying its unique field", async () => {
|
|
187
|
+
|
|
188
|
+
const cleanFylo = new Fylo()
|
|
189
|
+
|
|
190
|
+
await cleanFylo.putData<_post>(COLLECTION, {
|
|
191
|
+
userId: 1,
|
|
192
|
+
id: 600,
|
|
193
|
+
title: 'Before SQL Update',
|
|
194
|
+
body: 'original'
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const updated = await cleanFylo.executeSQL<_post>(
|
|
198
|
+
`UPDATE ${COLLECTION} SET title = 'After SQL Update' WHERE id = 600`
|
|
199
|
+
) as number
|
|
200
|
+
|
|
201
|
+
expect(updated).toBe(1)
|
|
202
|
+
|
|
203
|
+
const results = await cleanFylo.executeSQL<_post>(
|
|
204
|
+
`SELECT * FROM ${COLLECTION} WHERE title = 'After SQL Update'`
|
|
205
|
+
) as Record<_ttid, _post>
|
|
206
|
+
|
|
207
|
+
expect(Object.keys(results).length).toBe(1)
|
|
208
|
+
expect(Object.values(results)[0].title).toBe('After SQL Update')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test("DELETE ONE — delete a single document by querying its unique field", async () => {
|
|
212
|
+
|
|
213
|
+
const cleanFylo = new Fylo()
|
|
214
|
+
|
|
215
|
+
await cleanFylo.putData<_post>(COLLECTION, {
|
|
216
|
+
userId: 1,
|
|
217
|
+
id: 700,
|
|
218
|
+
title: 'Delete Via SQL',
|
|
219
|
+
body: 'should be removed'
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
await cleanFylo.executeSQL<_post>(
|
|
223
|
+
`DELETE FROM ${COLLECTION} WHERE title = 'Delete Via SQL'`
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
const results = await cleanFylo.executeSQL<_post>(
|
|
227
|
+
`SELECT * FROM ${COLLECTION} WHERE title = 'Delete Via SQL'`
|
|
228
|
+
) as Record<_ttid, _post>
|
|
229
|
+
|
|
230
|
+
expect(Object.keys(results).length).toBe(0)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll, mock } from 'bun:test'
|
|
2
|
+
import Fylo from '../../src'
|
|
3
|
+
import S3Mock from '../mocks/s3'
|
|
4
|
+
import RedisMock from '../mocks/redis'
|
|
5
|
+
import { CipherMock } from '../mocks/cipher'
|
|
6
|
+
|
|
7
|
+
const COLLECTION = 'encrypted-test'
|
|
8
|
+
|
|
9
|
+
const fylo = new Fylo()
|
|
10
|
+
|
|
11
|
+
mock.module('../../src/adapters/s3', () => ({ S3: S3Mock }))
|
|
12
|
+
mock.module('../../src/adapters/redis', () => ({ Redis: RedisMock }))
|
|
13
|
+
mock.module('../../src/adapters/cipher', () => ({ Cipher: CipherMock }))
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
await Fylo.createCollection(COLLECTION)
|
|
17
|
+
await CipherMock.configure('test-secret-key')
|
|
18
|
+
CipherMock.registerFields(COLLECTION, ['email', 'ssn', 'address'])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterAll(async () => {
|
|
22
|
+
CipherMock.reset()
|
|
23
|
+
await Fylo.dropCollection(COLLECTION)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe("Encryption", () => {
|
|
27
|
+
|
|
28
|
+
let docId: _ttid
|
|
29
|
+
|
|
30
|
+
test("PUT encrypted document", async () => {
|
|
31
|
+
|
|
32
|
+
docId = await fylo.putData(COLLECTION, {
|
|
33
|
+
name: 'Alice',
|
|
34
|
+
email: 'alice@example.com',
|
|
35
|
+
ssn: '123-45-6789',
|
|
36
|
+
age: 30
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
expect(docId).toBeDefined()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("GET decrypts fields transparently", async () => {
|
|
43
|
+
|
|
44
|
+
const result = await Fylo.getDoc(COLLECTION, docId).once()
|
|
45
|
+
const doc = Object.values(result)[0]
|
|
46
|
+
|
|
47
|
+
expect(doc.name).toBe('Alice')
|
|
48
|
+
expect(doc.email).toBe('alice@example.com')
|
|
49
|
+
expect(doc.ssn).toBe('123-45-6789')
|
|
50
|
+
expect(doc.age).toBe(30)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("encrypted values stored in S3 keys are not plaintext", () => {
|
|
54
|
+
|
|
55
|
+
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
|
+
expect(bucket).toBeDefined()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("$eq query works on encrypted field", async () => {
|
|
63
|
+
|
|
64
|
+
let found = false
|
|
65
|
+
|
|
66
|
+
for await (const data of Fylo.findDocs(COLLECTION, {
|
|
67
|
+
$ops: [{ email: { $eq: 'alice@example.com' } }]
|
|
68
|
+
}).collect()) {
|
|
69
|
+
|
|
70
|
+
if(typeof data === 'object') {
|
|
71
|
+
const doc = Object.values(data)[0]
|
|
72
|
+
expect(doc.email).toBe('alice@example.com')
|
|
73
|
+
found = true
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
expect(found).toBe(true)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("$ne throws on encrypted field", async () => {
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const iter = Fylo.findDocs(COLLECTION, {
|
|
84
|
+
$ops: [{ email: { $ne: 'bob@example.com' } }]
|
|
85
|
+
}).collect()
|
|
86
|
+
await iter.next()
|
|
87
|
+
expect(true).toBe(false) // Should not reach here
|
|
88
|
+
} catch (e) {
|
|
89
|
+
expect((e as Error).message).toContain('not supported on encrypted field')
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test("$gt throws on encrypted field", async () => {
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const iter = Fylo.findDocs(COLLECTION, {
|
|
97
|
+
$ops: [{ ssn: { $gt: 0 } }]
|
|
98
|
+
}).collect()
|
|
99
|
+
await iter.next()
|
|
100
|
+
expect(true).toBe(false)
|
|
101
|
+
} catch (e) {
|
|
102
|
+
expect((e as Error).message).toContain('not supported on encrypted field')
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("$like throws on encrypted field", async () => {
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const iter = Fylo.findDocs(COLLECTION, {
|
|
110
|
+
$ops: [{ email: { $like: '%@example.com' } }]
|
|
111
|
+
}).collect()
|
|
112
|
+
await iter.next()
|
|
113
|
+
expect(true).toBe(false)
|
|
114
|
+
} catch (e) {
|
|
115
|
+
expect((e as Error).message).toContain('not supported on encrypted field')
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test("non-encrypted fields remain queryable with all operators", async () => {
|
|
120
|
+
|
|
121
|
+
let found = false
|
|
122
|
+
|
|
123
|
+
for await (const data of Fylo.findDocs(COLLECTION, {
|
|
124
|
+
$ops: [{ name: { $eq: 'Alice' } }]
|
|
125
|
+
}).collect()) {
|
|
126
|
+
|
|
127
|
+
if(typeof data === 'object') {
|
|
128
|
+
const doc = Object.values(data)[0]
|
|
129
|
+
expect(doc.name).toBe('Alice')
|
|
130
|
+
found = true
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
expect(found).toBe(true)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test("nested encrypted field (address.city)", async () => {
|
|
138
|
+
|
|
139
|
+
const id = await fylo.putData(COLLECTION, {
|
|
140
|
+
name: 'Bob',
|
|
141
|
+
email: 'bob@example.com',
|
|
142
|
+
ssn: '987-65-4321',
|
|
143
|
+
age: 25,
|
|
144
|
+
address: { city: 'Toronto', zip: 'M5V 2T6' }
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const result = await Fylo.getDoc(COLLECTION, id).once()
|
|
148
|
+
const doc = Object.values(result)[0]
|
|
149
|
+
|
|
150
|
+
expect(doc.address.city).toBe('Toronto')
|
|
151
|
+
expect(doc.address.zip).toBe('M5V 2T6')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test("UPDATE preserves encryption", async () => {
|
|
155
|
+
|
|
156
|
+
await fylo.patchDoc(COLLECTION, {
|
|
157
|
+
[docId]: { email: 'alice-new@example.com' }
|
|
158
|
+
} as Record<_ttid, Record<string, string>>)
|
|
159
|
+
|
|
160
|
+
// Find the updated doc (patchDoc generates new TTID)
|
|
161
|
+
let found = false
|
|
162
|
+
|
|
163
|
+
for await (const data of Fylo.findDocs(COLLECTION, {
|
|
164
|
+
$ops: [{ email: { $eq: 'alice-new@example.com' } }]
|
|
165
|
+
}).collect()) {
|
|
166
|
+
|
|
167
|
+
if(typeof data === 'object') {
|
|
168
|
+
const doc = Object.values(data)[0]
|
|
169
|
+
expect(doc.email).toBe('alice-new@example.com')
|
|
170
|
+
found = true
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
expect(found).toBe(true)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll, mock } from 'bun:test'
|
|
2
|
+
import Fylo from '../../src'
|
|
3
|
+
import { postsURL } from '../data'
|
|
4
|
+
import S3Mock from '../mocks/s3'
|
|
5
|
+
import RedisMock from '../mocks/redis'
|
|
6
|
+
|
|
7
|
+
const POSTS = 'exp-post'
|
|
8
|
+
const IMPORT_LIMIT = 20
|
|
9
|
+
|
|
10
|
+
let importedCount = 0
|
|
11
|
+
|
|
12
|
+
const fylo = new Fylo()
|
|
13
|
+
|
|
14
|
+
mock.module('../../src/adapters/s3', () => ({ S3: S3Mock }))
|
|
15
|
+
mock.module('../../src/adapters/redis', () => ({ Redis: RedisMock }))
|
|
16
|
+
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
await Fylo.createCollection(POSTS)
|
|
19
|
+
try {
|
|
20
|
+
importedCount = await fylo.importBulkData<_post>(POSTS, new URL(postsURL), IMPORT_LIMIT)
|
|
21
|
+
} catch {
|
|
22
|
+
await fylo.rollback()
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterAll(async () => {
|
|
27
|
+
await Fylo.dropCollection(POSTS)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe("NO-SQL", () => {
|
|
31
|
+
|
|
32
|
+
test("EXPORT count matches import", async () => {
|
|
33
|
+
|
|
34
|
+
let exported = 0
|
|
35
|
+
|
|
36
|
+
for await (const _doc of Fylo.exportBulkData<_post>(POSTS)) {
|
|
37
|
+
exported++
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
expect(exported).toBe(importedCount)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("EXPORT document shape", async () => {
|
|
44
|
+
|
|
45
|
+
for await (const doc of Fylo.exportBulkData<_post>(POSTS)) {
|
|
46
|
+
expect(doc).toHaveProperty('title')
|
|
47
|
+
expect(doc).toHaveProperty('userId')
|
|
48
|
+
expect(doc).toHaveProperty('body')
|
|
49
|
+
break
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("EXPORT all documents are valid posts", async () => {
|
|
54
|
+
|
|
55
|
+
for await (const doc of Fylo.exportBulkData<_post>(POSTS)) {
|
|
56
|
+
expect(typeof doc.title).toBe('string')
|
|
57
|
+
expect(typeof doc.userId).toBe('number')
|
|
58
|
+
expect(doc.userId).toBeGreaterThan(0)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll, mock } from 'bun:test'
|
|
2
|
+
import Fylo from '../../src'
|
|
3
|
+
import { albumURL, postsURL } from '../data'
|
|
4
|
+
import S3Mock from '../mocks/s3'
|
|
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
|
+
const ALBUMS = 'jm-album'
|
|
19
|
+
const POSTS = 'jm-post'
|
|
20
|
+
|
|
21
|
+
const fylo = new Fylo()
|
|
22
|
+
|
|
23
|
+
mock.module('../../src/adapters/s3', () => ({ S3: S3Mock }))
|
|
24
|
+
mock.module('../../src/adapters/redis', () => ({ Redis: RedisMock }))
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
await Promise.all([Fylo.createCollection(ALBUMS), Fylo.createCollection(POSTS)])
|
|
28
|
+
try {
|
|
29
|
+
await Promise.all([
|
|
30
|
+
fylo.importBulkData<_album>(ALBUMS, new URL(albumURL), 100),
|
|
31
|
+
fylo.importBulkData<_post>(POSTS, new URL(postsURL), 100)
|
|
32
|
+
])
|
|
33
|
+
} catch {
|
|
34
|
+
await fylo.rollback()
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterAll(async () => {
|
|
39
|
+
await Promise.all([Fylo.dropCollection(ALBUMS), Fylo.dropCollection(POSTS)])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe("NO-SQL", async () => {
|
|
43
|
+
|
|
44
|
+
test("INNER JOIN — returns only join field values", async () => {
|
|
45
|
+
|
|
46
|
+
const results = await Fylo.joinDocs<_album, _post>({
|
|
47
|
+
$leftCollection: ALBUMS,
|
|
48
|
+
$rightCollection: POSTS,
|
|
49
|
+
$mode: 'inner',
|
|
50
|
+
$on: { userId: { $eq: 'userId' } }
|
|
51
|
+
}) as Record<`${_ttid}, ${_ttid}`, { userId: number }>
|
|
52
|
+
|
|
53
|
+
const pairs = Object.values(results)
|
|
54
|
+
|
|
55
|
+
expect(pairs.length).toBeGreaterThan(0)
|
|
56
|
+
|
|
57
|
+
// inner mode returns only the join fields, not full documents
|
|
58
|
+
for (const pair of pairs) {
|
|
59
|
+
expect(pair).toHaveProperty('userId')
|
|
60
|
+
expect(typeof pair.userId).toBe('number')
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("LEFT JOIN — returns full left-collection document", async () => {
|
|
65
|
+
|
|
66
|
+
const results = await Fylo.joinDocs<_album, _post>({
|
|
67
|
+
$leftCollection: ALBUMS,
|
|
68
|
+
$rightCollection: POSTS,
|
|
69
|
+
$mode: 'left',
|
|
70
|
+
$on: { userId: { $eq: 'userId' } }
|
|
71
|
+
}) as Record<`${_ttid}, ${_ttid}`, _album>
|
|
72
|
+
|
|
73
|
+
const docs = Object.values(results)
|
|
74
|
+
|
|
75
|
+
expect(docs.length).toBeGreaterThan(0)
|
|
76
|
+
|
|
77
|
+
// left mode returns the full album (left collection) document
|
|
78
|
+
for (const doc of docs) {
|
|
79
|
+
expect(doc).toHaveProperty('title')
|
|
80
|
+
expect(doc).toHaveProperty('userId')
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("RIGHT JOIN — returns full right-collection document", async () => {
|
|
85
|
+
|
|
86
|
+
const results = await Fylo.joinDocs<_album, _post>({
|
|
87
|
+
$leftCollection: ALBUMS,
|
|
88
|
+
$rightCollection: POSTS,
|
|
89
|
+
$mode: 'right',
|
|
90
|
+
$on: { userId: { $eq: 'userId' } }
|
|
91
|
+
}) as Record<`${_ttid}, ${_ttid}`, _post>
|
|
92
|
+
|
|
93
|
+
const docs = Object.values(results)
|
|
94
|
+
|
|
95
|
+
expect(docs.length).toBeGreaterThan(0)
|
|
96
|
+
|
|
97
|
+
// right mode returns the full post (right collection) document
|
|
98
|
+
for (const doc of docs) {
|
|
99
|
+
expect(doc).toHaveProperty('title')
|
|
100
|
+
expect(doc).toHaveProperty('body')
|
|
101
|
+
expect(doc).toHaveProperty('userId')
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("OUTER JOIN — returns merged left + right document", async () => {
|
|
106
|
+
|
|
107
|
+
const results = await Fylo.joinDocs<_album, _post>({
|
|
108
|
+
$leftCollection: ALBUMS,
|
|
109
|
+
$rightCollection: POSTS,
|
|
110
|
+
$mode: 'outer',
|
|
111
|
+
$on: { userId: { $eq: 'userId' } }
|
|
112
|
+
}) as Record<`${_ttid}, ${_ttid}`, _album & _post>
|
|
113
|
+
|
|
114
|
+
const docs = Object.values(results)
|
|
115
|
+
|
|
116
|
+
expect(docs.length).toBeGreaterThan(0)
|
|
117
|
+
|
|
118
|
+
// outer mode merges both documents; both should have fields present
|
|
119
|
+
for (const doc of docs) {
|
|
120
|
+
expect(doc).toHaveProperty('userId')
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test("JOIN with $limit — respects the result cap", async () => {
|
|
125
|
+
|
|
126
|
+
const results = await Fylo.joinDocs<_album, _post>({
|
|
127
|
+
$leftCollection: ALBUMS,
|
|
128
|
+
$rightCollection: POSTS,
|
|
129
|
+
$mode: 'inner',
|
|
130
|
+
$on: { userId: { $eq: 'userId' } },
|
|
131
|
+
$limit: 5
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
expect(Object.keys(results).length).toBe(5)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test("JOIN with $select — only requested fields are returned", async () => {
|
|
138
|
+
|
|
139
|
+
const results = await Fylo.joinDocs<_album, _post>({
|
|
140
|
+
$leftCollection: ALBUMS,
|
|
141
|
+
$rightCollection: POSTS,
|
|
142
|
+
$mode: 'left',
|
|
143
|
+
$on: { userId: { $eq: 'userId' } },
|
|
144
|
+
$select: ['title'],
|
|
145
|
+
$limit: 10
|
|
146
|
+
}) as Record<`${_ttid}, ${_ttid}`, Partial<_album>>
|
|
147
|
+
|
|
148
|
+
const docs = Object.values(results)
|
|
149
|
+
|
|
150
|
+
expect(docs.length).toBeGreaterThan(0)
|
|
151
|
+
|
|
152
|
+
for (const doc of docs) {
|
|
153
|
+
expect(doc).toHaveProperty('title')
|
|
154
|
+
expect(doc).not.toHaveProperty('userId')
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test("JOIN with $groupby — groups results by field value", async () => {
|
|
159
|
+
|
|
160
|
+
const results = await Fylo.joinDocs<_album, _post>({
|
|
161
|
+
$leftCollection: ALBUMS,
|
|
162
|
+
$rightCollection: POSTS,
|
|
163
|
+
$mode: 'inner',
|
|
164
|
+
$on: { userId: { $eq: 'userId' } },
|
|
165
|
+
$groupby: 'userId'
|
|
166
|
+
}) as Record<string, Record<string, unknown>>
|
|
167
|
+
|
|
168
|
+
expect(Object.keys(results).length).toBeGreaterThan(0)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test("JOIN with $onlyIds — returns IDs only", async () => {
|
|
172
|
+
|
|
173
|
+
const results = await Fylo.joinDocs<_album, _post>({
|
|
174
|
+
$leftCollection: ALBUMS,
|
|
175
|
+
$rightCollection: POSTS,
|
|
176
|
+
$mode: 'inner',
|
|
177
|
+
$on: { userId: { $eq: 'userId' } },
|
|
178
|
+
$onlyIds: true,
|
|
179
|
+
$limit: 10
|
|
180
|
+
}) as _ttid[]
|
|
181
|
+
|
|
182
|
+
expect(Array.isArray(results)).toBe(true)
|
|
183
|
+
expect(results.length).toBeGreaterThan(0)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe("SQL", async () => {
|
|
188
|
+
|
|
189
|
+
test("INNER JOIN", async () => {
|
|
190
|
+
|
|
191
|
+
const results = await fylo.executeSQL<_album>(
|
|
192
|
+
`SELECT * FROM ${ALBUMS} INNER JOIN ${POSTS} ON userId = userId`
|
|
193
|
+
) as Record<`${_ttid}, ${_ttid}`, _album | _post>
|
|
194
|
+
|
|
195
|
+
expect(Object.keys(results).length).toBeGreaterThan(0)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test("LEFT JOIN", async () => {
|
|
199
|
+
|
|
200
|
+
const results = await fylo.executeSQL<_album>(
|
|
201
|
+
`SELECT * FROM ${ALBUMS} LEFT JOIN ${POSTS} ON userId = userId`
|
|
202
|
+
) as Record<`${_ttid}, ${_ttid}`, _album>
|
|
203
|
+
|
|
204
|
+
const docs = Object.values(results)
|
|
205
|
+
|
|
206
|
+
expect(docs.length).toBeGreaterThan(0)
|
|
207
|
+
expect(docs[0]).toHaveProperty('title')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test("RIGHT JOIN", async () => {
|
|
211
|
+
|
|
212
|
+
const results = await fylo.executeSQL<_post>(
|
|
213
|
+
`SELECT * FROM ${ALBUMS} RIGHT JOIN ${POSTS} ON userId = userId`
|
|
214
|
+
) as Record<`${_ttid}, ${_ttid}`, _post>
|
|
215
|
+
|
|
216
|
+
const docs = Object.values(results)
|
|
217
|
+
|
|
218
|
+
expect(docs.length).toBeGreaterThan(0)
|
|
219
|
+
expect(docs[0]).toHaveProperty('body')
|
|
220
|
+
})
|
|
221
|
+
})
|