@delma/fylo 2.0.0 → 2.1.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 (43) hide show
  1. package/README.md +185 -267
  2. package/package.json +2 -5
  3. package/src/core/directory.ts +22 -354
  4. package/src/engines/s3-files/documents.ts +65 -0
  5. package/src/engines/s3-files/filesystem.ts +172 -0
  6. package/src/engines/s3-files/query.ts +291 -0
  7. package/src/engines/s3-files/types.ts +42 -0
  8. package/src/engines/s3-files.ts +391 -510
  9. package/src/engines/types.ts +1 -1
  10. package/src/index.ts +142 -1237
  11. package/src/sync.ts +58 -0
  12. package/src/types/fylo.d.ts +66 -161
  13. package/src/types/node-runtime.d.ts +1 -0
  14. package/tests/collection/truncate.test.js +11 -10
  15. package/tests/helpers/root.js +7 -0
  16. package/tests/integration/create.test.js +9 -9
  17. package/tests/integration/delete.test.js +16 -14
  18. package/tests/integration/edge-cases.test.js +29 -25
  19. package/tests/integration/encryption.test.js +47 -30
  20. package/tests/integration/export.test.js +11 -11
  21. package/tests/integration/join-modes.test.js +16 -16
  22. package/tests/integration/nested.test.js +26 -24
  23. package/tests/integration/operators.test.js +43 -29
  24. package/tests/integration/read.test.js +25 -21
  25. package/tests/integration/rollback.test.js +21 -51
  26. package/tests/integration/s3-files.performance.test.js +75 -0
  27. package/tests/integration/s3-files.test.js +115 -18
  28. package/tests/integration/sync.test.js +154 -0
  29. package/tests/integration/update.test.js +24 -18
  30. package/src/adapters/redis.ts +0 -487
  31. package/src/adapters/s3.ts +0 -61
  32. package/src/core/walker.ts +0 -174
  33. package/src/core/write-queue.ts +0 -59
  34. package/src/migrate-cli.ts +0 -22
  35. package/src/migrate.ts +0 -74
  36. package/src/types/write-queue.ts +0 -42
  37. package/src/worker.ts +0 -18
  38. package/src/workers/write-worker.ts +0 -120
  39. package/tests/index.js +0 -14
  40. package/tests/integration/migration.test.js +0 -38
  41. package/tests/integration/queue.test.js +0 -83
  42. package/tests/mocks/redis.js +0 -123
  43. package/tests/mocks/s3.js +0 -80
