@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,418 @@
1
+ import { Walker } from "./walker"
2
+ import TTID from "@vyckr/ttid"
3
+ import { S3 } from "../adapters/s3"
4
+ import { Redis } from "../adapters/redis"
5
+ import { Cipher } from "../adapters/cipher"
6
+
7
+ export class Dir {
8
+
9
+ private static readonly KEY_LIMIT = 1024
10
+
11
+ private static readonly SLASH_ASCII = "%2F"
12
+
13
+ private readonly transactions: Array<{ action: (...args: string[]) => Promise<void>, args: string[] }>;
14
+
15
+ private static _redis: Redis | null = null
16
+
17
+ private static get redis(): Redis {
18
+ if (!Dir._redis) Dir._redis = new Redis()
19
+ return Dir._redis
20
+ }
21
+
22
+ constructor() {
23
+ this.transactions = []
24
+ }
25
+
26
+ static async claimTTID(_id: _ttid, ttlSeconds: number = 10): Promise<boolean> {
27
+ return await Dir.redis.claimTTID(_id, ttlSeconds)
28
+ }
29
+
30
+ static async reconstructData(collection: string, items: string[]) {
31
+
32
+ items = await this.readValues(collection, items)
33
+
34
+ let fieldVal: Record<string, string> = {}
35
+
36
+ for (const data of items) {
37
+ const segs = data.split('/')
38
+ const val = segs.pop()!
39
+ const fieldPath = segs.join('/')
40
+
41
+ // Decrypt value if field is encrypted — fieldPath starts with TTID segment
42
+ // so strip it to get the actual field name for the check
43
+ const fieldOnly = segs.slice(1).join('/')
44
+ if(Cipher.isConfigured() && Cipher.isEncryptedField(collection, fieldOnly)) {
45
+ fieldVal[fieldPath] = await Cipher.decrypt(val)
46
+ } else {
47
+ fieldVal[fieldPath] = val
48
+ }
49
+ }
50
+
51
+ return this.constructData(fieldVal)
52
+ }
53
+
54
+ private static async readValues(collection: string, items: string[]) {
55
+
56
+ for(let i = 0; i < items.length; i++) {
57
+
58
+ const segments = items[i].split('/')
59
+
60
+ const filename = segments.pop()!
61
+
62
+ if(TTID.isUUID(filename)) {
63
+
64
+ const file = S3.file(collection, items[i])
65
+ const val = await file.text()
66
+
67
+ items[i] = `${segments.join('/')}/${val}`
68
+ }
69
+ }
70
+
71
+ return items
72
+ }
73
+
74
+ private static async filterByTimestamp(_id: _ttid, indexes: string[], { updated, created }: { updated?: _timestamp, created?: _timestamp }) {
75
+
76
+ const { createdAt, updatedAt } = TTID.decodeTime(_id)
77
+
78
+ if(updated && updatedAt) {
79
+
80
+ if((updated.$gt || updated.$gte) && (updated.$lt || updated.$lte)) {
81
+
82
+ if(updated.$gt && updated.$lt) {
83
+
84
+ if(updated.$gt! > updated.$lt!) throw new Error("Invalid updated query")
85
+
86
+ indexes = updatedAt > updated.$gt! && updatedAt < updated.$lt! ? indexes : []
87
+
88
+ } else if(updated.$gt && updated.$lte) {
89
+
90
+ if(updated.$gt! > updated.$lte!) throw new Error("Invalid updated query")
91
+
92
+ indexes = updatedAt > updated.$gt! && updatedAt <= updated.$lte! ? indexes : []
93
+
94
+ } else if(updated.$gte && updated.$lt) {
95
+
96
+ if(updated.$gte! > updated.$lt!) throw new Error("Invalid updated query")
97
+
98
+ indexes = updatedAt >= updated.$gte! && updatedAt < updated.$lt! ? indexes : []
99
+
100
+ } else if(updated.$gte && updated.$lte) {
101
+
102
+ if(updated.$gte! > updated.$lte!) throw new Error("Invalid updated query")
103
+
104
+ indexes = updatedAt >= updated.$gte! && updatedAt <= updated.$lte! ? indexes : []
105
+ }
106
+
107
+ } else if((updated.$gt || updated.$gte) && !updated.$lt && !updated.$lte) {
108
+
109
+ indexes = updated.$gt ? updatedAt > updated.$gt! ? indexes : [] : updatedAt >= updated.$gte! ? indexes : []
110
+
111
+ } else if(!updated.$gt && !updated.$gte && (updated.$lt || updated.$lte)) {
112
+
113
+ indexes = updated.$lt ? updatedAt < updated.$lt! ? indexes : [] : updatedAt <= updated.$lte! ? indexes : []
114
+ }
115
+ }
116
+
117
+ if(created) {
118
+
119
+ if((created.$gt || created.$gte) && (created.$lt || created.$lte)) {
120
+
121
+ if(created.$gt && created.$lt) {
122
+
123
+ if(created.$gt! > created.$lt!) throw new Error("Invalid created query")
124
+
125
+ indexes = createdAt > created.$gt! && createdAt < created.$lt! ? indexes : []
126
+
127
+ } else if(created.$gt && created.$lte) {
128
+
129
+ if(created.$gt! > created.$lte!) throw new Error("Invalid updated query")
130
+
131
+ indexes = createdAt > created.$gt! && createdAt <= created.$lte! ? indexes : []
132
+
133
+ } else if(created.$gte && created.$lt) {
134
+
135
+ if(created.$gte! > created.$lt!) throw new Error("Invalid updated query")
136
+
137
+ indexes = createdAt >= created.$gte! && createdAt < created.$lt! ? indexes : []
138
+
139
+ } else if(created.$gte && created.$lte) {
140
+
141
+ if(created.$gte! > created.$lte!) throw new Error("Invalid updated query")
142
+
143
+ indexes = createdAt >= created.$gte! && createdAt <= created.$lte! ? indexes : []
144
+ }
145
+
146
+ } else if((created.$gt || created.$gte) && !created.$lt && !created.$lte) {
147
+
148
+ if(created.$gt) indexes = createdAt > created.$gt! ? indexes : []
149
+ else if(created.$gte) indexes = createdAt >= created.$gte! ? indexes : []
150
+
151
+ } else if(!created.$gt && !created.$gte && (created.$lt || created.$lte)) {
152
+
153
+ if(created.$lt) indexes = createdAt < created.$lt! ? indexes : []
154
+ else if(created.$lte) indexes = createdAt <= created.$lte! ? indexes : []
155
+ }
156
+ }
157
+
158
+ return indexes.length > 0
159
+ }
160
+
161
+ static async *searchDocs<T extends Record<string, any>>(collection: string, pattern: string | string[], { updated, created }: { updated?: _timestamp, created?: _timestamp }, { listen = false, skip = false }: { listen: boolean, skip: boolean }, deleted: boolean = false): AsyncGenerator<Record<_ttid, T> | _ttid | void, void, { count: number, limit?: number }> {
162
+
163
+ const data = yield
164
+ let count = data.count
165
+ let limit = data.limit
166
+
167
+ const constructData = async (collection: string, _id: _ttid, items: string[]) => {
168
+
169
+ if(created || updated) {
170
+
171
+ if(await this.filterByTimestamp(_id, items, { created, updated })) {
172
+
173
+ const data = await this.reconstructData(collection, items)
174
+
175
+ return { [_id]: data } as Record<_ttid, T>
176
+
177
+ } else return {}
178
+
179
+ } else {
180
+
181
+ const data = await this.reconstructData(collection, items)
182
+
183
+ return { [_id]: data } as Record<_ttid, T>
184
+ }
185
+ }
186
+
187
+ const processQuery = async function*(p: string): AsyncGenerator<Record<_ttid, T> | _ttid | void, void, { count: number, limit?: number }> {
188
+
189
+ let finished = false
190
+
191
+ if(listen && !deleted) {
192
+
193
+ const iter = Walker.search(collection, p, { listen, skip })
194
+
195
+ do {
196
+
197
+ const { value, done } = await iter.next({ count, limit })
198
+
199
+ if(done) finished = true
200
+
201
+ if(value) {
202
+ const data = yield await constructData(collection, value._id, value.data)
203
+ count = data.count
204
+ limit = data.limit
205
+ }
206
+
207
+ } while(!finished)
208
+
209
+ } else if(listen && deleted) {
210
+
211
+ const iter = Walker.search(collection, p, { listen, skip }, "delete")
212
+
213
+ do {
214
+
215
+ const { value, done } = await iter.next({ count, limit })
216
+
217
+ if(done) finished = true
218
+
219
+ if(value) {
220
+ const data = yield value._id
221
+ count = data.count
222
+ limit = data.limit
223
+ }
224
+
225
+ } while(!finished)
226
+
227
+ } else {
228
+
229
+ const iter = Walker.search(collection, p, { listen, skip })
230
+
231
+ do {
232
+
233
+ const { value, done } = await iter.next({ count, limit })
234
+
235
+ if(done) finished = true
236
+
237
+ if(value) {
238
+ const data = yield await constructData(collection, value._id, value.data)
239
+ count = data.count
240
+ limit = data.limit
241
+ }
242
+
243
+ } while(!finished)
244
+ }
245
+ }
246
+
247
+ if(Array.isArray(pattern)) {
248
+
249
+ for(const p of pattern) yield* processQuery(p)
250
+
251
+ } else yield* processQuery(pattern)
252
+ }
253
+
254
+ async putKeys(collection: string, { dataKey, indexKey }: { dataKey: string, indexKey: string }) {
255
+
256
+ let dataBody: string | undefined
257
+ let indexBody: string | undefined
258
+
259
+ if(dataKey.length > Dir.KEY_LIMIT) {
260
+
261
+ const dataSegs = dataKey.split('/')
262
+
263
+ dataBody = dataSegs.pop()!
264
+
265
+ indexKey = `${dataSegs.join('/')}/${Bun.randomUUIDv7()}`
266
+ }
267
+
268
+ if(indexKey.length > Dir.KEY_LIMIT) {
269
+
270
+ const indexSegs = indexKey.split('/')
271
+
272
+ const _id = indexSegs.pop()! as _ttid
273
+
274
+ indexBody = indexSegs.pop()!
275
+
276
+ dataKey = `${indexSegs.join('/')}/${_id}`
277
+ }
278
+
279
+ await Promise.all([
280
+ S3.put(collection, dataKey, dataBody ?? ''),
281
+ S3.put(collection, indexKey, indexBody ?? '')
282
+ ])
283
+
284
+ this.transactions.push({
285
+ action: S3.delete,
286
+ args: [collection, dataKey]
287
+ })
288
+
289
+ this.transactions.push({
290
+ action: S3.delete,
291
+ args: [collection, indexKey]
292
+ })
293
+
294
+ await Dir.redis.publish(collection, 'insert', indexKey)
295
+ }
296
+
297
+ async executeRollback() {
298
+
299
+ do {
300
+
301
+ const transaction = this.transactions.pop()
302
+
303
+ if(transaction) {
304
+
305
+ const { action, args } = transaction
306
+
307
+ await action(...args)
308
+ }
309
+
310
+ } while(this.transactions.length > 0)
311
+ }
312
+
313
+ async deleteKeys(collection: string, dataKey: string) {
314
+
315
+ const segments = dataKey.split('/')
316
+
317
+ const _id = segments.shift()!
318
+
319
+ const indexKey = `${segments.join('/')}/${_id}`
320
+
321
+ const dataFile = S3.file(collection, dataKey)
322
+ const indexFile = S3.file(collection, indexKey)
323
+
324
+ let dataBody: string | undefined
325
+ let indexBody: string | undefined
326
+
327
+ if(dataFile.size > 0) dataBody = await dataFile.text()
328
+ if(indexFile.size > 0) indexBody = await indexFile.text()
329
+
330
+ await Promise.all([
331
+ S3.delete(collection, indexKey),
332
+ S3.delete(collection, dataKey)
333
+ ])
334
+
335
+ this.transactions.push({
336
+ action: S3.put,
337
+ args: [collection, dataKey, dataBody ?? '']
338
+ })
339
+
340
+ this.transactions.push({
341
+ action: S3.put,
342
+ args: [collection, indexKey, indexBody ?? '']
343
+ })
344
+
345
+ await Dir.redis.publish(collection, 'delete', _id)
346
+ }
347
+
348
+ static async extractKeys<T>(collection: string, _id: _ttid, data: T, parentField?: string) {
349
+
350
+ const keys: { data: string[], indexes: string[] } = { data: [], indexes: [] }
351
+
352
+ const obj = {...data}
353
+
354
+ for(const field in obj) {
355
+
356
+ const newField = parentField ? `${parentField}/${field}` : field
357
+
358
+ if(typeof obj[field] === 'object' && !Array.isArray(obj[field])) {
359
+ const items = await this.extractKeys(collection, _id, obj[field], newField)
360
+ keys.data.push(...items.data)
361
+ keys.indexes.push(...items.indexes)
362
+ } else if(typeof obj[field] === 'object' && Array.isArray(obj[field])) {
363
+ const items: (string | number | boolean)[] = obj[field]
364
+ if(items.some((item) => typeof item === 'object')) throw new Error(`Cannot have an array of objects`)
365
+ for(let i = 0; i < items.length; i++) {
366
+ let val = String(items[i]).split('/').join(this.SLASH_ASCII)
367
+ if(Cipher.isConfigured() && Cipher.isEncryptedField(collection, newField)) val = await Cipher.encrypt(val, true)
368
+ keys.data.push(`${_id}/${newField}/${i}/${val}`)
369
+ keys.indexes.push(`${newField}/${i}/${val}/${_id}`)
370
+ }
371
+ } else {
372
+ let val = String(obj[field]).replaceAll('/', this.SLASH_ASCII)
373
+ if(Cipher.isConfigured() && Cipher.isEncryptedField(collection, newField)) val = await Cipher.encrypt(val, true)
374
+ keys.data.push(`${_id}/${newField}/${val}`)
375
+ keys.indexes.push(`${newField}/${val}/${_id}`)
376
+ }
377
+ }
378
+
379
+ return keys
380
+ }
381
+
382
+ static constructData(fieldVal: Record<string, string>) {
383
+
384
+ const data: Record<string, any> = {}
385
+
386
+ for(let fullField in fieldVal) {
387
+
388
+ const fields = fullField.split('/').slice(1)
389
+
390
+ let curr = data
391
+
392
+ while(fields.length > 1) {
393
+
394
+ const field = fields.shift()!
395
+
396
+ if(typeof curr[field] !== 'object' || curr[field] === null)
397
+ curr[field] = isNaN(Number(fields[0])) ? {} : []
398
+
399
+ curr = curr[field]
400
+ }
401
+
402
+ const lastKey = fields.shift()!
403
+
404
+ curr[lastKey] = this.parseValue(fieldVal[fullField].replaceAll(this.SLASH_ASCII, '/'))
405
+ }
406
+
407
+ return data
408
+ }
409
+
410
+ static parseValue(value: string) {
411
+
412
+ try {
413
+ return JSON.parse(value)
414
+ } catch {
415
+ return value
416
+ }
417
+ }
418
+ }
@@ -0,0 +1,19 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+
3
+ Object.appendGroup = function (target: Record<string, any>, source: Record<string, any>): Record<string, any> {
4
+ const result = { ...target }
5
+
6
+ for (const [sourceId, sourceGroup] of Object.entries(source)) {
7
+
8
+ if (!result[sourceId]) {
9
+ result[sourceId] = sourceGroup
10
+ continue
11
+ }
12
+
13
+ for (const [groupId, groupDoc] of Object.entries(sourceGroup)) {
14
+ result[sourceId][groupId] = groupDoc
15
+ }
16
+ }
17
+
18
+ return result
19
+ }