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.
@@ -0,0 +1,323 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import type { SqliteWorkflowBinding, SqliteWorkflowInstance } from '../bindings/workflow'
3
+ import {
4
+ clearInstanceMocks,
5
+ getWaitingEventTypes,
6
+ isInstanceSleeping,
7
+ onEventWaitRegistered,
8
+ onSleepRegistered,
9
+ onStatusChange,
10
+ onStepComplete,
11
+ registerEventMock,
12
+ registerEventTimeoutMock,
13
+ registerSleepDisable,
14
+ registerStepMock,
15
+ } from '../bindings/workflow'
16
+
17
+ const TERMINAL_STATUSES = new Set(['complete', 'errored', 'terminated'])
18
+ const DEFAULT_TIMEOUT = 5000
19
+
20
+ function timeoutError(what: string, ms: number): Error {
21
+ return new Error(`${what} timed out after ${ms}ms`)
22
+ }
23
+
24
+ export class TestWorkflowInstance {
25
+ private binding: SqliteWorkflowBinding
26
+ private instance: SqliteWorkflowInstance
27
+ private db: Database
28
+ private unsubs: (() => void)[] = []
29
+ private started = true
30
+
31
+ constructor(binding: SqliteWorkflowBinding, instance: SqliteWorkflowInstance, db: Database, prepared = false) {
32
+ this.binding = binding
33
+ this.instance = instance
34
+ this.db = db
35
+ this.started = !prepared
36
+ }
37
+
38
+ get id(): string {
39
+ return this.instance.id
40
+ }
41
+
42
+ /** Wait until the instance reaches one of the given statuses. */
43
+ async waitForStatus(...statuses: string[]): Promise<{ status: string; output?: unknown; error?: { name: string; message: string } }> {
44
+ const timeout = DEFAULT_TIMEOUT
45
+ const targets = new Set(statuses)
46
+
47
+ // Check current status first
48
+ const current = await this.instance.status()
49
+ if (targets.has(current.status)) return current
50
+
51
+ return new Promise<{ status: string; output?: unknown; error?: { name: string; message: string } }>((resolve, reject) => {
52
+ const timer = setTimeout(() => {
53
+ unsub()
54
+ reject(timeoutError(`waitForStatus(${statuses.join(', ')})`, timeout))
55
+ }, timeout)
56
+
57
+ const unsub = onStatusChange(this.instance.id, (status) => {
58
+ if (targets.has(status)) {
59
+ clearTimeout(timer)
60
+ unsub()
61
+ this.instance.status().then(resolve, reject)
62
+ }
63
+ })
64
+ this.unsubs.push(unsub)
65
+ })
66
+ }
67
+
68
+ /** Wait until a specific step completes. Returns its output. */
69
+ async waitForStep(name: string): Promise<unknown> {
70
+ const timeout = DEFAULT_TIMEOUT
71
+
72
+ // Check if step already cached in DB
73
+ const cached = this.db
74
+ .query('SELECT output FROM workflow_steps WHERE instance_id = ? AND step_name = ?')
75
+ .get(this.instance.id, name) as { output: string | null } | null
76
+ if (cached) return JSON.parse(cached.output!)
77
+
78
+ return new Promise<unknown>((resolve, reject) => {
79
+ const timer = setTimeout(() => {
80
+ unsub()
81
+ reject(timeoutError(`waitForStep("${name}")`, timeout))
82
+ }, timeout)
83
+
84
+ const unsub = onStepComplete(this.instance.id, name, (output) => {
85
+ clearTimeout(timer)
86
+ unsub()
87
+ resolve(output)
88
+ })
89
+ this.unsubs.push(unsub)
90
+ })
91
+ }
92
+
93
+ /** Wait until the instance is sleeping, then skip the sleep. */
94
+ async skipSleep(): Promise<void> {
95
+ const timeout = DEFAULT_TIMEOUT
96
+
97
+ // Already sleeping — skip immediately
98
+ if (isInstanceSleeping(this.instance.id)) {
99
+ await this.instance.skipSleep()
100
+ return
101
+ }
102
+
103
+ return new Promise<void>((resolve, reject) => {
104
+ const timer = setTimeout(() => {
105
+ unsub()
106
+ reject(timeoutError('skipSleep()', timeout))
107
+ }, timeout)
108
+
109
+ const unsub = onSleepRegistered(this.instance.id, () => {
110
+ clearTimeout(timer)
111
+ unsub()
112
+ this.instance.skipSleep().then(resolve, reject)
113
+ })
114
+ this.unsubs.push(unsub)
115
+ })
116
+ }
117
+
118
+ /** Wait until the instance is waiting for an event of the given type. */
119
+ async waitForEvent(type: string): Promise<void> {
120
+ const timeout = DEFAULT_TIMEOUT
121
+
122
+ // Check if already waiting
123
+ if (getWaitingEventTypes(this.instance.id).includes(type)) return
124
+
125
+ return new Promise<void>((resolve, reject) => {
126
+ const timer = setTimeout(() => {
127
+ unsub()
128
+ reject(timeoutError(`waitForEvent("${type}")`, timeout))
129
+ }, timeout)
130
+
131
+ const unsub = onEventWaitRegistered(this.instance.id, type, () => {
132
+ clearTimeout(timer)
133
+ unsub()
134
+ resolve()
135
+ })
136
+ this.unsubs.push(unsub)
137
+ })
138
+ }
139
+
140
+ /** Send an event to the workflow instance. */
141
+ async sendEvent(event: { type: string; payload?: unknown }): Promise<void> {
142
+ await this.instance.sendEvent(event)
143
+ }
144
+
145
+ /** Get all completed steps as a Map<name, output>. */
146
+ async steps(): Promise<Map<string, unknown>> {
147
+ const rows = this.db
148
+ .query('SELECT step_name, output FROM workflow_steps WHERE instance_id = ? ORDER BY completed_at ASC')
149
+ .all(this.instance.id) as { step_name: string; output: string | null }[]
150
+ const result = new Map<string, unknown>()
151
+ for (const row of rows) {
152
+ result.set(row.step_name, row.output !== null ? JSON.parse(row.output) : undefined)
153
+ }
154
+ return result
155
+ }
156
+
157
+ /** Get the output of a single step. */
158
+ async stepResult(name: string): Promise<unknown> {
159
+ const row = this.db
160
+ .query('SELECT output FROM workflow_steps WHERE instance_id = ? AND step_name = ?')
161
+ .get(this.instance.id, name) as { output: string | null } | null
162
+ if (!row) throw new Error(`Step "${name}" not found in workflow instance ${this.instance.id}`)
163
+ return row.output !== null ? JSON.parse(row.output) : undefined
164
+ }
165
+
166
+ /** Pause the workflow. */
167
+ async pause(): Promise<void> {
168
+ await this.instance.pause()
169
+ }
170
+
171
+ /** Resume the workflow. */
172
+ async resume(): Promise<void> {
173
+ await this.instance.resume()
174
+ }
175
+
176
+ /** Terminate the workflow. */
177
+ async terminate(): Promise<void> {
178
+ await this.instance.terminate()
179
+ }
180
+
181
+ /** Get the current status. */
182
+ async status(): Promise<{ status: string; output?: unknown; error?: { name: string; message: string } }> {
183
+ return this.instance.status()
184
+ }
185
+
186
+ /** Mock a step to return the given result without running the callback. */
187
+ mockStep(name: string, result: unknown): this {
188
+ registerStepMock(this.instance.id, name, { type: 'result', value: result })
189
+ return this
190
+ }
191
+
192
+ /** Mock a step to throw the given error. */
193
+ mockStepError(name: string, error: Error, opts?: { times?: number }): this {
194
+ registerStepMock(this.instance.id, name, { type: 'error', value: error, times: opts?.times })
195
+ return this
196
+ }
197
+
198
+ /** Mock a step to time out. */
199
+ mockStepTimeout(name: string): this {
200
+ registerStepMock(this.instance.id, name, { type: 'timeout' })
201
+ return this
202
+ }
203
+
204
+ /** Disable all sleeps for this instance — they resolve immediately. */
205
+ disableSleeps(): this {
206
+ registerSleepDisable(this.instance.id)
207
+ return this
208
+ }
209
+
210
+ /** Pre-deliver an event so waitForEvent() resolves immediately. */
211
+ mockEvent(event: { type: string; payload?: unknown }): this {
212
+ registerEventMock(this.instance.id, event.type, event.payload)
213
+ return this
214
+ }
215
+
216
+ /** Mock an event wait to time out immediately. */
217
+ mockEventTimeout(eventType: string): this {
218
+ registerEventTimeoutMock(this.instance.id, eventType)
219
+ return this
220
+ }
221
+
222
+ /** Start a prepared instance (created via TestWorkflowBinding.prepare()). */
223
+ async start(): Promise<void> {
224
+ if (this.started) throw new Error('Instance already started')
225
+ this.started = true
226
+ this.binding._executeInstance(this.instance.id)
227
+ }
228
+
229
+ /** @internal Add an unsubscribe function to be cleaned up on dispose. */
230
+ _addUnsub(unsub: () => void): void {
231
+ this.unsubs.push(unsub)
232
+ }
233
+
234
+ /** Clean up all listeners and mocks. */
235
+ dispose(): void {
236
+ for (const unsub of this.unsubs) unsub()
237
+ this.unsubs = []
238
+ clearInstanceMocks(this.instance.id)
239
+ }
240
+ }
241
+
242
+ export interface TestWorkflowRun {
243
+ instance: TestWorkflowInstance
244
+ result: Promise<{ status: string; output?: unknown; error?: { name: string; message: string } }>
245
+ }
246
+
247
+ export class TestWorkflowBinding {
248
+ private binding: SqliteWorkflowBinding
249
+ private db: Database
250
+ private instances: TestWorkflowInstance[] = []
251
+
252
+ constructor(binding: SqliteWorkflowBinding, db: Database) {
253
+ this.binding = binding
254
+ this.db = db
255
+ }
256
+
257
+ /** Create a workflow instance with manual step-by-step control. */
258
+ async create(opts?: { id?: string; params?: unknown }): Promise<TestWorkflowInstance> {
259
+ const instance = await this.binding.create(opts)
260
+ const testInstance = new TestWorkflowInstance(this.binding, instance, this.db)
261
+ this.instances.push(testInstance)
262
+ return testInstance
263
+ }
264
+
265
+ /** Create a workflow instance without starting it. Register mocks, then call instance.start(). */
266
+ async prepare(opts?: { id?: string; params?: unknown }): Promise<TestWorkflowInstance> {
267
+ const instance = await this.binding._createPrepared(opts)
268
+ const testInstance = new TestWorkflowInstance(this.binding, instance, this.db, true)
269
+ this.instances.push(testInstance)
270
+ return testInstance
271
+ }
272
+
273
+ /** Get an existing workflow instance by ID. */
274
+ async get(id: string): Promise<TestWorkflowInstance> {
275
+ const instance = await this.binding.get(id)
276
+ const testInstance = new TestWorkflowInstance(this.binding, instance, this.db)
277
+ this.instances.push(testInstance)
278
+ return testInstance
279
+ }
280
+
281
+ /** Run a workflow with auto-sleep-skip. Returns a result promise that resolves on completion. */
282
+ async run(opts?: { id?: string; params?: unknown; mocks?: (instance: TestWorkflowInstance) => void }): Promise<TestWorkflowRun> {
283
+ let rawInstance: SqliteWorkflowInstance
284
+ let testInstance: TestWorkflowInstance
285
+
286
+ if (opts?.mocks) {
287
+ // Use prepare+start to allow mocks to be registered before execution
288
+ rawInstance = await this.binding._createPrepared(opts)
289
+ testInstance = new TestWorkflowInstance(this.binding, rawInstance, this.db, true)
290
+ this.instances.push(testInstance)
291
+ testInstance.disableSleeps()
292
+ opts.mocks(testInstance)
293
+ testInstance.start()
294
+ } else {
295
+ rawInstance = await this.binding.create(opts)
296
+ testInstance = new TestWorkflowInstance(this.binding, rawInstance, this.db)
297
+ this.instances.push(testInstance)
298
+
299
+ // Auto-skip sleeps
300
+ const autoSkip = () => {
301
+ const unsub = onSleepRegistered(rawInstance.id, () => {
302
+ rawInstance.skipSleep().then(() => {
303
+ // Re-register for next sleep
304
+ autoSkip()
305
+ })
306
+ })
307
+ testInstance._addUnsub(unsub)
308
+ }
309
+ autoSkip()
310
+ }
311
+
312
+ // Result promise
313
+ const result = testInstance.waitForStatus('complete', 'errored', 'terminated')
314
+
315
+ return { instance: testInstance, result }
316
+ }
317
+
318
+ /** Clean up all tracked instances. */
319
+ dispose(): void {
320
+ for (const inst of this.instances) inst.dispose()
321
+ this.instances = []
322
+ }
323
+ }
@@ -143,6 +143,7 @@ export class TraceStore {
143
143
  s.status_message,
144
144
  s.start_time,
145
145
  s.duration_ms,
146
+ json_extract(s.attributes, '$."lopata.generation_id"') as generation_id,
146
147
  COUNT(c.span_id) as span_count,
147
148
  SUM(CASE WHEN c.status = 'error' THEN 1 ELSE 0 END) as error_count
148
149
  FROM spans s
@@ -164,6 +165,7 @@ export class TraceStore {
164
165
  durationMs: r.duration_ms as number | null,
165
166
  spanCount: r.span_count as number,
166
167
  errorCount: r.error_count as number,
168
+ generationId: (r.generation_id as number) ?? null,
167
169
  }))