@@ -1,487 +0,0 @@
1
- import { RedisClient } from 'bun'
2
- import { S3 } from './s3'
3
- import type {
4
- DeadLetterJob,
5
- QueueStats,
6
- StreamJobEntry,
7
- WriteJob,
8
- WriteJobStatus
9
- } from '../types/write-queue'
10
-
11
- export class Redis {
12
- static readonly WRITE_STREAM = 'fylo:writes'
13
-
14
- static readonly WRITE_GROUP = 'fylo-workers'
15
-
16
- static readonly DEAD_LETTER_STREAM = 'fylo:writes:dead'
17
-
18
- private client: RedisClient
19
-
20
- private static LOGGING = process.env.LOGGING
21
-
22
- constructor() {
23
- const redisUrl = process.env.REDIS_URL
24
- if (!redisUrl) throw new Error('REDIS_URL environment variable is required')
25
-
26
- this.client = new RedisClient(redisUrl, {
27
- connectionTimeout: process.env.REDIS_CONN_TIMEOUT
28
- ? Number(process.env.REDIS_CONN_TIMEOUT)
29
- : undefined,
30
- idleTimeout: process.env.REDIS_IDLE_TIMEOUT
31
- ? Number(process.env.REDIS_IDLE_TIMEOUT)
32
- : undefined,
33
- autoReconnect: process.env.REDIS_AUTO_CONNECT ? true : undefined,
34
- maxRetries: process.env.REDIS_MAX_RETRIES
35
- ? Number(process.env.REDIS_MAX_RETRIES)
36
- : undefined,
37
- enableOfflineQueue: process.env.REDIS_ENABLE_OFFLINE_QUEUE ? true : undefined,
38
- enableAutoPipelining: process.env.REDIS_ENABLE_AUTO_PIPELINING ? true : undefined,
39
- tls: process.env.REDIS_TLS ? true : undefined
40
- })
41
-
42
- this.client.onconnect = () => {
43
- if (Redis.LOGGING) console.log('Client Connected')
44
- }
45
-
46
- this.client.onclose = (err) => console.error('Redis client connection closed', err.message)
47
-
48
- this.client.connect()
49
- }
50
-
51
- private async ensureWriteGroup() {
52
- if (!this.client.connected) throw new Error('Redis not connected!')
53
-
54
- try {
55
- await this.client.send('XGROUP', [
56
- 'CREATE',
57
- Redis.WRITE_STREAM,
58
- Redis.WRITE_GROUP,
59
- '$',
60
- 'MKSTREAM'
61
- ])
62
- } catch (err) {
63
- if (!(err instanceof Error) || !err.message.includes('BUSYGROUP')) throw err
64
- }
65
- }
66
-
67
- private static hashKey(jobId: string) {
68
- return `fylo:job:${jobId}`
69
- }
70
-
71
- private static docKey(collection: string, docId: _ttid) {
72
- return `fylo:doc:${collection}:${docId}`
73
- }
74
-
75
- private static lockKey(collection: string, docId: _ttid) {
76
- return `fylo:lock:${collection}:${docId}`
77
- }
78
-
79
- private static parseHash(values: unknown): Record<string, string> {
80
- if (!Array.isArray(values)) return {}
81
-
82
- const parsed: Record<string, string> = {}
83
-
84
- for (let i = 0; i < values.length; i += 2) {
85
- parsed[String(values[i])] = String(values[i + 1] ?? '')
86
- }
87
-
88
- return parsed
89
- }
90
-
91
- async publish(collection: string, action: 'insert' | 'delete', keyId: string | _ttid) {
92
- if (this.client.connected) {
93
- await this.client.publish(
94
- S3.getBucketFormat(collection),
95
- JSON.stringify({ action, keyId })
96
- )
97
- }
98
- }
99
-
100
- async claimTTID(_id: _ttid, ttlSeconds: number = 10): Promise<boolean> {
101
- if (!this.client.connected) return false
102
-
103
- const result = await this.client.send('SET', [
104
- `ttid:${_id}`,
105
- '1',
106
- 'NX',
107
- 'EX',
108
- String(ttlSeconds)
109
- ])
110
-
111
- return result === 'OK'
112
- }
113
-
114
- async enqueueWrite<T extends Record<string, any>>(job: WriteJob<T>) {
115
- if (!this.client.connected) throw new Error('Redis not connected!')
116
-
117
- await this.ensureWriteGroup()
118
-
119
- const now = Date.now()
120
- const payload = JSON.stringify(job.payload)
121
-
122
- await this.client.send('HSET', [
123
- Redis.hashKey(job.jobId),
124
- 'jobId',
125
- job.jobId,
126
- 'collection',
127
- job.collection,
128
- 'docId',
129
- job.docId,
130
- 'operation',
131
- job.operation,
132
- 'payload',
133
- payload,
134
- 'status',
135
- job.status,
136
- 'attempts',
137
- String(job.attempts),
138
- 'createdAt',
139
- String(job.createdAt),
140
- 'updatedAt',
141
- String(now),
142
- 'nextAttemptAt',
143
- String(job.nextAttemptAt ?? now)
144
- ])
145
-
146
- await this.client.send('HSET', [
147
- Redis.docKey(job.collection, job.docId),
148
- 'status',
149
- 'queued',
150
- 'lastJobId',
151
- job.jobId,
152
- 'updatedAt',
153
- String(now)
154
- ])
155
-
156
- return await this.client.send('XADD', [
157
- Redis.WRITE_STREAM,
158
- '*',
159
- 'jobId',
160
- job.jobId,
161
- 'collection',
162
- job.collection,
163
- 'docId',
164
- job.docId,
165
- 'operation',
166
- job.operation
167
- ])
168
- }
169
-
170
- async readWriteJobs(
171
- workerId: string,
172
- count: number = 1,
173
- blockMs: number = 1000
174
- ): Promise<Array<StreamJobEntry>> {
175
- if (!this.client.connected) throw new Error('Redis not connected!')
176
-
177
- await this.ensureWriteGroup()
178
-
179
- const rows = await this.client.send('XREADGROUP', [
180
- 'GROUP',
181
- Redis.WRITE_GROUP,
182
- workerId,
183
- 'COUNT',
184
- String(count),
185
- 'BLOCK',
186
- String(blockMs),
187
- 'STREAMS',
188
- Redis.WRITE_STREAM,
189
- '>'
190
- ])
191
-
192
- if (!Array.isArray(rows) || rows.length === 0) return []
193
-
194
- const items: Array<StreamJobEntry> = []
195
-
196
- for (const streamRow of rows as unknown[]) {
197
- if (!Array.isArray(streamRow) || streamRow.length < 2) continue
198
- const entries = streamRow[1]
199
- if (!Array.isArray(entries)) continue
200
-
201
- for (const entry of entries as unknown[]) {
202
- if (!Array.isArray(entry) || entry.length < 2) continue
203
- const streamId = String(entry[0])
204
- const fields = Redis.parseHash(entry[1])
205
- const job = await this.getJob(fields.jobId)
206
- if (job) items.push({ streamId, job })
207
- }
208
- }
209
-
210
- return items
211
- }
212
-
213
- async ackWriteJob(streamId: string) {
214
- if (!this.client.connected) throw new Error('Redis not connected!')
215
-
216
- await this.client.send('XACK', [Redis.WRITE_STREAM, Redis.WRITE_GROUP, streamId])
217
- }
218
-
219
- async deadLetterWriteJob(streamId: string, job: WriteJob, reason?: string) {
220
- if (!this.client.connected) throw new Error('Redis not connected!')
221
-
222
- const failedAt = Date.now()
223
-
224
- await this.client.send('XADD', [
225
- Redis.DEAD_LETTER_STREAM,
226
- '*',
227
- 'jobId',
228
- job.jobId,
229
- 'collection',
230
- job.collection,
231
- 'docId',
232
- job.docId,
233
- 'operation',
234
- job.operation,
235
- 'reason',
236
- reason ?? '',
237
- 'failedAt',
238
- String(failedAt)
239
- ])
240
-
241
- await this.ackWriteJob(streamId)
242
- }
243
-
244
- async claimPendingJobs(
245
- workerId: string,
246
- minIdleMs: number = 30_000,
247
- count: number = 10
248
- ): Promise<Array<StreamJobEntry>> {
249
- if (!this.client.connected) throw new Error('Redis not connected!')
250
-
251
- await this.ensureWriteGroup()
252
-
253
- const result = await this.client.send('XAUTOCLAIM', [
254
- Redis.WRITE_STREAM,
255
- Redis.WRITE_GROUP,
256
- workerId,
257
- String(minIdleMs),
258
- '0-0',
259
- 'COUNT',
260
- String(count)
261
- ])
262
-
263
- if (!Array.isArray(result) || result.length < 2 || !Array.isArray(result[1])) return []
264
-
265
- const items: Array<StreamJobEntry> = []
266
-
267
- for (const entry of result[1] as unknown[]) {
268
- if (!Array.isArray(entry) || entry.length < 2) continue
269
- const streamId = String(entry[0])
270
- const fields = Redis.parseHash(entry[1])
271
- const job = await this.getJob(fields.jobId)
272
- if (job) items.push({ streamId, job })
273
- }
274
-
275
- return items
276
- }
277
-
278
- async setJobStatus(
279
- jobId: string,
280
- status: WriteJobStatus,
281
- extra: Partial<Pick<WriteJob, 'workerId' | 'error' | 'attempts' | 'nextAttemptAt'>> = {}
282
- ) {
283
- if (!this.client.connected) throw new Error('Redis not connected!')
284
-
285
- const args = [Redis.hashKey(jobId), 'status', status, 'updatedAt', String(Date.now())]
286
-
287
- if (extra.workerId) args.push('workerId', extra.workerId)
288
- if (extra.error) args.push('error', extra.error)
289
- if (typeof extra.attempts === 'number') args.push('attempts', String(extra.attempts))
290
- if (typeof extra.nextAttemptAt === 'number')
291
- args.push('nextAttemptAt', String(extra.nextAttemptAt))
292
-
293
- await this.client.send('HSET', args)
294
- }
295
-
296
- async setDocStatus(collection: string, docId: _ttid, status: WriteJobStatus, jobId?: string) {
297
- if (!this.client.connected) throw new Error('Redis not connected!')
298
-
299
- const args = [
300
- Redis.docKey(collection, docId),
301
- 'status',
302
- status,
303
- 'updatedAt',
304
- String(Date.now())
305
- ]
306
-
307
- if (jobId) args.push('lastJobId', jobId)
308
-
309
- await this.client.send('HSET', args)
310
- }
311
-
312
- async getJob(jobId: string): Promise<WriteJob | null> {
313
- if (!this.client.connected) throw new Error('Redis not connected!')
314
-
315
- const hash = Redis.parseHash(await this.client.send('HGETALL', [Redis.hashKey(jobId)]))
316
-
317
- if (Object.keys(hash).length === 0) return null
318
-
319
- return {
320
- jobId: hash.jobId,
321
- collection: hash.collection,
322
- docId: hash.docId as _ttid,
323
- operation: hash.operation as WriteJob['operation'],
324
- payload: JSON.parse(hash.payload),
325
- status: hash.status as WriteJobStatus,
326
- attempts: Number(hash.attempts ?? 0),
327
- createdAt: Number(hash.createdAt ?? 0),
328
- updatedAt: Number(hash.updatedAt ?? 0),
329
- nextAttemptAt: Number(hash.nextAttemptAt ?? 0) || undefined,
330
- workerId: hash.workerId || undefined,
331
- error: hash.error || undefined
332
- }
333
- }
334
-
335
- async getDocStatus(collection: string, docId: _ttid) {
336
- if (!this.client.connected) throw new Error('Redis not connected!')
337
-
338
- const hash = Redis.parseHash(
339
- await this.client.send('HGETALL', [Redis.docKey(collection, docId)])
340
- )
341
-
342
- return Object.keys(hash).length > 0 ? hash : null
343
- }
344
-
345
- async readDeadLetters(count: number = 10): Promise<Array<DeadLetterJob>> {
346
- if (!this.client.connected) throw new Error('Redis not connected!')
347
-
348
- const rows = await this.client.send('XRANGE', [
349
- Redis.DEAD_LETTER_STREAM,
350
- '-',
351
- '+',
352
- 'COUNT',
353
- String(count)
354
- ])
355
-
356
- if (!Array.isArray(rows)) return []
357
-
358
- const items: Array<DeadLetterJob> = []
359
-
360
- for (const row of rows as unknown[]) {
361
- if (!Array.isArray(row) || row.length < 2) continue
362
- const streamId = String(row[0])
363
- const fields = Redis.parseHash(row[1])
364
- const job = await this.getJob(fields.jobId)
365
-
366
- if (job) {
367
- items.push({
368
- streamId,
369
- job,
370
- reason: fields.reason || undefined,
371
- failedAt: Number(fields.failedAt ?? 0)
372
- })
373
- }
374
- }
375
-
376
- return items
377
- }
378
-
379
- async replayDeadLetter(streamId: string): Promise<WriteJob | null> {
380
- if (!this.client.connected) throw new Error('Redis not connected!')
381
-
382
- const rows = await this.client.send('XRANGE', [
383
- Redis.DEAD_LETTER_STREAM,
384
- streamId,
385
- streamId,
386
- 'COUNT',
387
- '1'
388
- ])
389
-
390
- if (!Array.isArray(rows) || rows.length === 0) return null
391
-
392
- const row = rows[0]
393
- if (!Array.isArray(row) || row.length < 2) return null
394
-
395
- const fields = Redis.parseHash(row[1])
396
- const job = await this.getJob(fields.jobId)
397
-
398
- if (!job) return null
399
-
400
- const replayed: WriteJob = {
401
- ...job,
402
- status: 'queued',
403
- error: undefined,
404
- workerId: undefined,
405
- attempts: 0,
406
- updatedAt: Date.now(),
407
- nextAttemptAt: Date.now()
408
- }
409
-
410
- await this.enqueueWrite(replayed)
411
- await this.client.send('XDEL', [Redis.DEAD_LETTER_STREAM, streamId])
412
-
413
- return replayed
414
- }
415
-
416
- async getQueueStats(): Promise<QueueStats> {
417
- if (!this.client.connected) throw new Error('Redis not connected!')
418
-
419
- await this.ensureWriteGroup()
420
-
421
- const [queuedRaw, deadRaw, pendingRaw] = await Promise.all([
422
- this.client.send('XLEN', [Redis.WRITE_STREAM]),
423
- this.client.send('XLEN', [Redis.DEAD_LETTER_STREAM]),
424
- this.client.send('XPENDING', [Redis.WRITE_STREAM, Redis.WRITE_GROUP])
425
- ])
426
-
427
- const pending = Array.isArray(pendingRaw) ? Number(pendingRaw[0] ?? 0) : 0
428
-
429
- return {
430
- queued: Number(queuedRaw ?? 0),
431
- pending,
432
- deadLetters: Number(deadRaw ?? 0)
433
- }
434
- }
435
-
436
- async acquireDocLock(collection: string, docId: _ttid, jobId: string, ttlSeconds: number = 60) {
437
- if (!this.client.connected) throw new Error('Redis not connected!')
438
-
439
- const result = await this.client.send('SET', [
440
- Redis.lockKey(collection, docId),
441
- jobId,
442
- 'NX',
443
- 'EX',
444
- String(ttlSeconds)
445
- ])
446
-
447
- return result === 'OK'
448
- }
449
-
450
- async releaseDocLock(collection: string, docId: _ttid, jobId: string) {
451
- if (!this.client.connected) throw new Error('Redis not connected!')
452
-
453
- const key = Redis.lockKey(collection, docId)
454
- const current = await this.client.send('GET', [key])
455
- if (current === jobId) await this.client.send('DEL', [key])
456
- }
457
-
458
- async *subscribe(collection: string) {
459
- if (!this.client.connected) throw new Error('Redis not connected!')
460
-
461
- const client = this.client
462
-
463
- const stream = new ReadableStream({
464
- async start(controller) {
465
- await client.subscribe(S3.getBucketFormat(collection), (message) => {
466
- controller.enqueue(message)
467
- })
468
- }
469
- })
470
-
471
- const reader = stream.getReader()
472
-
473
- while (true) {
474
- const { done, value } = await reader.read()
475
- if (done) break
476
- const parsed = JSON.parse(value)
477
- if (
478
- typeof parsed !== 'object' ||
479
- parsed === null ||
480
- !('action' in parsed) ||
481
- !('keyId' in parsed)
482
- )
483
- continue
484
- yield parsed
485
- }
486
- }
487
- }
@@ -1,61 +0,0 @@
1
- import { $, S3Client } from 'bun'
2
- import { validateCollectionName } from '../core/collection'
3
-
4
- export class S3 {
5
- static readonly BUCKET_ENV = process.env.BUCKET_PREFIX
6
-
7
- static readonly CREDS = {
8
- accessKeyId: process.env.S3_ACCESS_KEY_ID ?? process.env.AWS_ACCESS_KEY_ID,
9
- secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? process.env.AWS_SECRET_ACCESS_KEY,
10
- region: process.env.S3_REGION ?? process.env.AWS_REGION,
11
- endpoint: process.env.S3_ENDPOINT ?? process.env.AWS_ENDPOINT
12
- }
13
-
14
- private static validateCollection(collection: string): void {
15
- validateCollectionName(collection)
16
- }
17
-
18
- static getBucketFormat(collection: string) {
19
- S3.validateCollection(collection)
20
- return S3.BUCKET_ENV ? `${S3.BUCKET_ENV}-${collection}` : collection
21
- }
22
-
23
- static file(collection: string, path: string) {
24
- return S3Client.file(path, {
25
- bucket: S3.getBucketFormat(collection),
26
- ...S3.CREDS
27
- })
28
- }
29
-
30
- static async list(collection: string, options?: Bun.S3ListObjectsOptions) {
31
- return await S3Client.list(options, {
32
- bucket: S3.getBucketFormat(collection),
33
- ...S3.CREDS
34
- })
35
- }
36
-
37
- static async put(collection: string, path: string, data: string) {
38
- await S3Client.write(path, data, {
39
- bucket: S3.getBucketFormat(collection),
40
- ...S3.CREDS
41
- })
42
- }
43
-
44
- static async delete(collection: string, path: string) {
45
- await S3Client.delete(path, {
46
- bucket: S3.getBucketFormat(collection),
47
- ...S3.CREDS
48
- })
49
- }
50
-
51
- static async createBucket(collection: string) {
52
- const endpoint = S3.CREDS.endpoint
53
- await $`aws s3 mb s3://${S3.getBucketFormat(collection)} ${endpoint ? `--endpoint-url=${endpoint}` : ''}`.quiet()
54
- }
55
-
56
- static async deleteBucket(collection: string) {
57
- const endpoint = S3.CREDS.endpoint
58
- await $`aws s3 rm s3://${S3.getBucketFormat(collection)} --recursive ${endpoint ? `--endpoint-url=${endpoint}` : ''}`.quiet()
59
- await $`aws s3 rb s3://${S3.getBucketFormat(collection)} ${endpoint ? `--endpoint-url=${endpoint}` : ''}`.quiet()
60
- }
61
- }