@delma/fylo 1.0.0 → 1.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.
@@ -0,0 +1,99 @@
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
+ const POSTS = 'queued-post'
7
+
8
+ mock.module('../../src/adapters/s3', () => ({ S3: S3Mock }))
9
+ mock.module('../../src/adapters/redis', () => ({ Redis: RedisMock }))
10
+
11
+ const fylo = new Fylo()
12
+
13
+ beforeAll(async () => {
14
+ await Fylo.createCollection(POSTS)
15
+ })
16
+
17
+ afterAll(async () => {
18
+ await Fylo.dropCollection(POSTS)
19
+ })
20
+
21
+ describe('queue writes', () => {
22
+ test('queuePutData enqueues and worker commits insert', async () => {
23
+ const queued = await fylo.queuePutData(POSTS, {
24
+ title: 'Queued Title',
25
+ body: 'Queued Body'
26
+ })
27
+
28
+ const queuedStatus = await fylo.getJobStatus(queued.jobId)
29
+ expect(queuedStatus?.status).toBe('queued')
30
+
31
+ const processed = await fylo.processQueuedWrites(1)
32
+ expect(processed).toBe(1)
33
+
34
+ const doc = await Fylo.getDoc<_post>(POSTS, queued.docId, false).once()
35
+ expect(Object.keys(doc).length).toBe(1)
36
+ })
37
+
38
+ test('putData can return immediately when wait is false', async () => {
39
+ const queued = await fylo.putData(POSTS, {
40
+ title: 'Async Title',
41
+ body: 'Async Body'
42
+ } as _post, { wait: false })
43
+
44
+ expect(typeof queued).toBe('object')
45
+ expect('jobId' in queued).toBe(true)
46
+
47
+ const before = await Fylo.getDoc<_post>(POSTS, queued.docId, false).once()
48
+ expect(Object.keys(before).length).toBe(0)
49
+
50
+ const processed = await fylo.processQueuedWrites(1)
51
+ expect(processed).toBe(1)
52
+
53
+ const stats = await fylo.getQueueStats()
54
+ expect(stats.deadLetters).toBe(0)
55
+
56
+ const after = await Fylo.getDoc<_post>(POSTS, queued.docId, false).once()
57
+ expect(Object.keys(after).length).toBe(1)
58
+ })
59
+
60
+ test('failed jobs can be recovered and eventually dead-lettered', async () => {
61
+ const originalExecute = fylo.executeQueuedWrite.bind(fylo)
62
+ ;(fylo as any).executeQueuedWrite = async () => {
63
+ throw new Error('simulated write failure')
64
+ }
65
+
66
+ try {
67
+ const queued = await fylo.putData(POSTS, {
68
+ title: 'Broken Title',
69
+ body: 'Broken Body'
70
+ } as _post, { wait: false })
71
+
72
+ expect(await fylo.processQueuedWrites(1)).toBe(0)
73
+ expect((await fylo.getJobStatus(queued.jobId))?.status).toBe('failed')
74
+
75
+ await Bun.sleep(15)
76
+ expect(await fylo.processQueuedWrites(1, true)).toBe(0)
77
+ expect((await fylo.getJobStatus(queued.jobId))?.status).toBe('failed')
78
+
79
+ await Bun.sleep(25)
80
+ expect(await fylo.processQueuedWrites(1, true)).toBe(0)
81
+ expect((await fylo.getJobStatus(queued.jobId))?.status).toBe('dead-letter')
82
+
83
+ const deadLetters = await fylo.getDeadLetters()
84
+ expect((await fylo.getQueueStats()).deadLetters).toBeGreaterThan(0)
85
+ expect(deadLetters.some(item => item.job.jobId === queued.jobId)).toBe(true)
86
+
87
+ ;(fylo as any).executeQueuedWrite = originalExecute
88
+
89
+ const replayed = await fylo.replayDeadLetter(deadLetters[0]!.streamId)
90
+ expect(replayed?.jobId).toBe(queued.jobId)
91
+ expect((await fylo.getQueueStats()).deadLetters).toBe(0)
92
+
93
+ expect(await fylo.processQueuedWrites(1)).toBe(1)
94
+ expect((await fylo.getJobStatus(queued.jobId))?.status).toBe('committed')
95
+ } finally {
96
+ ;(fylo as any).executeQueuedWrite = originalExecute
97
+ }
98
+ })
99
+ })
@@ -5,9 +5,165 @@
5
5
  */