168
170
 
169
171
  const last = items[items.length - 1]
@@ -219,6 +221,7 @@ export class TraceStore {
219
221
  s.status_message,
220
222
  s.start_time,
221
223
  s.duration_ms,
224
+ json_extract(s.attributes, '$."lopata.generation_id"') as generation_id,
222
225
  COUNT(c.span_id) as span_count,
223
226
  SUM(CASE WHEN c.status = 'error' THEN 1 ELSE 0 END) as error_count
224
227
  FROM spans s
@@ -239,6 +242,7 @@ export class TraceStore {
239
242
  durationMs: r.duration_ms as number | null,
240
243
  spanCount: r.span_count as number,
241
244
  errorCount: r.error_count as number,
245
+ generationId: (r.generation_id as number) ?? null,
242
246
  }))
243
247
  }
244
248
 
@@ -253,6 +257,7 @@ export class TraceStore {
253
257
  s.status_message,
254
258
  s.start_time,
255
259
  s.duration_ms,
260
+ json_extract(s.attributes, '$."lopata.generation_id"') as generation_id,
256
261
  (SELECT COUNT(*) FROM spans WHERE trace_id = s.trace_id) as span_count,
257
262
  (SELECT COUNT(*) FROM spans WHERE trace_id = s.trace_id AND status = 'error') as error_count
258
263
  FROM spans s
@@ -274,6 +279,7 @@ export class TraceStore {
274
279
  durationMs: r.duration_ms as number | null,
275
280
  spanCount: r.span_count as number,
276
281
  errorCount: r.error_count as number,
282
+ generationId: (r.generation_id as number) ?? null,
277
283
  })),
