@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,212 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll, mock } from 'bun:test'
|
|
2
|
+
import Fylo from '../../src'
|
|
3
|
+
import { usersURL } from '../data'
|
|
4
|
+
import S3Mock from '../mocks/s3'
|
|
5
|
+
import RedisMock from '../mocks/redis'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* _user has deeply nested fields:
|
|
9
|
+
* address.street, address.suite, address.city, address.zipcode
|
|
10
|
+
* address.geo.lat, address.geo.lng
|
|
11
|
+
* company.name, company.catchPhrase, company.bs
|
|
12
|
+
*
|
|
13
|
+
* This exercises the recursive branch in Dir.extractKeys and the nested
|
|
14
|
+
* path reconstruction in Dir.constructData.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const USERS = 'nst-user'
|
|
18
|
+
|
|
19
|
+
let insertedCount = 0
|
|
20
|
+
let sampleId: _ttid
|
|
21
|
+
|
|
22
|
+
const fylo = new Fylo()
|
|
23
|
+
|
|
24
|
+
mock.module('../../src/adapters/s3', () => ({ S3: S3Mock }))
|
|
25
|
+
mock.module('../../src/adapters/redis', () => ({ Redis: RedisMock }))
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
await Fylo.createCollection(USERS)
|
|
29
|
+
try {
|
|
30
|
+
insertedCount = await fylo.importBulkData<_user>(USERS, new URL(usersURL))
|
|
31
|
+
} catch {
|
|
32
|
+
await fylo.rollback()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for await (const data of Fylo.findDocs<_user>(USERS, { $limit: 1, $onlyIds: true }).collect()) {
|
|
36
|
+
sampleId = data as _ttid
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
afterAll(async () => {
|
|
41
|
+
await Fylo.dropCollection(USERS)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe("NO-SQL", async () => {
|
|
45
|
+
|
|
46
|
+
test("SELECT ALL — nested documents are returned", async () => {
|
|
47
|
+
|
|
48
|
+
let results: Record<_ttid, _user> = {}
|
|
49
|
+
|
|
50
|
+
for await (const data of Fylo.findDocs<_user>(USERS).collect()) {
|
|
51
|
+
results = { ...results, ...data as Record<_ttid, _user> }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
expect(Object.keys(results).length).toBe(insertedCount)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test("GET ONE — top-level fields are reconstructed correctly", async () => {
|
|
58
|
+
|
|
59
|
+
const result = await Fylo.getDoc<_user>(USERS, sampleId).once()
|
|
60
|
+
const user = result[sampleId]
|
|
61
|
+
|
|
62
|
+
expect(user).toBeDefined()
|
|
63
|
+
expect(typeof user.name).toBe('string')
|
|
64
|
+
expect(typeof user.email).toBe('string')
|
|
65
|
+
expect(typeof user.phone).toBe('string')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("GET ONE — first-level nested object is reconstructed correctly", async () => {
|
|
69
|
+
|
|
70
|
+
const result = await Fylo.getDoc<_user>(USERS, sampleId).once()
|
|
71
|
+
const user = result[sampleId]
|
|
72
|
+
|
|
73
|
+
expect(user.address).toBeDefined()
|
|
74
|
+
expect(typeof user.address.city).toBe('string')
|
|
75
|
+
expect(typeof user.address.street).toBe('string')
|
|
76
|
+
expect(typeof user.address.zipcode).toBe('string')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("GET ONE — deeply nested object is reconstructed correctly", async () => {
|
|
80
|
+
|
|
81
|
+
const result = await Fylo.getDoc<_user>(USERS, sampleId).once()
|
|
82
|
+
const user = result[sampleId]
|
|
83
|
+
|
|
84
|
+
expect(user.address.geo).toBeDefined()
|
|
85
|
+
expect(typeof user.address.geo.lat).toBe('number')
|
|
86
|
+
expect(typeof user.address.geo.lng).toBe('number')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("GET ONE — second nested object is reconstructed correctly", async () => {
|
|
90
|
+
|
|
91
|
+
const result = await Fylo.getDoc<_user>(USERS, sampleId).once()
|
|
92
|
+
const user = result[sampleId]
|
|
93
|
+
|
|
94
|
+
expect(user.company).toBeDefined()
|
|
95
|
+
expect(typeof user.company.name).toBe('string')
|
|
96
|
+
expect(typeof user.company.catchPhrase).toBe('string')
|
|
97
|
+
expect(typeof user.company.bs).toBe('string')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test("SELECT — nested values are not corrupted across documents", async () => {
|
|
101
|
+
|
|
102
|
+
for await (const data of Fylo.findDocs<_user>(USERS).collect()) {
|
|
103
|
+
|
|
104
|
+
const [, user] = Object.entries(data as Record<_ttid, _user>)[0]
|
|
105
|
+
|
|
106
|
+
expect(user.address).toBeDefined()
|
|
107
|
+
expect(user.address.geo).toBeDefined()
|
|
108
|
+
expect(typeof user.address.geo.lat).toBe('number')
|
|
109
|
+
expect(user.company).toBeDefined()
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("$select — returns only requested top-level fields", async () => {
|
|
114
|
+
|
|
115
|
+
let results: Record<_ttid, _user> = {}
|
|
116
|
+
|
|
117
|
+
for await (const data of Fylo.findDocs<_user>(USERS, { $select: ['name', 'email'] }).collect()) {
|
|
118
|
+
results = { ...results, ...data as Record<_ttid, _user> }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const users = Object.values(results)
|
|
122
|
+
const onlyNameAndEmail = users.every(u => u.name && u.email && !u.phone && !u.address)
|
|
123
|
+
|
|
124
|
+
expect(onlyNameAndEmail).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test("$eq on nested string field — query by city", async () => {
|
|
128
|
+
|
|
129
|
+
const result = await Fylo.getDoc<_user>(USERS, sampleId).once()
|
|
130
|
+
const targetCity = result[sampleId].address.city
|
|
131
|
+
|
|
132
|
+
let results: Record<_ttid, _user> = {}
|
|
133
|
+
|
|
134
|
+
for await (const data of Fylo.findDocs<_user>(USERS, {
|
|
135
|
+
$ops: [{ ['address/city' as keyof _user]: { $eq: targetCity } }]
|
|
136
|
+
}).collect()) {
|
|
137
|
+
results = { ...results, ...data as Record<_ttid, _user> }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const matchingUsers = Object.values(results)
|
|
141
|
+
const allMatch = matchingUsers.every(u => u.address.city === targetCity)
|
|
142
|
+
|
|
143
|
+
expect(allMatch).toBe(true)
|
|
144
|
+
expect(matchingUsers.length).toBeGreaterThan(0)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe("SQL — dot notation", async () => {
|
|
149
|
+
|
|
150
|
+
test("WHERE with dot notation — first-level nested field", async () => {
|
|
151
|
+
|
|
152
|
+
const result = await Fylo.getDoc<_user>(USERS, sampleId).once()
|
|
153
|
+
const targetCity = result[sampleId].address.city
|
|
154
|
+
|
|
155
|
+
const results = await fylo.executeSQL<_user>(
|
|
156
|
+
`SELECT * FROM ${USERS} WHERE address.city = '${targetCity}'`
|
|
157
|
+
) as Record<_ttid, _user>
|
|
158
|
+
|
|
159
|
+
const users = Object.values(results)
|
|
160
|
+
const allMatch = users.every(u => u.address.city === targetCity)
|
|
161
|
+
|
|
162
|
+
expect(allMatch).toBe(true)
|
|
163
|
+
expect(users.length).toBeGreaterThan(0)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test("WHERE with dot notation — deeply nested field", async () => {
|
|
167
|
+
|
|
168
|
+
const result = await Fylo.getDoc<_user>(USERS, sampleId).once()
|
|
169
|
+
const targetLat = result[sampleId].address.geo.lat
|
|
170
|
+
|
|
171
|
+
const results = await fylo.executeSQL<_user>(
|
|
172
|
+
`SELECT * FROM ${USERS} WHERE address.geo.lat = '${targetLat}'`
|
|
173
|
+
) as Record<_ttid, _user>
|
|
174
|
+
|
|
175
|
+
const users = Object.values(results)
|
|
176
|
+
const allMatch = users.every(u => u.address.geo.lat === targetLat)
|
|
177
|
+
|
|
178
|
+
expect(allMatch).toBe(true)
|
|
179
|
+
expect(users.length).toBeGreaterThan(0)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test("WHERE with dot notation — second nested object", async () => {
|
|
183
|
+
|
|
184
|
+
const result = await Fylo.getDoc<_user>(USERS, sampleId).once()
|
|
185
|
+
const targetCompany = result[sampleId].company.name
|
|
186
|
+
|
|
187
|
+
const results = await fylo.executeSQL<_user>(
|
|
188
|
+
`SELECT * FROM ${USERS} WHERE company.name = '${targetCompany}'`
|
|
189
|
+
) as Record<_ttid, _user>
|
|
190
|
+
|
|
191
|
+
const users = Object.values(results)
|
|
192
|
+
const allMatch = users.every(u => u.company.name === targetCompany)
|
|
193
|
+
|
|
194
|
+
expect(allMatch).toBe(true)
|
|
195
|
+
expect(users.length).toBeGreaterThan(0)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test("SELECT with dot notation in WHERE — partial field selection", async () => {
|
|
199
|
+
|
|
200
|
+
const result = await Fylo.getDoc<_user>(USERS, sampleId).once()
|
|
201
|
+
const targetCity = result[sampleId].address.city
|
|
202
|
+
|
|
203
|
+
const results = await fylo.executeSQL<_user>(
|
|
204
|
+
`SELECT name, email FROM ${USERS} WHERE address.city = '${targetCity}'`
|
|
205
|
+
) as Record<_ttid, _user>
|
|
206
|
+
|
|
207
|
+
const users = Object.values(results)
|
|
208
|
+
|
|
209
|
+
expect(users.length).toBeGreaterThan(0)
|
|
210
|
+
expect(users.every(u => u.name && u.email && !u.phone)).toBe(true)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll, mock } from 'bun:test'
|
|
2
|
+
import Fylo from '../../src'
|
|
3
|
+
import { albumURL } from '../data'
|
|
4
|
+
import S3Mock from '../mocks/s3'
|
|
5
|
+
import RedisMock from '../mocks/redis'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Albums from JSONPlaceholder: 100 records, userId 1–10, 10 albums per userId.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: The numeric-range glob generator in query.ts produces digit-by-digit
|
|
11
|
+
* character classes (e.g. $gt:5 → "[6-9]"). This correctly matches single-digit
|
|
12
|
+
* values but misses two-digit values like 10. Tests below assert correct expected
|
|
13
|
+
* values so any regression in the generator is surfaced immediately.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const ALBUMS = 'ops-album'
|
|
17
|
+
|
|
18
|
+
const fylo = new Fylo()
|
|
19
|
+
|
|
20
|
+
mock.module('../../src/adapters/s3', () => ({ S3: S3Mock }))
|
|
21
|
+
mock.module('../../src/adapters/redis', () => ({ Redis: RedisMock }))
|
|
22
|
+
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
await Fylo.createCollection(ALBUMS)
|
|
25
|
+
try {
|
|
26
|
+
await fylo.importBulkData<_album>(ALBUMS, new URL(albumURL), 100)
|
|
27
|
+
} catch {
|
|
28
|
+
await fylo.rollback()
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
afterAll(async () => {
|
|
33
|
+
await Fylo.dropCollection(ALBUMS)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe("NO-SQL", async () => {
|
|
37
|
+
|
|
38
|
+
test("$ne — excludes matching value", async () => {
|
|
39
|
+
|
|
40
|
+
let results: Record<_ttid, _album> = {}
|
|
41
|
+
|
|
42
|
+
for await (const data of Fylo.findDocs<_album>(ALBUMS, { $ops: [{ userId: { $ne: 1 } }] }).collect()) {
|
|
43
|
+
results = { ...results, ...data as Record<_ttid, _album> }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const albums = Object.values(results)
|
|
47
|
+
const hasUserId1 = albums.some(a => a.userId === 1)
|
|
48
|
+
|
|
49
|
+
expect(hasUserId1).toBe(false)
|
|
50
|
+
expect(albums.length).toBe(90)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("$lt — returns documents where field is less than value", async () => {
|
|
54
|
+
|
|
55
|
+
let results: Record<_ttid, _album> = {}
|
|
56
|
+
|
|
57
|
+
for await (const data of Fylo.findDocs<_album>(ALBUMS, { $ops: [{ userId: { $lt: 5 } }] }).collect()) {
|
|
58
|
+
results = { ...results, ...data as Record<_ttid, _album> }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const albums = Object.values(results)
|
|
62
|
+
const allLessThan5 = albums.every(a => a.userId < 5)
|
|
63
|
+
|
|
64
|
+
expect(allLessThan5).toBe(true)
|
|
65
|
+
expect(albums.length).toBe(40) // userId 1,2,3,4 → 4×10
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("$lte — returns documents where field is less than or equal to value", async () => {
|
|
69
|
+
|
|
70
|
+
let results: Record<_ttid, _album> = {}
|
|
71
|
+
|
|
72
|
+
for await (const data of Fylo.findDocs<_album>(ALBUMS, { $ops: [{ userId: { $lte: 5 } }] }).collect()) {
|
|
73
|
+
results = { ...results, ...data as Record<_ttid, _album> }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const albums = Object.values(results)
|
|
77
|
+
const allLte5 = albums.every(a => a.userId <= 5)
|
|
78
|
+
|
|
79
|
+
expect(allLte5).toBe(true)
|
|
80
|
+
expect(albums.length).toBe(50) // userId 1,2,3,4,5 → 5×10
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("$gt — returns documents where field is greater than value", async () => {
|
|
84
|
+
|
|
85
|
+
let results: Record<_ttid, _album> = {}
|
|
86
|
+
|
|
87
|
+
for await (const data of Fylo.findDocs<_album>(ALBUMS, { $ops: [{ userId: { $gt: 5 } }] }).collect()) {
|
|
88
|
+
results = { ...results, ...data as Record<_ttid, _album> }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const albums = Object.values(results)
|
|
92
|
+
const allGt5 = albums.every(a => a.userId > 5)
|
|
93
|
+
|
|
94
|
+
expect(allGt5).toBe(true)
|
|
95
|
+
expect(albums.length).toBe(50) // userId 6,7,8,9,10 → 5×10
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test("$gte — returns documents where field is greater than or equal to value", async () => {
|
|
99
|
+
|
|
100
|
+
let results: Record<_ttid, _album> = {}
|
|
101
|
+
|
|
102
|
+
for await (const data of Fylo.findDocs<_album>(ALBUMS, { $ops: [{ userId: { $gte: 5 } }] }).collect()) {
|
|
103
|
+
results = { ...results, ...data as Record<_ttid, _album> }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const albums = Object.values(results)
|
|
107
|
+
const allGte5 = albums.every(a => a.userId >= 5)
|
|
108
|
+
|
|
109
|
+
expect(allGte5).toBe(true)
|
|
110
|
+
expect(albums.length).toBe(60) // userId 5,6,7,8,9,10 → 6×10
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("$like — matches substring pattern", async () => {
|
|
114
|
+
|
|
115
|
+
let results: Record<_ttid, _album> = {}
|
|
116
|
+
|
|
117
|
+
for await (const data of Fylo.findDocs<_album>(ALBUMS, { $ops: [{ title: { $like: '%quidem%' } }] }).collect()) {
|
|
118
|
+
results = { ...results, ...data as Record<_ttid, _album> }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const albums = Object.values(results)
|
|
122
|
+
const allMatch = albums.every(a => a.title.includes('quidem'))
|
|
123
|
+
|
|
124
|
+
expect(allMatch).toBe(true)
|
|
125
|
+
expect(albums.length).toBeGreaterThan(0)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test("$like — prefix pattern", async () => {
|
|
129
|
+
|
|
130
|
+
let results: Record<_ttid, _album> = {}
|
|
131
|
+
|
|
132
|
+
for await (const data of Fylo.findDocs<_album>(ALBUMS, { $ops: [{ title: { $like: 'omnis%' } }] }).collect()) {
|
|
133
|
+
results = { ...results, ...data as Record<_ttid, _album> }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const albums = Object.values(results)
|
|
137
|
+
const allStartWith = albums.every(a => a.title.startsWith('omnis'))
|
|
138
|
+
|
|
139
|
+
expect(allStartWith).toBe(true)
|
|
140
|
+
expect(albums.length).toBeGreaterThan(0)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe("SQL", async () => {
|
|
145
|
+
|
|
146
|
+
test("WHERE != — excludes matching value", async () => {
|
|
147
|
+
|
|
148
|
+
const results = await fylo.executeSQL<_album>(`SELECT * FROM ${ALBUMS} WHERE userId != 1`) as Record<_ttid, _album>
|
|
149
|
+
|
|
150
|
+
const albums = Object.values(results)
|
|
151
|
+
const hasUserId1 = albums.some(a => a.userId === 1)
|
|
152
|
+
|
|
153
|
+
expect(hasUserId1).toBe(false)
|
|
154
|
+
expect(albums.length).toBe(90)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test("WHERE LIKE — matches substring pattern", async () => {
|
|
158
|
+
|
|
159
|
+
const results = await fylo.executeSQL<_album>(`SELECT * FROM ${ALBUMS} WHERE title LIKE '%quidem%'`) as Record<_ttid, _album>
|
|
160
|
+
|
|
161
|
+
const albums = Object.values(results)
|
|
162
|
+
const allMatch = albums.every(a => a.title.includes('quidem'))
|
|
163
|
+
|
|
164
|
+
expect(allMatch).toBe(true)
|
|
165
|
+
expect(albums.length).toBeGreaterThan(0)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
const POSTS = `post`
|
|
8
|
+
const ALBUMS = `album`
|
|
9
|
+
|
|
10
|
+
let count = 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
|
+
|
|
19
|
+
await Promise.all([Fylo.createCollection(ALBUMS), fylo.executeSQL<_post>(`CREATE TABLE ${POSTS}`)])
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
count = await fylo.importBulkData<_album>(ALBUMS, new URL(albumURL), 100)
|
|
23
|
+
await fylo.importBulkData<_post>(POSTS, new URL(postsURL), 100)
|
|
24
|
+
} catch {
|
|
25
|
+
await fylo.rollback()
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
await Promise.all([Fylo.dropCollection(ALBUMS), Fylo.dropCollection(POSTS)])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe("NO-SQL", async () => {
|
|
34
|
+
|
|
35
|
+
test("SELECT ALL", async () => {
|
|
36
|
+
|
|
37
|
+
let results: Record<_ttid, _album> = {}
|
|
38
|
+
|
|
39
|
+
for await (const data of Fylo.findDocs<_album>(ALBUMS).collect()) {
|
|
40
|
+
|
|
41
|
+
results = { ...results, ... data as Record<_ttid, _album> }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
//console.format(results)
|
|
45
|
+
|
|
46
|
+
expect(Object.keys(results).length).toBe(count)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("SELECT PARTIAL", async () => {
|
|
50
|
+
|
|
51
|
+
let results: Record<_ttid, _album> = {}
|
|
52
|
+
|
|
53
|
+
for await (const data of Fylo.findDocs<_album>(ALBUMS, { $select: ["title"] }).collect()) {
|
|
54
|
+
|
|
55
|
+
results = { ...results, ... data as Record<_ttid, _album> }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
//console.format(results)
|
|
59
|
+
|
|
60
|
+
const allAlbums = Object.values(results)
|
|
61
|
+
|
|
62
|
+
const onlyTtitle = allAlbums.every(user => user.title && !user.userId)
|
|
63
|
+
|
|
64
|
+
expect(onlyTtitle).toBe(true)
|
|
65
|
+
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("GET ONE", async () => {
|
|
69
|
+
|
|
70
|
+
const ids: _ttid[] = []
|
|
71
|
+
|
|
72
|
+
for await (const data of Fylo.findDocs<_album>(ALBUMS, { $limit: 1, $onlyIds: true }).collect()) {
|
|
73
|
+
|
|
74
|
+
ids.push(data as _ttid)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const result = await Fylo.getDoc<_album>(ALBUMS, ids[0]).once()
|
|
78
|
+
|
|
79
|
+
const _id = Object.keys(result).shift()!
|
|
80
|
+
|
|
81
|
+
expect(ids[0]).toEqual(_id)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("SELECT CLAUSE", async () => {
|
|
85
|
+
|
|
86
|
+
let results: Record<_ttid, _album> = {}
|
|
87
|
+
|
|
88
|
+
for await (const data of Fylo.findDocs<_album>(ALBUMS, { $ops: [{ userId: { $eq: 2 } }] }).collect()) {
|
|
89
|
+
|
|
90
|
+
results = { ...results, ...data as Record<_ttid, _album> }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
//console.format(results)
|
|
94
|
+
|
|
95
|
+
const allAlbums = Object.values(results)
|
|
96
|
+
|
|
97
|
+
const onlyUserId = allAlbums.every(user => user.userId === 2)
|
|
98
|
+
|
|
99
|
+
expect(onlyUserId).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test("SELECT LIMIT", async () => {
|
|
103
|
+
|
|
104
|
+
let results: Record<_ttid, _album> = {}
|
|
105
|
+
|
|
106
|
+
for await (const data of Fylo.findDocs<_album>(ALBUMS, { $limit: 5 }).collect()) {
|
|
107
|
+
|
|
108
|
+
results = { ...results, ...data as Record<_ttid, _album> }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
//console.format(results)
|
|
112
|
+
|
|
113
|
+
expect(Object.keys(results).length).toBe(5)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test("SELECT GROUP BY", async () => {
|
|
117
|
+
|
|
118
|
+
let results: Record<_album[keyof _album], Record<_ttid, Partial<_album>>> = {} as Record<_album[keyof _album], Record<_ttid, Partial<_album>>>
|
|
119
|
+
|
|
120
|
+
for await (const data of Fylo.findDocs<_album>(ALBUMS, { $groupby: "userId", $onlyIds: true }).collect()) {
|
|
121
|
+
|
|
122
|
+
results = Object.appendGroup(results, (data as unknown as Record<string, Record<string, Record<_ttid, null>>>))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
//console.format(results)
|
|
126
|
+
|
|
127
|
+
expect(Object.keys(results).length).toBeGreaterThan(0)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test("SELECT JOIN", async () => {
|
|
131
|
+
|
|
132
|
+
const results = await Fylo.joinDocs<_album, _post>({ $leftCollection: ALBUMS, $rightCollection: POSTS, $mode: "inner", $on: { "userId": { $eq: "id" } } }) as Record<`${_ttid}, ${_ttid}`, _album | _post>
|
|
133
|
+
|
|
134
|
+
//console.format(results)
|
|
135
|
+
|
|
136
|
+
expect(Object.keys(results).length).toBeGreaterThan(0)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe("SQL", async () => {
|
|
141
|
+
|
|
142
|
+
test("SELECT PARTIAL", async () => {
|
|
143
|
+
|
|
144
|
+
const results = await fylo.executeSQL<_album>(`SELECT title FROM ${ALBUMS}`) as Record<_ttid, _album>
|
|
145
|
+
|
|
146
|
+
//console.format(results)
|
|
147
|
+
|
|
148
|
+
const allAlbums = Object.values(results)
|
|
149
|
+
|
|
150
|
+
const onlyTtitle = allAlbums.every(user => user.title && !user.userId)
|
|
151
|
+
|
|
152
|
+
expect(onlyTtitle).toBe(true)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test("SELECT CLAUSE", async () => {
|
|
156
|
+
|
|
157
|
+
const results = await fylo.executeSQL<_album>(`SELECT * FROM ${ALBUMS} WHERE user_id = 2`) as Record<_ttid, _album>
|
|
158
|
+
|
|
159
|
+
//console.format(results)
|
|
160
|
+
|
|
161
|
+
const allAlbums = Object.values(results)
|
|
162
|
+
|
|
163
|
+
const onlyUserId = allAlbums.every(user => user.userId === 2)
|
|
164
|
+
|
|
165
|
+
expect(onlyUserId).toBe(true)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test("SELECT ALL", async () => {
|
|
169
|
+
|
|
170
|
+
const results = await fylo.executeSQL<_album>(`SELECT * FROM ${ALBUMS}`) as Record<_ttid, _album>
|
|
171
|
+
|
|
172
|
+
//console.format(results)
|
|
173
|
+
|
|
174
|
+
expect(Object.keys(results).length).toBe(count)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test("SELECT LIMIT", async () => {
|
|
178
|
+
|
|
179
|
+
const results = await fylo.executeSQL<_album>(`SELECT * FROM ${ALBUMS} LIMIT 5`) as Record<_ttid, _album>
|
|
180
|
+
|
|
181
|
+
//console.format(results)
|
|
182
|
+
|
|
183
|
+
expect(Object.keys(results).length).toBe(5)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test("SELECT GROUP BY", async () => {
|
|
187
|
+
|
|
188
|
+
const results = await fylo.executeSQL<_album>(`SELECT * FROM ${ALBUMS} GROUP BY userId`) as unknown as Record<string, Record<keyof _album, Record<_album[keyof _album], _album>>>
|
|
189
|
+
|
|
190
|
+
//console.format(results)
|
|
191
|
+
|
|
192
|
+
expect(Object.keys(results).length).toBeGreaterThan(0)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test("SELECT JOIN", async () => {
|
|
196
|
+
|
|
197
|
+
const results = await fylo.executeSQL<_album>(`SELECT * FROM ${ALBUMS} INNER JOIN ${POSTS} ON userId = id`) as Record<`${_ttid}, ${_ttid}`, _album | _post>
|
|
198
|
+
|
|
199
|
+
//console.format(results)
|
|
200
|
+
|
|
201
|
+
expect(Object.keys(results).length).toBeGreaterThan(0)
|
|
202
|
+
})
|
|
203
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Rollback mechanism:
|
|
8
|
+
* - putKeys() pushes S3.delete ops into the transactions stack
|
|
9
|
+
* - deleteKeys() pushes S3.put ops (restore) into the stack
|
|
10
|
+
* - executeRollback() pops and executes them in reverse order
|
|
11
|
+
*
|
|
12
|
+
* After rollback, the written data should no longer be retrievable.
|
|
13
|
+
* After a delete rollback, the deleted data should be restored.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const POSTS = 'rb-post'
|
|
17
|
+
|
|
18
|
+
const fylo = new Fylo()
|
|
19
|
+
|
|
20
|
+
mock.module('../../src/adapters/s3', () => ({ S3: S3Mock }))
|
|
21
|
+
mock.module('../../src/adapters/redis', () => ({ Redis: RedisMock }))
|
|
22
|
+
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
await Fylo.createCollection(POSTS)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
afterAll(async () => {
|
|
28
|
+
await Fylo.dropCollection(POSTS)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe("NO-SQL", () => {
|
|
32
|
+
|
|
33
|
+
test("INSERT then rollback — document is not retrievable", async () => {
|
|
34
|
+
|
|
35
|
+
const _id = await fylo.putData<_post>(POSTS, {
|
|
36
|
+
userId: 99,
|
|
37
|
+
id: 9001,
|
|
38
|
+
title: 'Rollback Me',
|
|
39
|
+
body: 'This document should disappear after rollback'
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// Verify the document was actually written
|
|
43
|
+
const before = await Fylo.getDoc<_post>(POSTS, _id).once()
|
|
44
|
+
expect(Object.keys(before).length).toBe(1)
|
|
45
|
+
|
|
46
|
+
// Rollback undoes all writes made on this Fylo instance
|
|
47
|
+
await fylo.rollback()
|
|
48
|
+
|
|
49
|
+
const after = await Fylo.getDoc<_post>(POSTS, _id).once()
|
|
50
|
+
expect(Object.keys(after).length).toBe(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("DELETE then rollback — document is restored", async () => {
|
|
54
|
+
|
|
55
|
+
// Use a fresh Fylo instance so the transactions stack is clean
|
|
56
|
+
const freshFylo = new Fylo()
|
|
57
|
+
|
|
58
|
+
const _id = await freshFylo.putData<_post>(POSTS, {
|
|
59
|
+
userId: 99,
|
|
60
|
+
id: 9002,
|
|
61
|
+
title: 'Restore Me',
|
|
62
|
+
body: 'This document should reappear after delete rollback'
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Clear the insert transactions so rollback only covers the delete
|
|
66
|
+
// (call rollback would delete the just-written doc, defeating the purpose)
|
|
67
|
+
// Instead we use a second instance that has a clean transaction stack
|
|
68
|
+
const deleteInstance = new Fylo()
|
|
69
|
+
|
|
70
|
+
await deleteInstance.delDoc(POSTS, _id)
|
|
71
|
+
|
|
72
|
+
// Confirm it's gone
|
|
73
|
+
const after = await Fylo.getDoc<_post>(POSTS, _id).once()
|
|
74
|
+
expect(Object.keys(after).length).toBe(0)
|
|
75
|
+
|
|
76
|
+
// Rollback the delete — should restore the document
|
|
77
|
+
await deleteInstance.rollback()
|
|
78
|
+
|
|
79
|
+
const restored = await Fylo.getDoc<_post>(POSTS, _id).once()
|
|
80
|
+
expect(Object.keys(restored).length).toBe(1)
|
|
81
|
+
expect(restored[_id].title).toBe('Restore Me')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("batch INSERT then rollback — all documents are removed", async () => {
|
|
85
|
+
|
|
86
|
+
const batchFylo = new Fylo()
|
|
87
|
+
|
|
88
|
+
const batch: _post[] = [
|
|
89
|
+
{ userId: 98, id: 9003, title: 'Batch A', body: 'body a' },
|
|
90
|
+
{ userId: 98, id: 9004, title: 'Batch B', body: 'body b' },
|
|
91
|
+
{ userId: 98, id: 9005, title: 'Batch C', body: 'body c' }
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
const ids = await batchFylo.batchPutData<_post>(POSTS, batch)
|
|
95
|
+
|
|
96
|
+
// Confirm all written
|
|
97
|
+
const beforeResults = await Promise.all(ids.map(id => Fylo.getDoc<_post>(POSTS, id).once()))
|
|
98
|
+
expect(beforeResults.every(r => Object.keys(r).length === 1)).toBe(true)
|
|
99
|
+
|
|
100
|
+
await batchFylo.rollback()
|
|
101
|
+
|
|
102
|
+
const afterResults = await Promise.all(ids.map(id => Fylo.getDoc<_post>(POSTS, id).once()))
|
|
103
|
+
expect(afterResults.every(r => Object.keys(r).length === 0)).toBe(true)
|
|
104
|
+
})
|
|
105
|
+
})
|