@delma/fylo 1.1.1 → 2.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 (74) hide show
  1. package/.github/copilot-instructions.md +1 -1
  2. package/.github/prompts/release.prompt.md +4 -43
  3. package/AGENTS.md +1 -1
  4. package/CLAUDE.md +1 -1
  5. package/README.md +141 -62
  6. package/eslint.config.js +8 -4
  7. package/package.json +9 -7
  8. package/src/CLI +16 -14
  9. package/src/adapters/cipher.ts +12 -6
  10. package/src/adapters/redis.ts +193 -123
  11. package/src/adapters/s3.ts +6 -12
  12. package/src/core/collection.ts +5 -0
  13. package/src/core/directory.ts +120 -151
  14. package/src/core/extensions.ts +4 -2
  15. package/src/core/format.ts +390 -419
  16. package/src/core/parser.ts +167 -142
  17. package/src/core/query.ts +31 -26
  18. package/src/core/walker.ts +68 -61
  19. package/src/core/write-queue.ts +7 -4
  20. package/src/engines/s3-files.ts +888 -0
  21. package/src/engines/types.ts +21 -0
  22. package/src/index.ts +754 -378
  23. package/src/migrate-cli.ts +22 -0
  24. package/src/migrate.ts +74 -0
  25. package/src/types/bun-runtime.d.ts +73 -0
  26. package/src/types/fylo.d.ts +115 -27
  27. package/src/types/node-runtime.d.ts +61 -0
  28. package/src/types/query.d.ts +6 -2
  29. package/src/types/vendor-modules.d.ts +8 -7
  30. package/src/worker.ts +7 -1
  31. package/src/workers/write-worker.ts +25 -24
  32. package/tests/collection/truncate.test.js +35 -0
  33. package/tests/{data.ts → data.js} +8 -21
  34. package/tests/{index.ts → index.js} +4 -9
  35. package/tests/integration/aws-s3-files.canary.test.js +22 -0
  36. package/tests/integration/{create.test.ts → create.test.js} +13 -31
  37. package/tests/integration/delete.test.js +95 -0
  38. package/tests/integration/{edge-cases.test.ts → edge-cases.test.js} +50 -124
  39. package/tests/integration/{encryption.test.ts → encryption.test.js} +20 -65
  40. package/tests/integration/{export.test.ts → export.test.js} +8 -23
  41. package/tests/integration/{join-modes.test.ts → join-modes.test.js} +37 -104
  42. package/tests/integration/migration.test.js +38 -0
  43. package/tests/integration/nested.test.js +142 -0
  44. package/tests/integration/operators.test.js +122 -0
  45. package/tests/integration/{queue.test.ts → queue.test.js} +24 -40
  46. package/tests/integration/read.test.js +119 -0
  47. package/tests/integration/rollback.test.js +60 -0
  48. package/tests/integration/s3-files.test.js +108 -0
  49. package/tests/integration/update.test.js +99 -0
  50. package/tests/mocks/{cipher.ts → cipher.js} +11 -26
  51. package/tests/mocks/redis.js +123 -0
  52. package/tests/mocks/{s3.ts → s3.js} +24 -58
  53. package/tests/schemas/album.json +1 -1
  54. package/tests/schemas/comment.json +1 -1
  55. package/tests/schemas/photo.json +1 -1
  56. package/tests/schemas/post.json +1 -1
  57. package/tests/schemas/tip.json +1 -1
  58. package/tests/schemas/todo.json +1 -1
  59. package/tests/schemas/user.d.ts +12 -12
  60. package/tests/schemas/user.json +1 -1
  61. package/tsconfig.json +4 -2
  62. package/tsconfig.typecheck.json +31 -0
  63. package/.github/prompts/issue.prompt.md +0 -19
  64. package/.github/prompts/pr.prompt.md +0 -18
  65. package/.github/prompts/review-pr.prompt.md +0 -19
  66. package/.github/prompts/sync-main.prompt.md +0 -14
  67. package/tests/collection/truncate.test.ts +0 -56
  68. package/tests/integration/delete.test.ts +0 -147
  69. package/tests/integration/nested.test.ts +0 -212
  70. package/tests/integration/operators.test.ts +0 -167
  71. package/tests/integration/read.test.ts +0 -203
  72. package/tests/integration/rollback.test.ts +0 -105
  73. package/tests/integration/update.test.ts +0 -130
  74. package/tests/mocks/redis.ts +0 -169