278
284
  cursor: null,
279
285
  }
@@ -39,6 +39,7 @@ export interface TraceSummary {
39
39
  durationMs: number | null
40
40
  spanCount: number
41
41
  errorCount: number
42
+ generationId: number | null
42
43
  }
43
44
 
44
45
  export interface TraceDetail {
@@ -0,0 +1,99 @@
1
+ import type { BrowserBinding } from './bindings/browser'
2
+ import { ContainerBase, getContainer, getRandom } from './bindings/container'
3
+ import { DurableObjectBase, WebSocketRequestResponsePair } from './bindings/durable-object'
4
+ import { EmailMessage } from './bindings/email'
5
+ import type { ImageTransformOptions, OutputOptions } from './bindings/images'
6
+ import { WebSocketPair } from './bindings/websocket-pair'
7
+ import { NonRetryableError, WorkflowEntrypointBase } from './bindings/workflow'
8
+ import { globalEnv } from './env'
9
+ import { getActiveExecutionContext } from './execution-context'
10
+
11
+ /**
12
+ * Registers virtual modules for `cloudflare:workers`, `cloudflare:workflows`,
13
+ * `cloudflare:email`, `@cloudflare/containers`, and `@cloudflare/puppeteer`.
14
+ *
15
+ * Shared between `src/plugin.ts` (dev server) and `src/testing/setup.ts` (test preload).
16
+ */
17
+ export function registerVirtualModules(build: { module: (name: string, fn: () => any) => void }) {
18
+ build.module('cloudflare:workers', () => {
19
+ return {
20
+ exports: {
21
+ DurableObject: DurableObjectBase,
22
+ WorkflowEntrypoint: WorkflowEntrypointBase,
23
+ WorkerEntrypoint: class WorkerEntrypoint {
24
+ protected ctx: unknown
25
+ protected env: unknown
26
+ constructor(ctx: unknown, env: unknown) {
27
+ this.ctx = ctx
28
+ this.env = env
29
+ ;(this as any)[Symbol.for('lopata.RpcTarget')] = true
30
+ }
31
+ },
32
+ WebSocketRequestResponsePair,
33
+ WebSocketPair,
34
+ RpcTarget: class RpcTarget {
35
+ constructor() {
36
+ ;(this as any)[Symbol.for('lopata.RpcTarget')] = true
37
+ }
38
+ },
39
+ env: globalEnv,
40
+ waitUntil(promise: Promise<unknown>): void {
41
+ const ctx = getActiveExecutionContext()
42
+ if (ctx) {
43
+ ctx.waitUntil(promise)
44
+ }
45
+ },
46
+ },
47
+ loader: 'object',
48
+ }
49
+ })
50
+
51
+ build.module('@cloudflare/containers', () => {
52
+ return {
53
+ exports: {
54
+ Container: ContainerBase,
55
+ getContainer,
56
+ getRandom,
57
+ switchPort(request: Request, port: number): Request {
58
+ const headers = new Headers(request.headers)
59
+ headers.set('cf-container-target-port', port.toString())
60
+ return new Request(request, { headers })
61
+ },
62
+ loadBalance: getRandom,
63
+ },
64
+ loader: 'object',
65
+ }
66
+ })
67
+
68
+ build.module('cloudflare:email', () => {
69
+ return {
70
+ exports: {
71
+ EmailMessage,
72
+ },
73
+ loader: 'object',
74
+ }
75
+ })
76
+
77
+ build.module('cloudflare:workflows', () => {
78
+ return {
79
+ exports: {
80
+ NonRetryableError,
81
+ },
82
+ loader: 'object',
83
+ }
84
+ })
85
+
86
+ build.module('@cloudflare/puppeteer', () => {
87
+ return {
88
+ exports: {
89
+ default: {
90
+ launch: (endpoint: BrowserBinding, opts?: { keep_alive?: number }) => endpoint.launch(opts),
91
+ connect: (endpoint: BrowserBinding, sessionId: string) => endpoint.connect(sessionId),
92
+ sessions: (endpoint: BrowserBinding) => endpoint.sessions(),
93
+ },
94
+ ActiveSession: {} as any, // type-only re-export placeholder
95
+ },
96
+ loader: 'object',
97
+ }
98
+ })
99
+ }
@@ -47,6 +47,8 @@ export function configPlugin(envName: string): Plugin {
47
47
  appType: 'custom',
48
48
  server: {
49
49
  watch: {
50
+ usePolling: true,
51
+ interval: 500,
50
52
  ignored: ['**/.lopata/**', '**/.wrangler/**', '**/.react-router/**'],
51
53
  },
52
54
  },