6
6
  export default class RedisMock {
7
7
 
8
+ private static stream: Array<{
9
+ streamId: string
10
+ jobId: string
11
+ collection: string
12
+ docId: _ttid
13
+ operation: string
14
+ claimedBy?: string
15
+ }> = []
16
+
17
+ private static jobs = new Map<string, Record<string, any>>()
18
+
19
+ private static docs = new Map<string, Record<string, string>>()
20
+
21
+ private static locks = new Map<string, string>()
22
+
23
+ private static deadLetters: Array<{ streamId: string, jobId: string, reason?: string, failedAt: number }> = []
24
+
25
+ private static nextId = 0
26
+
8
27
  async publish(_collection: string, _action: 'insert' | 'delete', _keyId: string | _ttid): Promise<void> {}
9
28
 
10
29
  async claimTTID(_id: _ttid, _ttlSeconds: number = 10): Promise<boolean> { return true }
11
30
 
31
+ async enqueueWrite(job: Record<string, any>) {
32
+ RedisMock.jobs.set(job.jobId, { ...job, nextAttemptAt: job.nextAttemptAt ?? Date.now() })
33
+ RedisMock.docs.set(`fylo:doc:${job.collection}:${job.docId}`, {
34
+ status: 'queued',
35
+ lastJobId: job.jobId,
36
+ updatedAt: String(Date.now())
37
+ })
38
+ const streamId = String(++RedisMock.nextId)
39
+ RedisMock.stream.push({
40
+ streamId,
41
+ jobId: job.jobId,
42
+ collection: job.collection,
43
+ docId: job.docId,
44
+ operation: job.operation
45
+ })
46
+ return streamId
47
+ }
48
+
49
+ async readWriteJobs(workerId: string, count: number = 1): Promise<Array<{ streamId: string, job: Record<string, any> }>> {
50
+ const available = RedisMock.stream
51
+ .filter(entry => !entry.claimedBy)
52
+ .slice(0, count)
53
+
54
+ for(const entry of available) entry.claimedBy = workerId
55
+
56
+ return available.map(entry => ({
57
+ streamId: entry.streamId,
58
+ job: { ...RedisMock.jobs.get(entry.jobId)! }
59
+ }))
60
+ }
61
+
62
+ async ackWriteJob(streamId: string) {
63
+ RedisMock.stream = RedisMock.stream.filter(item => item.streamId !== streamId)
64
+ }
65
+
66
+ async deadLetterWriteJob(streamId: string, job: Record<string, any>, reason?: string) {
67
+ RedisMock.deadLetters.push({
68
+ streamId: String(RedisMock.deadLetters.length + 1),
69
+ jobId: job.jobId,
70
+ reason,
71
+ failedAt: Date.now()
72
+ })
73
+
74
+ await this.ackWriteJob(streamId)
75
+ }
76
+
77
+ async claimPendingJobs(workerId: string, _minIdleMs: number = 30_000, count: number = 10) {
78
+ const pending = RedisMock.stream
79
+ .filter(entry => entry.claimedBy)
80
+ .slice(0, count)
81
+
82
+ for(const entry of pending) entry.claimedBy = workerId
83
+
84
+ return pending.map(entry => ({
85
+ streamId: entry.streamId,
86
+ job: { ...RedisMock.jobs.get(entry.jobId)! }
87
+ }))
88
+ }
89
+
90
+ async setJobStatus(jobId: string, status: string, extra: Record<string, any> = {}) {
91
+ const job = RedisMock.jobs.get(jobId)
92
+ if(job) Object.assign(job, extra, { status, updatedAt: Date.now() })
93
+ }
94
+
95
+ async setDocStatus(collection: string, docId: _ttid, status: string, jobId?: string) {
96
+ const key = `fylo:doc:${collection}:${docId}`
97
+ const curr = RedisMock.docs.get(key) ?? {}
98
+ RedisMock.docs.set(key, {
99
+ ...curr,
100
+ status,
101
+ updatedAt: String(Date.now()),
102
+ ...(jobId ? { lastJobId: jobId } : {})
103
+ })
104
+ }
105
+
106
+ async getJob(jobId: string): Promise<Record<string, any> | null> {
107
+ const job = RedisMock.jobs.get(jobId)
108
+ return job ? { ...job } : null
109
+ }
110
+
111
+ async getDocStatus(collection: string, docId: _ttid): Promise<Record<string, string> | null> {
112
+ return RedisMock.docs.get(`fylo:doc:${collection}:${docId}`) ?? null
113
+ }
114
+
115
+ async readDeadLetters(count: number = 10) {
116
+ return RedisMock.deadLetters.slice(0, count).map(item => ({
117
+ streamId: item.streamId,
118
+ job: { ...RedisMock.jobs.get(item.jobId)! },
119
+ reason: item.reason,
120
+ failedAt: item.failedAt
121
+ }))
122
+ }
123
+
124
+ async replayDeadLetter(streamId: string) {
125
+ const item = RedisMock.deadLetters.find(entry => entry.streamId === streamId)
126
+ if(!item) return null
127
+
128
+ const job = RedisMock.jobs.get(item.jobId)
129
+ if(!job) return null
130
+
131
+ const replayed = {
132
+ ...job,
133
+ status: 'queued',
134
+ error: undefined,
135
+ workerId: undefined,
136
+ attempts: 0,
137
+ updatedAt: Date.now(),
138
+ nextAttemptAt: Date.now()
139
+ }
140
+
141
+ RedisMock.jobs.set(item.jobId, replayed)
142
+ await this.enqueueWrite(replayed)
143
+ RedisMock.deadLetters = RedisMock.deadLetters.filter(entry => entry.streamId !== streamId)
144
+
145
+ return { ...replayed }
146
+ }
147
+
148
+ async getQueueStats() {
149
+ return {
150
+ queued: RedisMock.stream.length,
151
+ pending: RedisMock.stream.filter(entry => entry.claimedBy).length,
152
+ deadLetters: RedisMock.deadLetters.length
153
+ }
154
+ }
155
+
156
+ async acquireDocLock(collection: string, docId: _ttid, jobId: string) {
157
+ const key = `fylo:lock:${collection}:${docId}`
158
+ if(RedisMock.locks.has(key)) return false
159
+ RedisMock.locks.set(key, jobId)
160
+ return true
161
+ }
162
+
163
+ async releaseDocLock(collection: string, docId: _ttid, jobId: string) {
164
+ const key = `fylo:lock:${collection}:${docId}`
165
+ if(RedisMock.locks.get(key) === jobId) RedisMock.locks.delete(key)
166
+ }
167
+
12
168
  async *subscribe(_collection: string): AsyncGenerator<never, void, unknown> {}
13
169
  }