lopata 0.7.0 → 0.8.2

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.
@@ -1,6 +1,8 @@
1
1
  import { Database, type SQLQueryBindings } from 'bun:sqlite'
2
2
  import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
+ import type { Clock } from '../testing/clock'
5
+ import { realClock } from '../testing/clock'
4
6
  import { persistError, startSpan } from '../tracing/span'
5
7
  import type { ContainerContext } from './container'
6
8
  import type { ContainerConfig } from './container'
@@ -84,20 +86,23 @@ export class SqlStorageCursor implements Iterable<Record<string, unknown>> {
84
86
  // --- SQL Storage API ---
85
87
 
86
88
  export class SqlStorage {
87
- private _dbPath: string
89
+ private _dbPath: string | null
88
90
  private _db: Database | null = null
89
91
 
90
- constructor(dbPath: string) {
92
+ constructor(dbPath: string | null) {
91
93
  this._dbPath = dbPath
92
94
  }
93
95
 
94
96
  private _getDb(): Database {
95
97
  if (!this._db) {
96
- // Ensure parent directory exists
97
- const dir = this._dbPath.substring(0, this._dbPath.lastIndexOf('/'))
98
- mkdirSync(dir, { recursive: true })
99
- this._db = new Database(this._dbPath, { create: true })
100
- this._db.run('PRAGMA journal_mode=WAL')
98
+ if (this._dbPath) {
99
+ const dir = this._dbPath.substring(0, this._dbPath.lastIndexOf('/'))
100
+ mkdirSync(dir, { recursive: true })
101
+ this._db = new Database(this._dbPath, { create: true })
102
+ this._db.run('PRAGMA journal_mode=WAL')
103
+ } else {
104
+ this._db = new Database(':memory:')
105
+ }
101
106
  }
102
107
  return this._db
103
108
  }
@@ -123,6 +128,11 @@ export class SqlStorage {
123
128
  }
124
129
 
125
130
  get databaseSize(): number {
131
+ if (!this._dbPath) {
132
+ const db = this._getDb()
133
+ const row = db.query('SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()').get() as { size: number } | null
134
+ return row?.size ?? 0
135
+ }
126
136
  try {
127
137
  return statSync(this._dbPath).size
128
138
  } catch {
@@ -273,10 +283,9 @@ export class SqliteDurableObjectStorage {
273
283
 
274
284
  get sql(): SqlStorage {
275
285
  if (!this._sql) {
276
- if (!this._dataDir) {
277
- throw new Error('SQL storage not available: dataDir not configured')
278
- }
279
- const dbPath = join(this._dataDir, 'do-sql', this.namespace, `${this.id}.sqlite`)
286
+ const dbPath = this._dataDir
287
+ ? join(this._dataDir, 'do-sql', this.namespace, `${this.id}.sqlite`)
288
+ : null
280
289
  this._sql = new SqlStorage(dbPath)
281
290
  }
282
291
  return this._sql
@@ -740,14 +749,17 @@ export class DurableObjectNamespaceImpl {
740
749
  private _containerConfig?: ContainerConfig
741
750
  private _factoryOverride?: DOExecutorFactory
742
751
  private _defaultFactory?: DOExecutorFactory
752
+ private _generationId?: number
753
+ private clock: Clock
743
754
 
744
- constructor(db: Database, namespaceName: string, dataDir?: string, limits?: DurableObjectLimits, factory?: DOExecutorFactory) {
755
+ constructor(db: Database, namespaceName: string, dataDir?: string, limits?: DurableObjectLimits, factory?: DOExecutorFactory, clock?: Clock) {
745
756
  this.db = db
746
757
  this.namespaceName = namespaceName
747
758
  this.dataDir = dataDir
748
759
  this.limits = limits
749
760
  this._factoryOverride = factory
750
761
  this._evictionTimeoutMs = limits?.evictionTimeoutMs ?? 120_000
762
+ this.clock = clock ?? realClock
751
763
  if (this._evictionTimeoutMs > 0) {
752
764
  this._evictionTimer = setInterval(() => this._evictIdle(), 30_000)
753
765
  }
@@ -763,9 +775,19 @@ export class DurableObjectNamespaceImpl {
763
775
  }
764
776
 
765
777
  /** Called after worker module is loaded to wire the actual class */
766
- _setClass(cls: new(ctx: DurableObjectStateImpl, env: unknown) => DurableObjectBase, env: unknown) {
778
+ _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
+ for (const [idStr, executor] of this._executors) {
781
+ if (executor.activeWebSocketCount() === 0) {
782
+ executor.dispose().catch(() => {})
783
+ this._executors.delete(idStr)
784
+ this._lastActivity.delete(idStr)
785
+ }
786
+ }
787
+
767
788
  this._class = cls
768
789
  this._env = env
790
+ this._generationId = generationId
769
791
  // Restore persisted alarms on startup
770
792
  this._restoreAlarms()
771
793
  }
@@ -791,7 +813,7 @@ export class DurableObjectNamespaceImpl {
791
813
  const existing = this.alarmTimers.get(idStr)
792
814
  if (existing) clearTimeout(existing)
793
815
 
794
- const delay = Math.max(0, scheduledTime - Date.now())
816
+ const delay = Math.max(0, scheduledTime - this.clock.now())
795
817
  const timer = setTimeout(() => {
796
818
  this.alarmTimers.delete(idStr)
797
819
  this._fireAlarm(idStr, 0)
@@ -804,7 +826,7 @@ export class DurableObjectNamespaceImpl {
804
826
  const executor = this._getOrCreateExecutor(idStr)
805
827
  if (!executor) return
806
828
 
807
- this._lastActivity.set(idStr, Date.now())
829
+ this._lastActivity.set(idStr, this.clock.now())
808
830
 
809
831
  // Delete alarm from DB before calling handler (matching CF behavior)
810
832
  this.db
@@ -819,6 +841,7 @@ export class DurableObjectNamespaceImpl {
819
841
  'do.namespace': this.namespaceName,
820
842
  'do.id': idStr,
821
843
  'do.alarm.retryCount': retryCount,
844
+ ...(this._generationId != null ? { 'lopata.generation_id': this._generationId } : {}),
822
845
  },
823
846
  }, () => executor.executeAlarm(retryCount))
824
847
  } catch (e) {
@@ -826,7 +849,7 @@ export class DurableObjectNamespaceImpl {
826
849
  if (retryCount < MAX_ALARM_RETRIES) {
827
850
  // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s
828
851
  const backoffMs = Math.pow(2, retryCount) * 1000
829
- const retryTime = Date.now() + backoffMs
852
+ const retryTime = this.clock.now() + backoffMs
830
853
  // Re-persist alarm for retry
831
854
  this.db
832
855
  .query('INSERT OR REPLACE INTO do_alarms (namespace, id, alarm_time) VALUES (?, ?, ?)')
@@ -876,13 +899,13 @@ export class DurableObjectNamespaceImpl {
876
899
  })
877
900
 
878
901
  this._executors.set(idStr, executor)
879
- this._lastActivity.set(idStr, Date.now())
902
+ this._lastActivity.set(idStr, this.clock.now())
880
903
  return executor
881
904
  }
882
905
 
883
906
  /** @internal Evict idle executors */
884
907
  private _evictIdle() {
885
- const now = Date.now()
908
+ const now = this.clock.now()
886
909
  for (const [idStr, lastActivity] of this._lastActivity) {
887
910
  const executor = this._executors.get(idStr)
888
911
  if (!executor) continue
@@ -944,6 +967,23 @@ export class DurableObjectNamespaceImpl {
944
967
  return this._executors.get(idStr) ?? null
945
968
  }
946
969
 
970
+ /** @internal List active executors with their WebSocket counts */
971
+ _listActiveExecutors(): Array<{ id: string; wsCount: number }> {
972
+ const result: Array<{ id: string; wsCount: number }> = []
973
+ for (const [id, executor] of this._executors) {
974
+ result.push({ id, wsCount: executor.activeWebSocketCount() })
975
+ }
976
+ return result
977
+ }
978
+
979
+ /** @internal List all instance IDs in this namespace (from DB). */
980
+ _listInstanceIds(): string[] {
981
+ const rows = this.db
982
+ .query('SELECT id FROM do_instances WHERE namespace = ? ORDER BY created_at ASC')
983
+ .all(this.namespaceName) as { id: string }[]
984
+ return rows.map(r => r.id)
985
+ }
986
+
947
987
  /** Whether the DO class defines an alarm() handler */
948
988
  hasAlarmHandler(): boolean {
949
989
  return typeof this._class?.prototype?.alarm === 'function'
@@ -958,6 +998,25 @@ export class DurableObjectNamespaceImpl {
958
998
  return this._fireAlarm(idStr, 0)
959
999
  }
960
1000
 
1001
+ /** @internal Fire all alarms whose scheduled time is <= clock.now(). Returns an array of promises. */
1002
+ _fireReadyAlarms(): Promise<void>[] {
1003
+ const now = this.clock.now()
1004
+ const rows = this.db
1005
+ .query('SELECT id, alarm_time FROM do_alarms WHERE namespace = ?')
1006
+ .all(this.namespaceName) as { id: string; alarm_time: number }[]
1007
+ const results: Promise<void>[] = []
1008
+ for (const row of rows) {
1009
+ if (row.alarm_time <= now) {
1010
+ // Cancel the existing setTimeout timer
1011
+ const existing = this.alarmTimers.get(row.id)
1012
+ if (existing) clearTimeout(existing)
1013
+ this.alarmTimers.delete(row.id)
1014
+ results.push(this._fireAlarm(row.id, 0))
1015
+ }
1016
+ }
1017
+ return results
1018
+ }
1019
+
961
1020
  /** @internal Cancel a scheduled alarm without firing it */
962
1021
  cancelAlarm(idStr: string): void {
963
1022
  const timer = this.alarmTimers.get(idStr)
@@ -1058,7 +1117,7 @@ export class DurableObjectNamespaceImpl {
1058
1117
  if (prop === 'fetch') {
1059
1118
  return async (input: Request | string | URL, init?: RequestInit) => {
1060
1119
  const executor = self._getOrCreateExecutor(idStr, id)!
1061
- self._lastActivity.set(idStr, Date.now())
1120
+ self._lastActivity.set(idStr, self.clock.now())
1062
1121
  const request = input instanceof Request ? input : new Request(input instanceof URL ? input.href : input, init)
1063
1122
  return await executor.executeFetch(request)
1064
1123
  }
@@ -1067,7 +1126,7 @@ export class DurableObjectNamespaceImpl {
1067
1126
  // RPC: return a callable that also acts as a thenable for property access
1068
1127
  const rpcCallable = (...args: unknown[]) => {
1069
1128
  const executor = self._getOrCreateExecutor(idStr, id)!
1070
- self._lastActivity.set(idStr, Date.now())
1129
+ self._lastActivity.set(idStr, self.clock.now())
1071
1130
  return executor.executeRpc(String(prop), args)
1072
1131
  }
1073
1132
 
@@ -1077,7 +1136,7 @@ export class DurableObjectNamespaceImpl {
1077
1136
  onRejected?: ((reason: unknown) => unknown) | null,
1078
1137
  ) => {
1079
1138
  const executor = self._getOrCreateExecutor(idStr, id)!
1080
- self._lastActivity.set(idStr, Date.now())
1139
+ self._lastActivity.set(idStr, self.clock.now())
1081
1140
  const promise = executor.executeRpcGet(String(prop))
1082
1141
  return promise.then(onFulfilled, onRejected)
1083
1142
  }
@@ -1,4 +1,6 @@
1
1
  import type { Database } from 'bun:sqlite'
2
+ import type { Clock } from '../testing/clock'
3
+ import { realClock } from '../testing/clock'
2
4
 
3
5
  export interface KVLimits {
4
6
  maxKeySize?: number // default 512 (bytes)
@@ -22,11 +24,13 @@ export class SqliteKVNamespace {
22
24
  private db: Database
23
25
  private namespace: string
24
26
  private limits: Required<KVLimits>
27
+ private clock: Clock
25
28
 
26
- constructor(db: Database, namespace: string, limits?: KVLimits) {
29
+ constructor(db: Database, namespace: string, limits?: KVLimits, clock?: Clock) {
27
30
  this.db = db
28
31
  this.namespace = namespace
29
32
  this.limits = { ...KV_DEFAULTS, ...limits }
33
+ this.clock = clock ?? realClock
30
34
  }
31
35
 
32
36
  private validateKey(key: string): void {
@@ -76,7 +80,7 @@ export class SqliteKVNamespace {
76
80
 
77
81
  if (!row) return null
78
82
 
79
- if (row.expiration && row.expiration < Date.now() / 1000) {
83
+ if (row.expiration && row.expiration < this.clock.now() / 1000) {
80
84
  this.db.run('DELETE FROM kv WHERE namespace = ? AND key = ?', [this.namespace, keyOrKeys])
81
85
  return null
82
86
  }
@@ -101,7 +105,7 @@ export class SqliteKVNamespace {
101
105
  if (type === 'arrayBuffer' || type === 'stream') {
102
106
  throw new Error(`KV bulk get does not support type "${type}"`)
103
107
  }
104
- const now = Date.now() / 1000
108
+ const now = this.clock.now() / 1000
105
109
 
106
110
  if (keys.length === 0) return result
107
111
 
@@ -157,7 +161,7 @@ export class SqliteKVNamespace {
157
161
 
158
162
  if (!row) return { value: null, metadata: null, cacheStatus: null }
159
163
 
160
- if (row.expiration && row.expiration < Date.now() / 1000) {
164
+ if (row.expiration && row.expiration < this.clock.now() / 1000) {
161
165
  this.db.run('DELETE FROM kv WHERE namespace = ? AND key = ?', [this.namespace, keyOrKeys])
162
166
  return { value: null, metadata: null, cacheStatus: null }
163
167
  }
@@ -184,7 +188,7 @@ export class SqliteKVNamespace {
184
188
  if (type === 'arrayBuffer' || type === 'stream') {
185
189
  throw new Error(`KV bulk get does not support type "${type}"`)
186
190
  }
187
- const now = Date.now() / 1000
191
+ const now = this.clock.now() / 1000
188
192
 
189
193
  if (keys.length === 0) return result
190
194
 
@@ -233,7 +237,7 @@ export class SqliteKVNamespace {
233
237
  this.validateTtl(options.expirationTtl)
234
238
  }
235
239
  if (options?.expiration !== undefined) {
236
- const minExpiration = Date.now() / 1000 + this.limits.minTtlSeconds
240
+ const minExpiration = this.clock.now() / 1000 + this.limits.minTtlSeconds
237
241
  if (options.expiration < minExpiration) {
238
242
  throw new Error(`KV expiration must be at least ${this.limits.minTtlSeconds} seconds in the future`)
239
243
  }
@@ -241,7 +245,7 @@ export class SqliteKVNamespace {
241
245
 
242
246
  let expiration: number | null = null
243
247
  if (options?.expiration) expiration = options.expiration
244
- else if (options?.expirationTtl) expiration = Date.now() / 1000 + options.expirationTtl
248
+ else if (options?.expirationTtl) expiration = this.clock.now() / 1000 + options.expirationTtl
245
249
 
246
250
  let metadata: string | null = null
247
251
  if (options?.metadata !== undefined) {
@@ -263,7 +267,7 @@ export class SqliteKVNamespace {
263
267
  const limit = Math.min(options?.limit ?? 1000, 1000)
264
268
  const cursor = options?.cursor ?? ''
265
269
 
266
- const now = Date.now() / 1000
270
+ const now = this.clock.now() / 1000
267
271
 
268
272
  // Lazily delete expired entries for this namespace
269
273
  this.db.run(
@@ -2,6 +2,8 @@ import { randomUUIDv7 } from 'bun'
2
2
  import type { Database } from 'bun:sqlite'
3
3
  import crypto from 'node:crypto'
4
4
  import { ExecutionContext } from '../execution-context'
5
+ import type { Clock } from '../testing/clock'
6
+ import { realClock } from '../testing/clock'
5
7
  import { persistError, startSpan } from '../tracing/span'
6
8
 
7
9
  // --- Types ---
@@ -108,12 +110,14 @@ export class SqliteQueueProducer {
108
110
  private queueName: string
109
111
  private defaultDelay: number
110
112
  private limits: Required<QueueLimits>
113
+ private clock: Clock
111
114
 
112
- constructor(db: Database, queueName: string, defaultDelay: number = 0, limits?: QueueLimits) {
115
+ constructor(db: Database, queueName: string, defaultDelay: number = 0, limits?: QueueLimits, clock?: Clock) {
113
116
  this.db = db
114
117
  this.queueName = queueName
115
118
  this.defaultDelay = defaultDelay
116
119
  this.limits = { ...QUEUE_DEFAULTS, ...limits }
120
+ this.clock = clock ?? realClock
117
121
  }
118
122
 
119
123
  async send(message: unknown, options?: SendOptions): Promise<void> {
@@ -130,7 +134,7 @@ export class SqliteQueueProducer {
130
134
  throw new Error(`Message exceeds max size of ${this.limits.maxMessageSize} bytes`)
131
135
  }
132
136
 
133
- const now = Date.now()
137
+ const now = this.clock.now()
134
138
  const visibleAt = now + delaySeconds * 1000
135
139
 
136
140
  this.db.run(
@@ -147,7 +151,7 @@ export class SqliteQueueProducer {
147
151
  const stmt = this.db.prepare(
148
152
  'INSERT INTO queue_messages (id, queue, body, content_type, attempts, visible_at, created_at) VALUES (?, ?, ?, ?, 0, ?, ?)',
149
153
  )
150
- const now = Date.now()
154
+ const now = this.clock.now()
151
155
 
152
156
  // Pre-encode all messages and validate total size
153
157
  const encoded: { data: Uint8Array; contentType: string; delaySeconds: number }[] = []
@@ -199,18 +203,22 @@ export class QueueConsumer {
199
203
  private polling = false
200
204
  private _activeDeliveries = 0
201
205
 
206
+ private clock: Clock
207
+
202
208
  constructor(
203
209
  db: Database,
204
210
  config: ConsumerConfig,
205
211
  handler: QueueHandler,
206
212
  env: Record<string, unknown>,
207
213
  workerName?: string,
214
+ clock?: Clock,
208
215
  ) {
209
216
  this.db = db
210
217
  this.config = config
211
218
  this.handler = handler
212
219
  this.env = env
213
220
  this.workerName = workerName
221
+ this.clock = clock ?? realClock
214
222
  }
215
223
 
216
224
  start(intervalMs: number = 1000): void {
@@ -237,7 +245,7 @@ export class QueueConsumer {
237
245
  if (this.config.maxConcurrency && this._activeDeliveries >= this.config.maxConcurrency) return
238
246
  this.polling = true
239
247
  try {
240
- const now = Date.now()
248
+ const now = this.clock.now()
241
249
 
242
250
  // Periodically clean up completed messages beyond retention period
243
251
  const retentionMs = (this.config.retentionPeriodSeconds ?? 345600) * 1000
@@ -343,7 +351,7 @@ export class QueueConsumer {
343
351
 
344
352
  if (!decision || decision.type === 'ack') {
345
353
  // Ack (explicit or default) — mark as acked
346
- this.db.run("UPDATE queue_messages SET status = 'acked', completed_at = ? WHERE id = ?", [Date.now(), row.id])
354
+ this.db.run("UPDATE queue_messages SET status = 'acked', completed_at = ? WHERE id = ?", [this.clock.now(), row.id])
347
355
  } else {
348
356
  // Retry
349
357
  const delay = decision.delaySeconds ?? this.config.retryDelay ?? 0
@@ -352,17 +360,17 @@ export class QueueConsumer {
352
360
  if (this.config.deadLetterQueue) {
353
361
  this.db.run(
354
362
  "UPDATE queue_messages SET queue = ?, visible_at = ?, status = 'pending' WHERE id = ?",
355
- [this.config.deadLetterQueue, Date.now(), row.id],
363
+ [this.config.deadLetterQueue, this.clock.now(), row.id],
356
364
  )
357
365
  } else {
358
366
  console.warn(`[lopata] Queue message ${row.id} exceeded max retries (${this.config.maxRetries}), discarding`)
359
- this.db.run("UPDATE queue_messages SET status = 'failed', completed_at = ? WHERE id = ?", [Date.now(), row.id])
367
+ this.db.run("UPDATE queue_messages SET status = 'failed', completed_at = ? WHERE id = ?", [this.clock.now(), row.id])
360
368
  }
361
369
  } else {
362
370
  // Retry with delay
363
371
  this.db.run(
364
372
  'UPDATE queue_messages SET visible_at = ? WHERE id = ?',
365
- [Date.now() + delay * 1000, row.id],
373
+ [this.clock.now() + delay * 1000, row.id],
366
374
  )
367
375
  }
368
376
  }
@@ -400,16 +408,18 @@ const DEFAULT_PULL_BATCH_SIZE = 10
400
408
  export class QueuePullConsumer {
401
409
  private db: Database
402
410
  private queueName: string
411
+ private clock: Clock
403
412
 
404
- constructor(db: Database, queueName: string) {
413
+ constructor(db: Database, queueName: string, clock?: Clock) {
405
414
  this.db = db
406
415
  this.queueName = queueName
416
+ this.clock = clock ?? realClock
407
417
  }
408
418
 
409
419
  pull(options?: PullRequest): PullResponse {
410
420
  const batchSize = options?.batch_size ?? DEFAULT_PULL_BATCH_SIZE
411
421
  const visibilityTimeoutMs = options?.visibility_timeout_ms ?? DEFAULT_VISIBILITY_TIMEOUT_MS
412
- const now = Date.now()
422
+ const now = this.clock.now()
413
423
 
414
424
  // Clean up expired leases — make messages visible again
415
425
  this.db.run(
@@ -472,7 +482,7 @@ export class QueuePullConsumer {
472
482
  ack(request: AckRequest): { acked: number; retried: number } {
473
483
  let acked = 0
474
484
  let retried = 0
475
- const now = Date.now()
485
+ const now = this.clock.now()
476
486
 
477
487
  const tx = this.db.transaction(() => {
478
488
  // Process acks
@@ -487,7 +497,7 @@ export class QueuePullConsumer {
487
497
  ).get(lease_id, this.queueName, now)
488
498
 
489
499
  if (lease) {
490
- this.db.run("UPDATE queue_messages SET status = 'acked', completed_at = ? WHERE id = ?", [Date.now(), lease.message_id])
500
+ this.db.run("UPDATE queue_messages SET status = 'acked', completed_at = ? WHERE id = ?", [this.clock.now(), lease.message_id])
491
501
  this.db.run('DELETE FROM queue_leases WHERE lease_id = ?', [lease_id])
492
502
  acked++
493
503
  }