@@ -1,203 +0,0 @@
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
- const POSTS = `post`
8
- const ALBUMS = `album`
9
-
10
- let count = 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
-
19
- await Promise.all([Fylo.createCollection(ALBUMS), fylo.executeSQL<_post>(`CREATE TABLE ${POSTS}`)])
20
-
21
- try {
22
- count = await fylo.importBulkData<_album>(ALBUMS, new URL(albumURL), 100)
23
- await fylo.importBulkData<_post>(POSTS, new URL(postsURL), 100)
24
- } catch {
25
- await fylo.rollback()
26
- }
27
- })
28
-
29
- afterAll(async () => {
30
- await Promise.all([Fylo.dropCollection(ALBUMS), Fylo.dropCollection(POSTS)])
31
- })
32
-
33
- describe("NO-SQL", async () => {
34
-
35
- test("SELECT ALL", async () => {
36
-
37
- let results: Record<_ttid, _album> = {}
38
-
39
- for await (const data of Fylo.findDocs<_album>(ALBUMS).collect()) {
40
-
41
- results = { ...results, ... data as Record<_ttid, _album> }
42
- }
43
-
44
- //console.format(results)
45
-
46
- expect(Object.keys(results).length).toBe(count)
47
- })
48
-
49
- test("SELECT PARTIAL", async () => {
50
-
51
- let results: Record<_ttid, _album> = {}
52
-
53
- for await (const data of Fylo.findDocs<_album>(ALBUMS, { $select: ["title"] }).collect()) {
54
-
55
- results = { ...results, ... data as Record<_ttid, _album> }
56
- }
57
-
58
- //console.format(results)
59
-
60
- const allAlbums = Object.values(results)
61
-
62
- const onlyTtitle = allAlbums.every(user => user.title && !user.userId)
63
-
64
- expect(onlyTtitle).toBe(true)
65
-
66
- })
67
-
68
- test("GET ONE", async () => {
69
-
70
- const ids: _ttid[] = []
71
-
72
- for await (const data of Fylo.findDocs<_album>(ALBUMS, { $limit: 1, $onlyIds: true }).collect()) {
73
-
74
- ids.push(data as _ttid)
75
- }
76
-
77
- const result = await Fylo.getDoc<_album>(ALBUMS, ids[0]).once()
78
-
79
- const _id = Object.keys(result).shift()!
80
-
81
- expect(ids[0]).toEqual(_id)
82
- })
83
-
84
- test("SELECT CLAUSE", async () => {
85
-
86
- let results: Record<_ttid, _album> = {}
87
-
88
- for await (const data of Fylo.findDocs<_album>(ALBUMS, { $ops: [{ userId: { $eq: 2 } }] }).collect()) {
89
-
90
- results = { ...results, ...data as Record<_ttid, _album> }
91
- }
92
-
93
- //console.format(results)
94
-
95
- const allAlbums = Object.values(results)
96
-
97
- const onlyUserId = allAlbums.every(user => user.userId === 2)
98
-
99
- expect(onlyUserId).toBe(true)
100
- })
101
-
102
- test("SELECT LIMIT", async () => {
103
-
104
- let results: Record<_ttid, _album> = {}
105
-
106
- for await (const data of Fylo.findDocs<_album>(ALBUMS, { $limit: 5 }).collect()) {
107
-
108
- results = { ...results, ...data as Record<_ttid, _album> }
109
- }
110
-
111
- //console.format(results)
112
-
113
- expect(Object.keys(results).length).toBe(5)
114
- })
115
-
116
- test("SELECT GROUP BY", async () => {
117
-
118
- let results: Record<_album[keyof _album], Record<_ttid, Partial<_album>>> = {} as Record<_album[keyof _album], Record<_ttid, Partial<_album>>>
119
-
120
- for await (const data of Fylo.findDocs<_album>(ALBUMS, { $groupby: "userId", $onlyIds: true }).collect()) {
121
-
122
- results = Object.appendGroup(results, (data as unknown as Record<string, Record<string, Record<_ttid, null>>>))
123
- }
124
-
125
- //console.format(results)
126
-
127
- expect(Object.keys(results).length).toBeGreaterThan(0)
128
- })
129
-
130
- test("SELECT JOIN", async () => {
131
-
132
- const results = await Fylo.joinDocs<_album, _post>({ $leftCollection: ALBUMS, $rightCollection: POSTS, $mode: "inner", $on: { "userId": { $eq: "id" } } }) as Record<`${_ttid}, ${_ttid}`, _album | _post>
133
-
134
- //console.format(results)
135
-
136
- expect(Object.keys(results).length).toBeGreaterThan(0)
137
- })
138
- })
139
-
140
- describe("SQL", async () => {
141
-
142
- test("SELECT PARTIAL", async () => {
143
-
144
- const results = await fylo.executeSQL<_album>(`SELECT title FROM ${ALBUMS}`) as Record<_ttid, _album>
145
-
146
- //console.format(results)
147
-
148
- const allAlbums = Object.values(results)
149
-
150
- const onlyTtitle = allAlbums.every(user => user.title && !user.userId)
151
-
152
- expect(onlyTtitle).toBe(true)
153
- })
154
-
155
- test("SELECT CLAUSE", async () => {
156
-
157
- const results = await fylo.executeSQL<_album>(`SELECT * FROM ${ALBUMS} WHERE user_id = 2`) as Record<_ttid, _album>
158
-
159
- //console.format(results)
160
-
161
- const allAlbums = Object.values(results)
162
-
163
- const onlyUserId = allAlbums.every(user => user.userId === 2)
164
-
165
- expect(onlyUserId).toBe(true)
166
- })
167
-
168
- test("SELECT ALL", async () => {
169
-
170
- const results = await fylo.executeSQL<_album>(`SELECT * FROM ${ALBUMS}`) as Record<_ttid, _album>
171
-
172
- //console.format(results)
173
-
174
- expect(Object.keys(results).length).toBe(count)
175
- })
176
-
177
- test("SELECT LIMIT", async () => {
178
-
179
- const results = await fylo.executeSQL<_album>(`SELECT * FROM ${ALBUMS} LIMIT 5`) as Record<_ttid, _album>
180
-
181
- //console.format(results)
182
-
183
- expect(Object.keys(results).length).toBe(5)
184
- })
185
-
186
- test("SELECT GROUP BY", async () => {
187
-
188
- const results = await fylo.executeSQL<_album>(`SELECT * FROM ${ALBUMS} GROUP BY userId`) as unknown as Record<string, Record<keyof _album, Record<_album[keyof _album], _album>>>
189
-
190
- //console.format(results)
191
-
192
- expect(Object.keys(results).length).toBeGreaterThan(0)
193
- })
194
-
195
- test("SELECT JOIN", async () => {
196
-
197
- const results = await fylo.executeSQL<_album>(`SELECT * FROM ${ALBUMS} INNER JOIN ${POSTS} ON userId = id`) as Record<`${_ttid}, ${_ttid}`, _album | _post>
198
-
199
- //console.format(results)
200
-
201
- expect(Object.keys(results).length).toBeGreaterThan(0)
202
- })
203
- })
@@ -1,105 +0,0 @@
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
- /**
7
- * Rollback mechanism:
8
- * - putKeys() pushes S3.delete ops into the transactions stack
9
- * - deleteKeys() pushes S3.put ops (restore) into the stack
10
- * - executeRollback() pops and executes them in reverse order
11
- *
12
- * After rollback, the written data should no longer be retrievable.
13
- * After a delete rollback, the deleted data should be restored.
14
- */
15
-
16
- const POSTS = 'rb-post'
17
-
18
- const fylo = new Fylo()
19
-
20
- mock.module('../../src/adapters/s3', () => ({ S3: S3Mock }))
21
- mock.module('../../src/adapters/redis', () => ({ Redis: RedisMock }))
22
-
23
- beforeAll(async () => {
24
- await Fylo.createCollection(POSTS)
25
- })
26
-
27
- afterAll(async () => {
28
- await Fylo.dropCollection(POSTS)
29
- })
30
-
31
- describe("NO-SQL", () => {
32
-
33
- test("INSERT then rollback — document is not retrievable", async () => {
34
-
35
- const _id = await fylo.putData<_post>(POSTS, {
36
- userId: 99,
37
- id: 9001,
38
- title: 'Rollback Me',
39
- body: 'This document should disappear after rollback'
40
- })
41
-
42
- // Verify the document was actually written
43
- const before = await Fylo.getDoc<_post>(POSTS, _id).once()
44
- expect(Object.keys(before).length).toBe(1)
45
-
46
- // Rollback undoes all writes made on this Fylo instance
47
- await fylo.rollback()
48
-
49
- const after = await Fylo.getDoc<_post>(POSTS, _id).once()
50
- expect(Object.keys(after).length).toBe(0)
51
- })
52
-
53
- test("DELETE then rollback — document is restored", async () => {
54
-
55
- // Use a fresh Fylo instance so the transactions stack is clean
56
- const freshFylo = new Fylo()
57
-
58
- const _id = await freshFylo.putData<_post>(POSTS, {
59
- userId: 99,
60
- id: 9002,
61
- title: 'Restore Me',
62
- body: 'This document should reappear after delete rollback'
63
- })
64
-
65
- // Clear the insert transactions so rollback only covers the delete
66
- // (call rollback would delete the just-written doc, defeating the purpose)
67
- // Instead we use a second instance that has a clean transaction stack
68
- const deleteInstance = new Fylo()
69
-
70
- await deleteInstance.delDoc(POSTS, _id)
71
-
72
- // Confirm it's gone
73
- const after = await Fylo.getDoc<_post>(POSTS, _id).once()
74
- expect(Object.keys(after).length).toBe(0)
75
-
76
- // Rollback the delete — should restore the document
77
- await deleteInstance.rollback()
78
-
79
- const restored = await Fylo.getDoc<_post>(POSTS, _id).once()
80
- expect(Object.keys(restored).length).toBe(1)
81
- expect(restored[_id].title).toBe('Restore Me')
82
- })
83
-
84
- test("batch INSERT then rollback — all documents are removed", async () => {
85
-
86
- const batchFylo = new Fylo()
87
-
88
- const batch: _post[] = [
89
- { userId: 98, id: 9003, title: 'Batch A', body: 'body a' },
90
- { userId: 98, id: 9004, title: 'Batch B', body: 'body b' },
91
- { userId: 98, id: 9005, title: 'Batch C', body: 'body c' }
92
- ]
93
-
94
- const ids = await batchFylo.batchPutData<_post>(POSTS, batch)
95
-
96
- // Confirm all written
97
- const beforeResults = await Promise.all(ids.map(id => Fylo.getDoc<_post>(POSTS, id).once()))
98
- expect(beforeResults.every(r => Object.keys(r).length === 1)).toBe(true)
99
-
100
- await batchFylo.rollback()
101
-
102
- const afterResults = await Promise.all(ids.map(id => Fylo.getDoc<_post>(POSTS, id).once()))
103
- expect(afterResults.every(r => Object.keys(r).length === 0)).toBe(true)
104
- })
105
- })
@@ -1,130 +0,0 @@
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
- })
@@ -1,169 +0,0 @@
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
- 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
-
27
- async publish(_collection: string, _action: 'insert' | 'delete', _keyId: string | _ttid): Promise<void> {}
28
-
29
- async claimTTID(_id: _ttid, _ttlSeconds: number = 10): Promise<boolean> { return true }
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
-
168
- async *subscribe(_collection: string): AsyncGenerator<never, void, unknown> {}
169
- }