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