lopata 0.13.0 → 0.14.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lopata",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -45,24 +45,22 @@ export class InProcessExecutor implements DOExecutor {
45
45
  }
46
46
 
47
47
  async executeFetch(request: Request): Promise<Response> {
48
- const unlock = await this._state._lock()
48
+ await this._state._enter()
49
49
  try {
50
- await this._state._waitForReady()
51
50
  const fetchFn = (this._instance as unknown as Record<string, unknown>).fetch
52
51
  if (typeof fetchFn !== 'function') {
53
52
  throw new Error('Durable Object does not implement fetch()')
54
53
  }
55
54
  return await (fetchFn as (req: Request) => Promise<Response>).call(this._instance, request)
56
55
  } finally {
57
- unlock()
56
+ this._state._exit()
58
57
  }
59
58
  }
60
59
 
61
60
  async executeRpc(method: string, args: unknown[]): Promise<unknown> {
62
61
  warnInvalidRpcArgs(args, method)
63
- const unlock = await this._state._lock()
62
+ await this._state._enter()
64
63
  try {
65
- await this._state._waitForReady()
66
64
  const val = (this._instance as unknown as Record<string, unknown>)[method]
67
65
  if (typeof val === 'function') {
68
66
  const result = await (val as (...a: unknown[]) => unknown).call(this._instance, ...args)
@@ -70,28 +68,26 @@ export class InProcessExecutor implements DOExecutor {
70
68
  }
71
69
  throw new Error(`"${method}" is not a method on the Durable Object`)
72
70
  } finally {
73
- unlock()
71
+ this._state._exit()
74
72
  }
75
73
  }
76
74
 
77
75
  async executeRpcGet(prop: string): Promise<unknown> {
78
- const unlock = await this._state._lock()
76
+ await this._state._enter()
79
77
  try {
80
- await this._state._waitForReady()
81
78
  const val = (this._instance as unknown as Record<string, unknown>)[prop]
82
79
  if (typeof val === 'function') {
83
80
  return createRpcFunctionStub(val as Function, this._instance)
84
81
  }
85
82
  return wrapRpcReturnValue(val, prop)
86
83
  } finally {
87
- unlock()
84
+ this._state._exit()
88
85
  }
89
86
  }
90
87
 
91
88
  async executeAlarm(retryCount: number): Promise<void> {
92
- const unlock = await this._state._lock()
89
+ await this._state._enter()
93
90
  try {
94
- await this._state._waitForReady()
95
91
  const alarmFn = (this._instance as unknown as Record<string, unknown>).alarm
96
92
  if (typeof alarmFn === 'function') {
97
93
  await alarmFn.call(this._instance, {
@@ -100,7 +96,7 @@ export class InProcessExecutor implements DOExecutor {
100
96
  })
101
97
  }
102
98
  } finally {
103
- unlock()
99
+ this._state._exit()
104
100
  }
105
101
  }
106
102
 
@@ -120,7 +116,18 @@ export class InProcessExecutor implements DOExecutor {
120
116
  return this._state._isAborted()
121
117
  }
122
118
 
119
+ reloadClass(cls: new(ctx: DurableObjectStateImpl, env: unknown) => DurableObjectBase, env: unknown): void {
120
+ this._instance = new cls(this._state, env)
121
+ this._state._setInstanceResolver(() => this._instance)
122
+ }
123
+
123
124
  async dispose(): Promise<void> {
125
+ // Close all accepted WebSockets so clients can reconnect to new instance
126
+ for (const ws of this._state.getWebSockets()) {
127
+ try {
128
+ ws.close(1012, 'Service restart')
129
+ } catch {}
130
+ }
124
131
  if (this._containerRuntime) {
125
132
  await this._containerRuntime.cleanup()
126
133
  }
@@ -39,6 +39,9 @@ export interface DOExecutor {
39
39
  /** Whether the instance has been aborted */
40
40
  isAborted(): boolean
41
41
 
42
+ /** Hot-swap the DO class and env without disposing (preserves WebSocket connections) */
43
+ reloadClass?(cls: new(ctx: any, env: unknown) => DurableObjectBase, env: unknown): void
44
+
42
45
  /** Kill the instance */
43
46
  dispose(): Promise<void>
44
47
  }
@@ -94,9 +94,8 @@ async function initWorker(workerConfig: WorkerConfig) {
94
94
  async function handleCommand(cmd: DOCommand): Promise<DOResult> {
95
95
  switch (cmd.type) {
96
96
  case 'fetch': {
97
- const unlock = await state._lock()
97
+ await state._enter()
98
98
  try {
99
- await state._waitForReady()
100
99
  const fetchFn = (instance as any).fetch
101
100
  if (typeof fetchFn !== 'function') {
102
101
  throw new Error('Durable Object does not implement fetch()')
@@ -118,14 +117,13 @@ async function initWorker(workerConfig: WorkerConfig) {
118
117
  body: resBody,
119
118
  }
120
119
  } finally {
121
- unlock()
120
+ state._exit()
122
121
  }
123
122
  }
124
123
 
125
124
  case 'rpc-call': {
126
- const unlock = await state._lock()
125
+ await state._enter()
127
126
  try {
128
- await state._waitForReady()
129
127
  const val = (instance as any)[cmd.method]
130
128
  if (typeof val !== 'function') {
131
129
  throw new Error(`"${cmd.method}" is not a method on the Durable Object`)
@@ -133,28 +131,26 @@ async function initWorker(workerConfig: WorkerConfig) {
133
131
  const result = await val.call(instance, ...cmd.args)
134
132
  return { type: 'rpc-call', value: result }
135
133
  } finally {
136
- unlock()
134
+ state._exit()
137
135
  }
138
136
  }
139
137
 
140
138
  case 'rpc-get': {
141
- const unlock = await state._lock()
139
+ await state._enter()
142
140
  try {
143
- await state._waitForReady()
144
141
  const val = (instance as any)[cmd.prop]
145
142
  if (typeof val === 'function') {
146
143
  return { type: 'rpc-get', value: '__function__' }
147
144
  }
148
145
  return { type: 'rpc-get', value: val }
149
146
  } finally {
150
- unlock()
147
+ state._exit()
151
148
  }
152
149
  }
153
150
 
154
151
  case 'alarm': {
155
- const unlock = await state._lock()
152
+ await state._enter()
156
153
  try {
157
- await state._waitForReady()
158
154
  const alarmFn = (instance as any).alarm
159
155
  if (typeof alarmFn === 'function') {
160
156
  await alarmFn.call(instance, {
@@ -164,7 +160,7 @@ async function initWorker(workerConfig: WorkerConfig) {
164
160
  }
165
161
  return { type: 'alarm' }
166
162
  } finally {
167
- unlock()
163
+ state._exit()
168
164
  }
169
165
  }
170
166
 
@@ -83,6 +83,24 @@ export class SqlStorageCursor implements Iterable<Record<string, unknown>> {
83
83
  }
84
84
  }
85
85
 
86
+ // --- DB-level transaction mutex ---
87
+ // Shared across all SqliteDurableObjectStorage instances using the same Database connection.
88
+ // Prevents overlapping BEGIN/COMMIT blocks from concurrent requests.
89
+ const dbTxnLocks = new WeakMap<Database, Promise<void>>()
90
+
91
+ async function acquireDbTxnLock(db: Database): Promise<() => void> {
92
+ let unlock!: () => void
93
+ const next = new Promise<void>((r) => {
94
+ unlock = r
95
+ })
96
+ const ready = dbTxnLocks.get(db) ?? Promise.resolve()
97
+ dbTxnLocks.set(db, next)
98
+ await ready
99
+ return () => {
100
+ unlock()
101
+ }
102
+ }
103
+
86
104
  // --- SQL Storage API ---
87
105
 
88
106
  export class SqlStorage {
@@ -324,14 +342,15 @@ export class SqliteDurableObjectStorage {
324
342
  const stmt = this.db.query(
325
343
  'INSERT OR REPLACE INTO do_storage (namespace, id, key, value) VALUES (?, ?, ?, ?)',
326
344
  )
327
- this.db.run('BEGIN')
345
+ this.db.run('SAVEPOINT put_batch')
328
346
  try {
329
347
  for (const [k, v] of Object.entries(keyOrEntries)) {
330
348
  stmt.run(this.namespace, this.id, k, JSON.stringify(v))
331
349
  }
332
- this.db.run('COMMIT')
350
+ this.db.run('RELEASE put_batch')
333
351
  } catch (e) {
334
- this.db.run('ROLLBACK')
352
+ this.db.run('ROLLBACK TO put_batch')
353
+ this.db.run('RELEASE put_batch')
335
354
  throw e
336
355
  }
337
356
  }
@@ -415,14 +434,19 @@ export class SqliteDurableObjectStorage {
415
434
  }
416
435
 
417
436
  async transaction<T>(closure: (txn: SqliteDurableObjectStorage) => Promise<T>): Promise<T> {
418
- this.db.run('BEGIN')
437
+ const unlock = await acquireDbTxnLock(this.db)
419
438
  try {
420
- const result = await closure(this)
421
- this.db.run('COMMIT')
422
- return result
423
- } catch (e) {
424
- this.db.run('ROLLBACK')
425
- throw e
439
+ this.db.run('BEGIN')
440
+ try {
441
+ const result = await closure(this)
442
+ this.db.run('COMMIT')
443
+ return result
444
+ } catch (e) {
445
+ this.db.run('ROLLBACK')
446
+ throw e
447
+ }
448
+ } finally {
449
+ unlock()
426
450
  }
427
451
  }
428
452
 
@@ -506,7 +530,6 @@ export class DurableObjectStateImpl {
506
530
  private _hibernatableTimeout: number | null = null
507
531
  private _limits: Required<DurableObjectLimits>
508
532
  private _instanceResolver: (() => DurableObjectBase | null) | null = null
509
- private _lockTail: Promise<void> = Promise.resolve()
510
533
  private _activeRequests = 0
511
534
  private _aborted = false
512
535
  private _abortReason: string | undefined
@@ -545,27 +568,21 @@ export class DurableObjectStateImpl {
545
568
  return this._activeRequests > 0
546
569
  }
547
570
 
548
- /** @internal Acquire the serial-execution lock. Caller awaits this, then runs work in its own async stack. */
549
- async _lock(): Promise<() => void> {
571
+ /** @internal Enter a request waits for blockConcurrencyWhile, checks abort, increments counter. */
572
+ async _enter(): Promise<void> {
550
573
  if (this._aborted) {
551
574
  throw new Error(this._abortReason ?? 'Durable Object has been aborted')
552
575
  }
553
- let unlockNext: () => void
554
- const nextTail = new Promise<void>(r => {
555
- unlockNext = r
556
- })
557
- const ready = this._lockTail
558
- this._lockTail = nextTail
559
- await ready
576
+ await this._waitForReady()
560
577
  if (this._aborted) {
561
- unlockNext!()
562
578
  throw new Error(this._abortReason ?? 'Durable Object has been aborted')
563
579
  }
564
580
  this._activeRequests++
565
- return () => {
566
- this._activeRequests--
567
- unlockNext!()
568
- }
581
+ }
582
+
583
+ /** @internal Exit a request — decrements counter. */
584
+ _exit(): void {
585
+ this._activeRequests--
569
586
  }
570
587
 
571
588
  /**
@@ -776,9 +793,11 @@ export class DurableObjectNamespaceImpl {
776
793
 
777
794
  /** Called after worker module is loaded to wire the actual class */
778
795
  _setClass(cls: new(ctx: DurableObjectStateImpl, env: unknown) => DurableObjectBase, env: unknown, generationId?: number) {
779
- // Dispose existing executors so next request creates new ones with the new class
780
796
  for (const [idStr, executor] of this._executors) {
781
- if (executor.activeWebSocketCount() === 0) {
797
+ if (executor.activeWebSocketCount() > 0 && executor.reloadClass) {
798
+ // Hot-swap: reuse state + WebSocket connections, create new instance with new code
799
+ executor.reloadClass(cls, env)
800
+ } else {
782
801
  executor.dispose().catch(() => {})
783
802
  this._executors.delete(idStr)
784
803
  this._lastActivity.delete(idStr)
@@ -0,0 +1,320 @@
1
+ // Media Transformations binding — ffmpeg-based implementation for local dev
2
+ // Cloudflare's Media binding provides video/audio processing (resize, crop, frame extraction, etc.)
3
+ // Requires ffmpeg installed on the system. Falls back to passthrough if unavailable.
4
+ //
5
+ // Note: MP4 files often have the moov atom at the end, making them non-streamable.
6
+ // ffmpeg needs to seek backwards to read the moov atom, which doesn't work with stdin pipes.
7
+ // We use temp files for input/output to handle all container formats correctly.
8
+
9
+ import { randomUUID } from 'node:crypto'
10
+ import { unlink } from 'node:fs/promises'
11
+ import { tmpdir } from 'node:os'
12
+ import { join } from 'node:path'
13
+
14
+ export interface MediaTransformOptions {
15
+ width?: number
16
+ height?: number
17
+ fit?: 'contain' | 'cover' | 'crop' | 'scale-down'
18
+ trim?: { start?: string; end?: string }
19
+ }
20
+
21
+ export interface MediaOutputOptions {
22
+ mode: 'video' | 'frame' | 'spritesheet' | 'audio'
23
+ duration?: string
24
+ offset?: string
25
+ format?: string
26
+ fps?: number
27
+ columns?: number
28
+ rows?: number
29
+ }
30
+
31
+ export interface MediaOutputResult {
32
+ response(): Promise<Response>
33
+ }
34
+
35
+ // --- Lazy ffmpeg detection ---
36
+
37
+ let _ffmpegResolved = false
38
+ let _ffmpegAvailable = false
39
+ let _ffmpegWarned = false
40
+
41
+ async function hasFfmpeg(): Promise<boolean> {
42
+ if (!_ffmpegResolved) {
43
+ try {
44
+ const proc = Bun.spawn(['ffmpeg', '-version'], { stdout: 'ignore', stderr: 'ignore' })
45
+ const code = await proc.exited
46
+ _ffmpegAvailable = code === 0
47
+ } catch {
48
+ _ffmpegAvailable = false
49
+ }
50
+ _ffmpegResolved = true
51
+ }
52
+ return _ffmpegAvailable
53
+ }
54
+
55
+ function warnFfmpegMissing() {
56
+ if (!_ffmpegWarned) {
57
+ console.warn('[lopata] ffmpeg is not installed — media transformations will pass through unchanged. Install it: https://ffmpeg.org')
58
+ _ffmpegWarned = true
59
+ }
60
+ }
61
+
62
+ // --- Content type helpers ---
63
+
64
+ const MODE_CONTENT_TYPES: Record<string, Record<string, string>> = {
65
+ video: { mp4: 'video/mp4', webm: 'video/webm' },
66
+ frame: { png: 'image/png', jpeg: 'image/jpeg', jpg: 'image/jpeg', webp: 'image/webp' },
67
+ spritesheet: { png: 'image/png', jpeg: 'image/jpeg', jpg: 'image/jpeg', webp: 'image/webp' },
68
+ audio: { m4a: 'audio/mp4', mp3: 'audio/mpeg', ogg: 'audio/ogg' },
69
+ }
70
+
71
+ const MODE_DEFAULTS: Record<string, { format: string; contentType: string }> = {
72
+ video: { format: 'mp4', contentType: 'video/mp4' },
73
+ frame: { format: 'png', contentType: 'image/png' },
74
+ spritesheet: { format: 'png', contentType: 'image/png' },
75
+ audio: { format: 'm4a', contentType: 'audio/mp4' },
76
+ }
77
+
78
+ function resolveContentType(mode: string, format?: string): { ffFormat: string; contentType: string } {
79
+ const defaults = MODE_DEFAULTS[mode] ?? MODE_DEFAULTS.video!
80
+ if (!format) return { ffFormat: defaults.format, contentType: defaults.contentType }
81
+ const ct = MODE_CONTENT_TYPES[mode]?.[format]
82
+ return { ffFormat: format, contentType: ct ?? defaults.contentType }
83
+ }
84
+
85
+ // --- ffmpeg argument builders ---
86
+
87
+ function buildScaleFilter(transforms: MediaTransformOptions[]): string | null {
88
+ // Use the last transform that specifies dimensions
89
+ for (let i = transforms.length - 1; i >= 0; i--) {
90
+ const t = transforms[i]!
91
+ if (t.width || t.height) {
92
+ const w = t.width ?? -2
93
+ const h = t.height ?? -2
94
+ const fit = t.fit ?? 'contain'
95
+
96
+ if (fit === 'contain' || fit === 'scale-down') {
97
+ // Scale to fit within dimensions, preserving aspect ratio
98
+ return `scale=${w}:${h}:force_original_aspect_ratio=decrease`
99
+ } else if (fit === 'cover' || fit === 'crop') {
100
+ // Scale to cover, then crop to exact dimensions
101
+ if (t.width && t.height) {
102
+ return `scale=${t.width}:${t.height}:force_original_aspect_ratio=increase,crop=${t.width}:${t.height}`
103
+ }
104
+ return `scale=${w}:${h}:force_original_aspect_ratio=increase`
105
+ }
106
+ return `scale=${w}:${h}`
107
+ }
108
+ }
109
+ return null
110
+ }
111
+
112
+ function buildTrimArgs(transforms: MediaTransformOptions[], outputOpts: MediaOutputOptions): string[] {
113
+ const args: string[] = []
114
+
115
+ // Seek from transform trim or output offset
116
+ for (const t of transforms) {
117
+ if (t.trim?.start) {
118
+ args.push('-ss', t.trim.start)
119
+ break
120
+ }
121
+ }
122
+ if (args.length === 0 && outputOpts.offset) {
123
+ args.push('-ss', outputOpts.offset)
124
+ }
125
+
126
+ // End time from transform trim
127
+ for (const t of transforms) {
128
+ if (t.trim?.end) {
129
+ args.push('-to', t.trim.end)
130
+ break
131
+ }
132
+ }
133
+
134
+ // Duration from output options
135
+ if (outputOpts.duration) {
136
+ args.push('-t', outputOpts.duration)
137
+ }
138
+
139
+ return args
140
+ }
141
+
142
+ function buildVideoArgs(transforms: MediaTransformOptions[], opts: MediaOutputOptions): string[] {
143
+ const args: string[] = []
144
+
145
+ args.push(...buildTrimArgs(transforms, opts))
146
+
147
+ const scale = buildScaleFilter(transforms)
148
+ if (scale) args.push('-vf', scale)
149
+
150
+ args.push('-movflags', '+faststart')
151
+
152
+ return args
153
+ }
154
+
155
+ function buildFrameArgs(transforms: MediaTransformOptions[], opts: MediaOutputOptions): string[] {
156
+ const args: string[] = []
157
+
158
+ if (opts.offset) args.push('-ss', opts.offset)
159
+
160
+ args.push('-frames:v', '1')
161
+
162
+ const scale = buildScaleFilter(transforms)
163
+ if (scale) args.push('-vf', scale)
164
+
165
+ return args
166
+ }
167
+
168
+ function buildSpritesheetArgs(transforms: MediaTransformOptions[], opts: MediaOutputOptions): string[] {
169
+ const args: string[] = []
170
+
171
+ args.push(...buildTrimArgs(transforms, opts))
172
+
173
+ const fps = opts.fps ?? 1
174
+ const columns = opts.columns ?? 5
175
+ const rows = opts.rows ?? 4
176
+
177
+ const filters: string[] = []
178
+ filters.push(`fps=${fps}`)
179
+
180
+ const scale = buildScaleFilter(transforms)
181
+ if (scale) filters.push(scale)
182
+
183
+ filters.push(`tile=${columns}x${rows}`)
184
+
185
+ args.push('-vf', filters.join(','))
186
+ args.push('-frames:v', '1')
187
+
188
+ return args
189
+ }
190
+
191
+ function buildAudioArgs(transforms: MediaTransformOptions[], opts: MediaOutputOptions): string[] {
192
+ const args: string[] = []
193
+ const { ffFormat } = resolveContentType('audio', opts.format)
194
+
195
+ args.push(...buildTrimArgs(transforms, opts))
196
+
197
+ args.push('-vn') // no video
198
+
199
+ // m4a extension is recognized by ffmpeg, no explicit format needed
200
+ const codecMap: Record<string, string[]> = {
201
+ ogg: ['-c:a', 'libvorbis'],
202
+ }
203
+ if (codecMap[ffFormat]) args.push(...codecMap[ffFormat]!)
204
+
205
+ return args
206
+ }
207
+
208
+ // --- Passthrough (no ffmpeg) ---
209
+
210
+ async function readStream(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
211
+ const reader = stream.getReader()
212
+ const chunks: Uint8Array[] = []
213
+ let totalLength = 0
214
+ while (true) {
215
+ const { done, value } = await reader.read()
216
+ if (done) break
217
+ chunks.push(value)
218
+ totalLength += value.byteLength
219
+ }
220
+ const result = new Uint8Array(totalLength)
221
+ let offset = 0
222
+ for (const chunk of chunks) {
223
+ result.set(chunk, offset)
224
+ offset += chunk.byteLength
225
+ }
226
+ return result
227
+ }
228
+
229
+ // --- MediaTransformer ---
230
+
231
+ class MediaTransformer {
232
+ private streamPromise: Promise<Uint8Array>
233
+ private transforms: MediaTransformOptions[] = []
234
+
235
+ constructor(stream: ReadableStream<Uint8Array>) {
236
+ this.streamPromise = readStream(stream)
237
+ }
238
+
239
+ transform(options: MediaTransformOptions): MediaTransformer {
240
+ this.transforms.push(options)
241
+ return this
242
+ }
243
+
244
+ output(opts: MediaOutputOptions): MediaOutputResult {
245
+ const transforms = this.transforms
246
+ const streamPromise = this.streamPromise
247
+
248
+ return {
249
+ async response(): Promise<Response> {
250
+ if (!(await hasFfmpeg())) {
251
+ warnFfmpegMissing()
252
+ const buf = await streamPromise
253
+ return new Response(buf, {
254
+ headers: { 'content-type': 'application/octet-stream' },
255
+ })
256
+ }
257
+
258
+ const { contentType } = resolveContentType(opts.mode, opts.format)
259
+
260
+ let modeArgs: string[]
261
+ switch (opts.mode) {
262
+ case 'video':
263
+ modeArgs = buildVideoArgs(transforms, opts)
264
+ break
265
+ case 'frame':
266
+ modeArgs = buildFrameArgs(transforms, opts)
267
+ break
268
+ case 'spritesheet':
269
+ modeArgs = buildSpritesheetArgs(transforms, opts)
270
+ break
271
+ case 'audio':
272
+ modeArgs = buildAudioArgs(transforms, opts)
273
+ break
274
+ }
275
+
276
+ const inputBuf = await streamPromise
277
+
278
+ // Use temp files — MP4 moov atom is often at EOF, requiring seek (stdin pipes can't seek)
279
+ const { ffFormat } = resolveContentType(opts.mode, opts.format)
280
+ const id = randomUUID()
281
+ const inputPath = join(tmpdir(), `lopata-media-in-${id}`)
282
+ const outputPath = join(tmpdir(), `lopata-media-out-${id}.${ffFormat}`)
283
+
284
+ try {
285
+ await Bun.write(inputPath, inputBuf)
286
+
287
+ const proc = Bun.spawn(
288
+ ['ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', '-i', inputPath, ...modeArgs, outputPath],
289
+ { stdout: 'pipe', stderr: 'pipe' },
290
+ )
291
+
292
+ const [stderrBuf, exitCode] = await Promise.all([
293
+ new Response(proc.stderr).text(),
294
+ proc.exited,
295
+ ])
296
+
297
+ if (exitCode !== 0) {
298
+ throw new Error(`[lopata] ffmpeg failed (exit ${exitCode}): ${stderrBuf.trim()}`)
299
+ }
300
+
301
+ const output = await Bun.file(outputPath).arrayBuffer()
302
+ return new Response(output, {
303
+ headers: { 'content-type': contentType },
304
+ })
305
+ } finally {
306
+ unlink(inputPath).catch(() => {})
307
+ unlink(outputPath).catch(() => {})
308
+ }
309
+ },
310
+ }
311
+ }
312
+ }
313
+
314
+ // --- MediaBinding ---
315
+
316
+ export class MediaBinding {
317
+ input(stream: ReadableStream<Uint8Array>): MediaTransformer {
318
+ return new MediaTransformer(stream)
319
+ }
320
+ }
package/src/cli/cache.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import type { CliContext } from './context'
2
- import { parseFlag } from './context'
2
+ import { parseArgs } from './context'
3
3
 
4
4
  export async function run(ctx: CliContext, args: string[]) {
5
5
  const action = args[0]
6
6
 
7
7
  switch (action) {
8
8
  case 'list': {
9
+ parseArgs(args.slice(1), {})
9
10
  const db = ctx.db()
10
11
  const rows = db.query<{ cache_name: string; cnt: number }, []>(
11
12
  'SELECT cache_name, COUNT(*) as cnt FROM cache_entries GROUP BY cache_name ORDER BY cache_name',
@@ -20,12 +21,14 @@ export async function run(ctx: CliContext, args: string[]) {
20
21
  break
21
22
  }
22
23
  case 'purge': {
23
- const cacheName = parseFlag(ctx.args, '--name')
24
+ const { values } = parseArgs(args.slice(1), {
25
+ name: { type: 'string' },
26
+ })
24
27
  const db = ctx.db()
25
28
  let result: { changes: number }
26
- if (cacheName) {
27
- result = db.run('DELETE FROM cache_entries WHERE cache_name = ?', [cacheName])
28
- console.log(`Purged ${result.changes} entries from cache "${cacheName}"`)
29
+ if (values.name) {
30
+ result = db.run('DELETE FROM cache_entries WHERE cache_name = ?', [values.name])
31
+ console.log(`Purged ${result.changes} entries from cache "${values.name}"`)
29
32
  } else {
30
33
  result = db.run('DELETE FROM cache_entries')
31
34
  console.log(`Purged ${result.changes} cache entries (all caches)`)