@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,130 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll, mock } from 'bun:test'
|
|
2
|
+
import Fylo from '../../src'
|
|
3
|
+
import { photosURL, todosURL } from '../data'
|
|
4
|
+
import S3Mock from '../mocks/s3'
|
|
5
|
+
import RedisMock from '../mocks/redis'
|
|
6
|
+
|
|
7
|
+
const PHOTOS = `photo`
|
|
8
|
+
const TODOS = `todo`
|
|
9
|
+
|
|
10
|
+
const fylo = new Fylo()
|
|
11
|
+
|
|
12
|
+
mock.module('../../src/adapters/s3', () => ({ S3: S3Mock }))
|
|
13
|
+
mock.module('../../src/adapters/redis', () => ({ Redis: RedisMock }))
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
|
|
17
|
+
await Promise.all([Fylo.createCollection(PHOTOS), fylo.executeSQL<_todo>(`CREATE TABLE ${TODOS}`)])
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
await fylo.importBulkData<_photo>(PHOTOS, new URL(photosURL), 100)
|
|
21
|
+
await fylo.importBulkData<_todo>(TODOS, new URL(todosURL), 100)
|
|
22
|
+
} catch {
|
|
23
|
+
await fylo.rollback()
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
afterAll(async () => {
|
|
28
|
+
await Promise.all([Fylo.dropCollection(PHOTOS), fylo.executeSQL<_todo>(`DROP TABLE ${TODOS}`)])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe("NO-SQL", async () => {
|
|
32
|
+
|
|
33
|
+
test("UPDATE ONE", async () => {
|
|
34
|
+
|
|
35
|
+
const ids: _ttid[] = []
|
|
36
|
+
|
|
37
|
+
for await (const data of Fylo.findDocs<_photo>(PHOTOS, { $limit: 1, $onlyIds: true }).collect()) {
|
|
38
|
+
|
|
39
|
+
ids.push(data as _ttid)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await fylo.patchDoc<_photo>(PHOTOS, { [ids.shift() as _ttid]: { title: "All Mighty" }})
|
|
44
|
+
} catch {
|
|
45
|
+
await fylo.rollback()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let results: Record<_ttid, _photo> = {}
|
|
49
|
+
|
|
50
|
+
for await (const data of Fylo.findDocs<_photo>(PHOTOS, { $ops: [{ title: { $eq: "All Mighty" } }]}).collect()) {
|
|
51
|
+
|
|
52
|
+
results = { ...results, ...data as Record<_ttid, _photo> }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
expect(Object.keys(results).length).toBe(1)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("UPDATE CLAUSE", async () => {
|
|
59
|
+
|
|
60
|
+
let count = -1
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
count = await fylo.patchDocs<_photo>(PHOTOS, { $set: { title: "All Mighti" }, $where: { $ops: [{ title: { $like: "%est%" } }] } })
|
|
64
|
+
} catch {
|
|
65
|
+
await fylo.rollback()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let results: Record<_ttid, _photo> = {}
|
|
69
|
+
|
|
70
|
+
for await (const data of Fylo.findDocs<_photo>(PHOTOS, { $ops: [ { title: { $eq: "All Mighti" } } ] }).collect()) {
|
|
71
|
+
|
|
72
|
+
results = { ...results, ...data as Record<_ttid, _photo> }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
expect(Object.keys(results).length).toBe(count)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("UPDATE ALL", async () => {
|
|
79
|
+
|
|
80
|
+
let count = -1
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
count = await fylo.patchDocs<_photo>(PHOTOS, { $set: { title: "All Mighter" } })
|
|
84
|
+
} catch {
|
|
85
|
+
await fylo.rollback()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let results: Record<_ttid, _photo> = {}
|
|
89
|
+
|
|
90
|
+
for await (const data of Fylo.findDocs<_photo>(PHOTOS, { $ops: [ { title: { $eq: "All Mighter" } } ] }).collect()) {
|
|
91
|
+
|
|
92
|
+
results = { ...results, ...data as Record<_ttid, _photo> }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
expect(Object.keys(results).length).toBe(count)
|
|
96
|
+
}, 20000)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe("SQL", async () => {
|
|
100
|
+
|
|
101
|
+
test("UPDATE CLAUSE", async () => {
|
|
102
|
+
|
|
103
|
+
let count = -1
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
count = await fylo.executeSQL<_todo>(`UPDATE ${TODOS} SET title = 'All Mighty' WHERE title LIKE '%est%'`) as number
|
|
107
|
+
} catch {
|
|
108
|
+
await fylo.rollback()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const results = await fylo.executeSQL<_todo>(`SELECT * FROM ${TODOS} WHERE title = 'All Mighty'`) as Record<_ttid, _todo>
|
|
112
|
+
|
|
113
|
+
expect(Object.keys(results).length).toBe(count)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test("UPDATE ALL", async () => {
|
|
117
|
+
|
|
118
|
+
let count = -1
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
count = await fylo.executeSQL<_todo>(`UPDATE ${TODOS} SET title = 'All Mightier'`) as number
|
|
122
|
+
} catch {
|
|
123
|
+
await fylo.rollback()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const results = await fylo.executeSQL<_todo>(`SELECT * FROM ${TODOS} WHERE title = 'All Mightier'`) as Record<_ttid, _todo>
|
|
127
|
+
|
|
128
|
+
expect(Object.keys(results).length).toBe(count)
|
|
129
|
+
}, 20000)
|
|
130
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pass-through Cipher mock for tests that don't need real encryption.
|
|
3
|
+
* Returns values as-is (base64-encoded to match real adapter interface shape).
|
|
4
|
+
*/
|
|
5
|
+
export class CipherMock {
|
|
6
|
+
|
|
7
|
+
private static _configured = false
|
|
8
|
+
private static collections: Map<string, Set<string>> = new Map()
|
|
9
|
+
|
|
10
|
+
static isConfigured(): boolean {
|
|
11
|
+
return CipherMock._configured
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
static hasEncryptedFields(collection: string): boolean {
|
|
15
|
+
const fields = CipherMock.collections.get(collection)
|
|
16
|
+
return !!fields && fields.size > 0
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static isEncryptedField(collection: string, field: string): boolean {
|
|
20
|
+
const fields = CipherMock.collections.get(collection)
|
|
21
|
+
if (!fields || fields.size === 0) return false
|
|
22
|
+
|
|
23
|
+
for (const pattern of fields) {
|
|
24
|
+
if (field === pattern) return true
|
|
25
|
+
if (field.startsWith(`${pattern}/`)) return true
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static registerFields(collection: string, fields: string[]): void {
|
|
32
|
+
if (fields.length > 0) {
|
|
33
|
+
CipherMock.collections.set(collection, new Set(fields))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static async configure(_secret: string): Promise<void> {
|
|
38
|
+
CipherMock._configured = true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static reset(): void {
|
|
42
|
+
CipherMock._configured = false
|
|
43
|
+
CipherMock.collections = new Map()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static async encrypt(value: string): Promise<string> {
|
|
47
|
+
return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static async decrypt(encoded: string): Promise<string> {
|
|
51
|
+
const b64 = encoded.replace(/-/g, '+').replace(/_/g, '/')
|
|
52
|
+
const padded = b64 + '='.repeat((4 - b64.length % 4) % 4)
|
|
53
|
+
return atob(padded)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* No-op Redis mock. All methods are silent no-ops so tests never need a
|
|
3
|
+
* running Redis instance. subscribe yields nothing so listener code paths
|
|
4
|
+
* simply exit immediately.
|
|
5
|
+
*/
|
|
6
|
+
export default class RedisMock {
|
|
7
|
+
|
|
8
|
+
async publish(_collection: string, _action: 'insert' | 'delete', _keyId: string | _ttid): Promise<void> {}
|
|
9
|
+
|
|
10
|
+
async claimTTID(_id: _ttid, _ttlSeconds: number = 10): Promise<boolean> { return true }
|
|
11
|
+
|
|
12
|
+
async *subscribe(_collection: string): AsyncGenerator<never, void, unknown> {}
|
|
13
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory S3 mock. Replaces src/adapters/s3 so tests never touch real S3
|
|
3
|
+
* or the AWS CLI. Each test file gets a fresh store because mock.module is
|
|
4
|
+
* hoisted before imports, and module-level state is isolated per test file
|
|
5
|
+
* in Bun's test runner.
|
|
6
|
+
*
|
|
7
|
+
* createBucket / deleteBucket are no-ops (bucket creation/deletion is
|
|
8
|
+
* handled implicitly by the in-memory store).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const store = new Map<string, Map<string, string>>()
|
|
12
|
+
|
|
13
|
+
function getBucket(name: string): Map<string, string> {
|
|
14
|
+
if (!store.has(name)) store.set(name, new Map())
|
|
15
|
+
return store.get(name)!
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default class S3Mock {
|
|
19
|
+
|
|
20
|
+
static readonly BUCKET_ENV = process.env.BUCKET_PREFIX
|
|
21
|
+
|
|
22
|
+
static readonly CREDS = {
|
|
23
|
+
accessKeyId: 'mock',
|
|
24
|
+
secretAccessKey: 'mock',
|
|
25
|
+
region: 'mock',
|
|
26
|
+
endpoint: undefined as string | undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static getBucketFormat(collection: string): string {
|
|
30
|
+
return S3Mock.BUCKET_ENV ? `${S3Mock.BUCKET_ENV}-${collection}` : collection
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static file(collection: string, path: string) {
|
|
34
|
+
const bucket = getBucket(S3Mock.getBucketFormat(collection))
|
|
35
|
+
return {
|
|
36
|
+
get size() {
|
|
37
|
+
const val = bucket.get(path)
|
|
38
|
+
return val !== undefined ? val.length : 0
|
|
39
|
+
},
|
|
40
|
+
async text(): Promise<string> {
|
|
41
|
+
return bucket.get(path) ?? ''
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static async list(collection: string, options: {
|
|
47
|
+
prefix?: string
|
|
48
|
+
delimiter?: string
|
|
49
|
+
maxKeys?: number
|
|
50
|
+
continuationToken?: string
|
|
51
|
+
} = {}) {
|
|
52
|
+
const bucket = getBucket(S3Mock.getBucketFormat(collection))
|
|
53
|
+
const prefix = options.prefix ?? ''
|
|
54
|
+
const delimiter = options.delimiter
|
|
55
|
+
const maxKeys = options.maxKeys ?? 1000
|
|
56
|
+
const token = options.continuationToken
|
|
57
|
+
|
|
58
|
+
const allKeys = Array.from(bucket.keys()).filter(k => k.startsWith(prefix)).sort()
|
|
59
|
+
|
|
60
|
+
if (delimiter) {
|
|
61
|
+
const prefixSet = new Set<string>()
|
|
62
|
+
const contents: Array<{ key: string }> = []
|
|
63
|
+
|
|
64
|
+
for (const key of allKeys) {
|
|
65
|
+
const rest = key.slice(prefix.length)
|
|
66
|
+
const idx = rest.indexOf(delimiter)
|
|
67
|
+
if (idx >= 0) {
|
|
68
|
+
prefixSet.add(prefix + rest.slice(0, idx + 1))
|
|
69
|
+
} else {
|
|
70
|
+
contents.push({ key })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const allPrefixes = Array.from(prefixSet).map(p => ({ prefix: p }))
|
|
75
|
+
const limitedPrefixes = allPrefixes.slice(0, maxKeys)
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
contents: contents.length ? contents : undefined,
|
|
79
|
+
commonPrefixes: limitedPrefixes.length
|
|
80
|
+
? limitedPrefixes
|
|
81
|
+
: undefined,
|
|
82
|
+
isTruncated: allPrefixes.length > maxKeys,
|
|
83
|
+
nextContinuationToken: undefined
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const startIdx = token ? parseInt(token) : 0
|
|
88
|
+
const page = allKeys.slice(startIdx, startIdx + maxKeys)
|
|
89
|
+
const nextToken = startIdx + maxKeys < allKeys.length
|
|
90
|
+
? String(startIdx + maxKeys)
|
|
91
|
+
: undefined
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
contents: page.length ? page.map(k => ({ key: k })) : undefined,
|
|
95
|
+
isTruncated: !!nextToken,
|
|
96
|
+
nextContinuationToken: nextToken,
|
|
97
|
+
commonPrefixes: undefined
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static async put(collection: string, path: string, data: string): Promise<void> {
|
|
102
|
+
getBucket(S3Mock.getBucketFormat(collection)).set(path, data)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
static async delete(collection: string, path: string): Promise<void> {
|
|
106
|
+
getBucket(S3Mock.getBucketFormat(collection)).delete(path)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
static async createBucket(_collection: string): Promise<void> {}
|
|
110
|
+
|
|
111
|
+
static async deleteBucket(collection: string): Promise<void> {
|
|
112
|
+
store.delete(S3Mock.getBucketFormat(collection))
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
interface _user {
|
|
2
|
+
id: number
|
|
3
|
+
name: string
|
|
4
|
+
username: string
|
|
5
|
+
email: string
|
|
6
|
+
address: {
|
|
7
|
+
street: string
|
|
8
|
+
suite: string
|
|
9
|
+
city: string
|
|
10
|
+
zipcode: string
|
|
11
|
+
geo: {
|
|
12
|
+
lat: string
|
|
13
|
+
lng: string
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
phone: string
|
|
17
|
+
website: string
|
|
18
|
+
company: {
|
|
19
|
+
name: string
|
|
20
|
+
catchPhrase: string
|
|
21
|
+
bs: string
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": -0,
|
|
3
|
+
"name": "",
|
|
4
|
+
"username": "",
|
|
5
|
+
"^email$": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$",
|
|
6
|
+
"address": {
|
|
7
|
+
"street": "",
|
|
8
|
+
"suite": "",
|
|
9
|
+
"city": "",
|
|
10
|
+
"zipcode": "",
|
|
11
|
+
"geo": {
|
|
12
|
+
"lat": "",
|
|
13
|
+
"lng": ""
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"phone": "",
|
|
17
|
+
"website": "",
|
|
18
|
+
"company": {
|
|
19
|
+
"name": "",
|
|
20
|
+
"catchPhrase": "",
|
|
21
|
+
"bs": ""
|
|
22
|
+
}
|
|
23
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"strict": true,
|
|
4
|
+
"outDir": "dist",
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"lib": ["ESNext"],
|
|
7
|
+
"module": "ES2022",
|
|
8
|
+
"moduleResolution": "node",
|
|
9
|
+
"sourceMap": true,
|
|
10
|
+
"experimentalDecorators": true,
|
|
11
|
+
"pretty": true,
|
|
12
|
+
"noFallthroughCasesInSwitch": true,
|
|
13
|
+
"noImplicitReturns": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"types": ["bun-types", "node", "@vyckr/ttid"],
|
|
16
|
+
"isolatedModules": true,
|
|
17
|
+
"skipLibCheck": true
|
|
18
|
+
}
|
|
19
|
+
}
|