@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.
Files changed (60) hide show
  1. package/.env.example +16 -0
  2. package/.github/copilot-instructions.md +113 -0
  3. package/.github/prompts/issue.prompt.md +19 -0
  4. package/.github/prompts/pr.prompt.md +18 -0
  5. package/.github/prompts/release.prompt.md +49 -0
  6. package/.github/prompts/review-pr.prompt.md +19 -0
  7. package/.github/prompts/sync-main.prompt.md +14 -0
  8. package/.github/workflows/ci.yml +37 -0
  9. package/.github/workflows/publish.yml +101 -0
  10. package/.prettierrc +7 -0
  11. package/LICENSE +21 -0
  12. package/README.md +230 -0
  13. package/eslint.config.js +28 -0
  14. package/package.json +51 -0
  15. package/src/CLI +37 -0
  16. package/src/adapters/cipher.ts +174 -0
  17. package/src/adapters/redis.ts +71 -0
  18. package/src/adapters/s3.ts +67 -0
  19. package/src/core/directory.ts +418 -0
  20. package/src/core/extensions.ts +19 -0
  21. package/src/core/format.ts +486 -0
  22. package/src/core/parser.ts +876 -0
  23. package/src/core/query.ts +48 -0
  24. package/src/core/walker.ts +167 -0
  25. package/src/index.ts +1088 -0
  26. package/src/types/fylo.d.ts +139 -0
  27. package/src/types/index.d.ts +3 -0
  28. package/src/types/query.d.ts +73 -0
  29. package/tests/collection/truncate.test.ts +56 -0
  30. package/tests/data.ts +110 -0
  31. package/tests/index.ts +19 -0
  32. package/tests/integration/create.test.ts +57 -0
  33. package/tests/integration/delete.test.ts +147 -0
  34. package/tests/integration/edge-cases.test.ts +232 -0
  35. package/tests/integration/encryption.test.ts +176 -0
  36. package/tests/integration/export.test.ts +61 -0
  37. package/tests/integration/join-modes.test.ts +221 -0
  38. package/tests/integration/nested.test.ts +212 -0
  39. package/tests/integration/operators.test.ts +167 -0
  40. package/tests/integration/read.test.ts +203 -0
  41. package/tests/integration/rollback.test.ts +105 -0
  42. package/tests/integration/update.test.ts +130 -0
  43. package/tests/mocks/cipher.ts +55 -0
  44. package/tests/mocks/redis.ts +13 -0
  45. package/tests/mocks/s3.ts +114 -0
  46. package/tests/schemas/album.d.ts +5 -0
  47. package/tests/schemas/album.json +5 -0
  48. package/tests/schemas/comment.d.ts +7 -0
  49. package/tests/schemas/comment.json +7 -0
  50. package/tests/schemas/photo.d.ts +7 -0
  51. package/tests/schemas/photo.json +7 -0
  52. package/tests/schemas/post.d.ts +6 -0
  53. package/tests/schemas/post.json +6 -0
  54. package/tests/schemas/tip.d.ts +7 -0
  55. package/tests/schemas/tip.json +7 -0
  56. package/tests/schemas/todo.d.ts +6 -0
  57. package/tests/schemas/todo.json +6 -0
  58. package/tests/schemas/user.d.ts +23 -0
  59. package/tests/schemas/user.json +23 -0
  60. 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
+ })