@panagos/jojoscript 0.7.1 → 0.9.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.
@@ -0,0 +1,90 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ /*
5
+ * Storage interface for recorded effect calls, mirroring
6
+ * `checkpoint-store.js`'s `CheckpointStore`: the record/replay handler
7
+ * wrappers in `effects.js` only ever talk to this interface, never to a
8
+ * filesystem/database directly, so alternative backends can be added later
9
+ * without touching the effect runtime.
10
+ */
11
+ export class EffectLog {
12
+ async record(_call) {
13
+ throw new Error('EffectLog.record() is not implemented')
14
+ }
15
+
16
+ async replayNext(_name) {
17
+ throw new Error('EffectLog.replayNext() is not implemented')
18
+ }
19
+ }
20
+
21
+ /*
22
+ * In-memory log. `replayNext(name)` returns recorded calls for that effect
23
+ * name in the order they were recorded, one per call — the same effect
24
+ * invoked three times during recording replays its three results in order
25
+ * during replay, regardless of what other effects were interleaved.
26
+ */
27
+ export class MemoryEffectLog extends EffectLog {
28
+ constructor(entries = []) {
29
+ super()
30
+ this.entries = [...entries]
31
+ this.cursor = new Map()
32
+ }
33
+
34
+ async record(call) {
35
+ this.entries.push(call)
36
+ }
37
+
38
+ async replayNext(name) {
39
+ const index = this.cursor.get(name) ?? 0
40
+ const matches = this.entries.filter(entry => entry.name === name)
41
+
42
+ if (index >= matches.length) {
43
+ throw new Error(`No recorded call left to replay for effect "${name}"`)
44
+ }
45
+
46
+ this.cursor.set(name, index + 1)
47
+ return matches[index]
48
+ }
49
+
50
+ toJSON() {
51
+ return this.entries
52
+ }
53
+ }
54
+
55
+ /*
56
+ * JSON-on-disk log, one file for the whole recording. Like
57
+ * `FileCheckpointStore`, writes go to a temp file first and are then
58
+ * renamed into place so a crash mid-write never corrupts the log.
59
+ */
60
+ export class FileEffectLog extends EffectLog {
61
+ constructor(file) {
62
+ super()
63
+ this.file = file
64
+ this.entries = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : []
65
+ this.cursor = new Map()
66
+ }
67
+
68
+ async record(call) {
69
+ this.entries.push(call)
70
+
71
+ const dir = path.dirname(this.file)
72
+ fs.mkdirSync(dir, { recursive: true })
73
+
74
+ const tmp = `${this.file}.tmp-${process.pid}-${Date.now()}`
75
+ fs.writeFileSync(tmp, JSON.stringify(this.entries, null, 2))
76
+ fs.renameSync(tmp, this.file)
77
+ }
78
+
79
+ async replayNext(name) {
80
+ const index = this.cursor.get(name) ?? 0
81
+ const matches = this.entries.filter(entry => entry.name === name)
82
+
83
+ if (index >= matches.length) {
84
+ throw new Error(`No recorded call left to replay for effect "${name}"`)
85
+ }
86
+
87
+ this.cursor.set(name, index + 1)
88
+ return matches[index]
89
+ }
90
+ }
@@ -0,0 +1,263 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+
3
+ /*
4
+ * Algebraic effects / effect handlers runtime.
5
+ *
6
+ * The core idea: `defineEffect` turns a name into a callable that *describes
7
+ * an intent* ("perform fetchUser(42)") without deciding how it is carried
8
+ * out. `withHandlers` installs a handler map for the dynamic extent of a
9
+ * callback (an async-local, stack-shaped scope, exactly like
10
+ * `execution-context.js` already does for checkpoint/metrics state), and
11
+ * `performEffect` resolves an effect call against the innermost matching
12
+ * handler at the moment it is invoked.
13
+ *
14
+ * This is deliberately a *simplified* effect-handler model, not full
15
+ * algebraic effects: there is no delimited-continuation `resume`, handlers
16
+ * cannot resume a suspended computation more than once (or zero times) and
17
+ * inspect the result, and effects cannot be "re-thrown" to an outer handler
18
+ * after partial handling. What it *does* provide: named effects, dynamic
19
+ * handler dispatch, handler nesting/overriding, deterministic-vs-real
20
+ * handler swapping, and a clean async/JS interop story (a handler is just
21
+ * a normal — sync or async — JavaScript function).
22
+ */
23
+
24
+ const handlerStorage = new AsyncLocalStorage()
25
+ const invocationStorage = new AsyncLocalStorage()
26
+
27
+ /*
28
+ * name -> { name, params, sideEffecting, replayable, builtin? }.
29
+ * Populated by every `defineEffect` call (i.e. every `effect name(...)`
30
+ * declaration compiles into one), plus the built-in `PARALLEL` capability.
31
+ * Purely descriptive metadata for inspection — never consulted by
32
+ * dispatch itself.
33
+ */
34
+ const effectRegistry = new Map()
35
+
36
+ export class UnhandledEffectError extends Error {
37
+ constructor(name) {
38
+ super(`Unhandled effect: ${name}\nNo handler registered for effect "${name}"`)
39
+ this.name = 'UnhandledEffectError'
40
+ this.effectName = name
41
+ }
42
+ }
43
+
44
+ export class EffectHandlerError extends Error {
45
+ constructor(name, cause) {
46
+ super(`Effect handler for "${name}" threw: ${cause?.message ?? cause}`, { cause })
47
+ this.name = 'EffectHandlerError'
48
+ this.effectName = name
49
+ }
50
+ }
51
+
52
+ export class RecursiveEffectError extends Error {
53
+ constructor(name) {
54
+ super(`Effect handler for "${name}" recursively invoked the same effect ("${name}")`)
55
+ this.name = 'RecursiveEffectError'
56
+ this.effectName = name
57
+ }
58
+ }
59
+
60
+ /*
61
+ * Declares a named effect and returns the callable used to *perform* it.
62
+ * `options.sideEffecting` (default true) and `options.replayable` (default
63
+ * false) are pure metadata consumed by inspection (`describeEffect`,
64
+ * `describeHandlers`) and by callers deciding whether it's safe to treat a
65
+ * result as reusable across a checkpoint/replay boundary — the runtime
66
+ * itself never enforces them.
67
+ */
68
+ export function defineEffect(name, params = [], options = {}) {
69
+ effectRegistry.set(name, {
70
+ name,
71
+ params: [...params],
72
+ sideEffecting: options.sideEffecting ?? true,
73
+ replayable: options.replayable ?? false
74
+ })
75
+
76
+ function effectFn(...args) {
77
+ return performEffect(name, args)
78
+ }
79
+
80
+ effectFn.effectName = name
81
+ effectFn.isEffect = true
82
+ return effectFn
83
+ }
84
+
85
+ export function describeEffect(name) {
86
+ return effectRegistry.get(name) ?? null
87
+ }
88
+
89
+ export function listEffects() {
90
+ return [...effectRegistry.values()]
91
+ }
92
+
93
+ function normalizeHandlerEntry(name, value) {
94
+ if (typeof value === 'function') {
95
+ return { handle: value, deterministic: undefined }
96
+ }
97
+
98
+ if (value && typeof value === 'object' && typeof value.handle === 'function') {
99
+ return { handle: value.handle, deterministic: value.deterministic ?? undefined }
100
+ }
101
+
102
+ throw new TypeError(
103
+ `Invalid handler for effect "${name}": expected a function, e.g. \`id => fakeUser(id)\`, ` +
104
+ 'or a `{ handle, deterministic }` descriptor'
105
+ )
106
+ }
107
+
108
+ function currentFrames() {
109
+ return handlerStorage.getStore() ?? []
110
+ }
111
+
112
+ /*
113
+ * Installs `handlers` (an object of `name -> handlerFn | { handle,
114
+ * deterministic }`) for the dynamic extent of `fn`, then restores whatever
115
+ * was active before. Nesting `withHandlers` calls (source: nested `handle`
116
+ * blocks) pushes a new innermost frame in front of the existing ones, so an
117
+ * inner handler for the same effect name shadows — overrides — an outer
118
+ * one, while effects the inner handler map does *not* mention still fall
119
+ * through to the outer handler.
120
+ *
121
+ * Caveat (shared with `execution-context.js`'s checkpoint/metrics context):
122
+ * because pipeline stages like `map`/`filter` are lazy generators, the
123
+ * handler frame is only active while `fn` is *synchronously/async-chain*
124
+ * executing. If `fn` merely constructs a lazy pipeline and returns it
125
+ * without consuming it (no `toArray`/`toArrayAsync`/`reduce`/...), items
126
+ * pulled later outside this call will see whatever handlers (if any) are
127
+ * active at that later point, not these ones. Always consume (or `await`)
128
+ * effectful pipelines inside the `handle { ... } { ... }` block itself.
129
+ */
130
+ export function withHandlers(handlers, fn) {
131
+ if (!handlers || typeof handlers !== 'object') {
132
+ throw new TypeError('withHandlers requires a handler map object, e.g. { effectName: handlerFn }')
133
+ }
134
+
135
+ const normalized = {}
136
+ for (const [name, value] of Object.entries(handlers)) {
137
+ normalized[name] = normalizeHandlerEntry(name, value)
138
+ }
139
+
140
+ const frames = [{ handlers: normalized }, ...currentFrames()]
141
+ return handlerStorage.run(frames, fn)
142
+ }
143
+
144
+ /*
145
+ * Resolves and invokes the effect named `name` against the innermost
146
+ * handler that declares it. Always returns a Promise (even for a
147
+ * synchronous handler) so calling code never needs to know whether the
148
+ * active handler is sync or async — production (often async: HTTP/DB) and
149
+ * test (often sync: fake data) handlers are interchangeable.
150
+ */
151
+ export async function performEffect(name, args) {
152
+ const inFlight = invocationStorage.getStore() ?? new Set()
153
+ if (inFlight.has(name)) {
154
+ throw new RecursiveEffectError(name)
155
+ }
156
+
157
+ const frame = currentFrames().find(f => name in f.handlers)
158
+ if (!frame) {
159
+ throw new UnhandledEffectError(name)
160
+ }
161
+
162
+ const entry = frame.handlers[name]
163
+ const nextInFlight = new Set(inFlight)
164
+ nextInFlight.add(name)
165
+
166
+ try {
167
+ return await invocationStorage.run(nextInFlight, () => entry.handle(...args))
168
+ } catch (error) {
169
+ if (error instanceof RecursiveEffectError || error instanceof UnhandledEffectError) throw error
170
+ throw new EffectHandlerError(name, error)
171
+ }
172
+ }
173
+
174
+ /*
175
+ * Introspection: which effects have an active handler *right now* (i.e.
176
+ * called from inside a `handle { ... } { ... }` block), and what is known
177
+ * about each — handled, whether the installed handler declared itself
178
+ * deterministic, and the declaring effect's `sideEffecting`/`replayable`
179
+ * metadata. Returns `[]` outside any `withHandlers` scope.
180
+ */
181
+ export function describeHandlers() {
182
+ const seen = new Map()
183
+
184
+ for (const frame of currentFrames()) {
185
+ for (const [name, entry] of Object.entries(frame.handlers)) {
186
+ if (seen.has(name)) continue
187
+
188
+ const registryEntry = effectRegistry.get(name)
189
+ seen.set(name, {
190
+ name,
191
+ handled: true,
192
+ deterministic: entry.deterministic ?? null,
193
+ sideEffecting: registryEntry?.sideEffecting ?? null,
194
+ replayable: registryEntry?.replayable ?? null
195
+ })
196
+ }
197
+ }
198
+
199
+ return [...seen.values()]
200
+ }
201
+
202
+ /*
203
+ * `parallel(n)` as an effect-backed capability (see collections.js' `map`):
204
+ * if a `PARALLEL` handler is currently installed, it decides how
205
+ * `items |> parallel(n) |> map(fn)` actually runs (real concurrency,
206
+ * deterministic sequential execution, recorded scheduling, ...). With no
207
+ * handler installed, `map` falls back to the original bounded-concurrency
208
+ * implementation — existing programs that never touch `handle {}` are
209
+ * completely unaffected.
210
+ */
211
+ effectRegistry.set('PARALLEL', {
212
+ name: 'PARALLEL',
213
+ params: ['concurrency'],
214
+ sideEffecting: true,
215
+ replayable: false,
216
+ builtin: true
217
+ })
218
+
219
+ export function getParallelHandler() {
220
+ const frame = currentFrames().find(f => 'PARALLEL' in f.handlers)
221
+ return frame ? frame.handlers.PARALLEL.handle : null
222
+ }
223
+
224
+ /*
225
+ * Record/replay extension point (see effect-log.js for the storage side).
226
+ * `recordHandlers` wraps a normal (typically production) handler map so
227
+ * every call's arguments and result are appended to `log`; `replayHandlers`
228
+ * builds a handler map that never runs real code at all, just replays
229
+ * recorded results for the given effect names, in call order, per effect.
230
+ */
231
+ export function recordHandlers(handlers, log) {
232
+ const wrapped = {}
233
+
234
+ for (const [name, value] of Object.entries(handlers)) {
235
+ const entry = normalizeHandlerEntry(name, value)
236
+ wrapped[name] = {
237
+ deterministic: entry.deterministic,
238
+ handle: async (...args) => {
239
+ const result = await entry.handle(...args)
240
+ await log.record({ name, args, result, recordedAt: new Date().toISOString() })
241
+ return result
242
+ }
243
+ }
244
+ }
245
+
246
+ return wrapped
247
+ }
248
+
249
+ export function replayHandlers(effectNames, log) {
250
+ const wrapped = {}
251
+
252
+ for (const name of effectNames) {
253
+ wrapped[name] = {
254
+ deterministic: true,
255
+ handle: async () => {
256
+ const call = await log.replayNext(name)
257
+ return call.result
258
+ }
259
+ }
260
+ }
261
+
262
+ return wrapped
263
+ }
@@ -0,0 +1,29 @@
1
+ /*
2
+ * An error thrown by user code (or attached with `.retryable = false`) that
3
+ * `retry()` must never retry, no matter how many attempts remain.
4
+ */
5
+ export class NonRetryableError extends Error {
6
+ constructor(message, options) {
7
+ super(message, options)
8
+ this.name = 'NonRetryableError'
9
+ this.retryable = false
10
+ }
11
+ }
12
+
13
+ /*
14
+ * A structured pipeline failure. Carries enough context (which pipeline,
15
+ * which node, which item, how many retries were attempted) for the CLI to
16
+ * print a useful failure report instead of a raw stack trace.
17
+ */
18
+ export class PipelineError extends Error {
19
+ constructor(message, details = {}) {
20
+ super(message, { cause: details.cause })
21
+ this.name = 'PipelineError'
22
+ this.pipelineId = details.pipelineId ?? null
23
+ this.nodeId = details.nodeId ?? null
24
+ this.operation = details.operation ?? null
25
+ this.itemIndex = details.itemIndex ?? null
26
+ this.retryCount = details.retryCount ?? 0
27
+ this.timestamp = details.timestamp ?? new Date().toISOString()
28
+ }
29
+ }
@@ -0,0 +1,86 @@
1
+ import fs from 'node:fs'
2
+ import { AsyncLocalStorage } from 'node:async_hooks'
3
+
4
+ import { Metrics } from './metrics.js'
5
+ import { FileCheckpointStore } from './checkpoint-store.js'
6
+
7
+ const storage = new AsyncLocalStorage()
8
+
9
+ /*
10
+ * Everything a running pipeline needs that isn't the data itself: identity,
11
+ * timing, metrics, where to persist checkpoints, and CLI-provided options
12
+ * such as which checkpoint to replay from. Threaded through explicitly via
13
+ * `runInContext`/`getCurrentContext` (AsyncLocalStorage) instead of bare
14
+ * module-level globals.
15
+ */
16
+ export class ExecutionContext {
17
+ constructor(options = {}) {
18
+ this.pipelineId = options.pipelineId ?? null
19
+ this.executionId = options.executionId ?? randomId()
20
+ this.startedAt = options.startedAt ?? new Date().toISOString()
21
+ this.currentNode = null
22
+ this.metrics = options.metrics ?? new Metrics()
23
+ this.checkpointStore = options.checkpointStore ?? new FileCheckpointStore()
24
+ this.options = options.options ?? {}
25
+ this.replayFrom = options.replayFrom ?? null
26
+ this.quiet = options.quiet ?? false
27
+ }
28
+ }
29
+
30
+ function randomId() {
31
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
32
+ }
33
+
34
+ export function runInContext(context, fn) {
35
+ return storage.run(context, fn)
36
+ }
37
+
38
+ export function getCurrentContext() {
39
+ return storage.getStore()
40
+ }
41
+
42
+ /*
43
+ * Compiled pipelines call `checkpoint()`/`inspect()` as ordinary function
44
+ * calls, with no CLI wrapping required (`node compiled.js` must work on its
45
+ * own for the resume story to make sense). When no context has been set up
46
+ * via `runInContext`, a single lazily-created default context is used
47
+ * instead, configured from environment variables so the CLI can steer it
48
+ * (`jojo replay --from`, `--out`, profiling) without needing to run the
49
+ * compiled module in-process.
50
+ */
51
+ let defaultContext = null
52
+
53
+ export function getDefaultContext() {
54
+ const active = getCurrentContext()
55
+ if (active) return active
56
+
57
+ if (!defaultContext) {
58
+ defaultContext = new ExecutionContext({
59
+ checkpointStore: new FileCheckpointStore(process.env.JOJO_CHECKPOINT_DIR || '.jojo-checkpoints'),
60
+ replayFrom: process.env.JOJO_REPLAY_FROM || null,
61
+ quiet: process.env.JOJO_QUIET === '1'
62
+ })
63
+
64
+ if (process.env.JOJO_PROFILE_OUT) {
65
+ const outFile = process.env.JOJO_PROFILE_OUT
66
+ process.on('exit', () => {
67
+ try {
68
+ fs.writeFileSync(outFile, JSON.stringify(defaultContext.metrics.toJSON()))
69
+ } catch {
70
+ /* best-effort; a missing metrics file is handled by the CLI */
71
+ }
72
+ })
73
+ }
74
+ }
75
+
76
+ return defaultContext
77
+ }
78
+
79
+ /*
80
+ * Test-only escape hatch: forces the next `getDefaultContext()` call in
81
+ * this process to create a fresh default context instead of reusing a
82
+ * memoized one.
83
+ */
84
+ export function resetDefaultContext() {
85
+ defaultContext = null
86
+ }
@@ -1,29 +1,70 @@
1
- export {
2
- map,
3
- filter,
4
- sort,
5
- unique,
6
- take,
7
- skip,
8
- flatMap,
9
- reduce,
10
- toArray,
11
- count,
12
- first,
13
- find,
14
- some,
15
- every,
16
- groupBy,
17
- partition,
18
- chunk,
19
- window,
20
- zip,
21
- zipWith,
22
- scan,
23
- sortBy,
24
- distinctBy,
25
- tap,
26
- mapAsync,
27
- filterAsync,
28
- toArrayAsync
29
- } from './collections.js'
1
+ export {
2
+ map,
3
+ filter,
4
+ sort,
5
+ unique,
6
+ take,
7
+ skip,
8
+ flatMap,
9
+ reduce,
10
+ toArray,
11
+ count,
12
+ first,
13
+ find,
14
+ some,
15
+ every,
16
+ groupBy,
17
+ partition,
18
+ chunk,
19
+ window,
20
+ zip,
21
+ zipWith,
22
+ scan,
23
+ sortBy,
24
+ distinctBy,
25
+ tap,
26
+ mapAsync,
27
+ filterAsync,
28
+ toArrayAsync,
29
+ parallel,
30
+ batch
31
+ } from './collections.js'
32
+
33
+ export { checkpoint } from './checkpoint.js'
34
+ export { inspect, formatInspectReport } from './inspect.js'
35
+ export { retry } from './retry.js'
36
+ export { traceNode } from './trace.js'
37
+ export { PipelineError, NonRetryableError } from './errors.js'
38
+ export { Metrics, throughputOf } from './metrics.js'
39
+ export {
40
+ CheckpointStore,
41
+ MemoryCheckpointStore,
42
+ FileCheckpointStore
43
+ } from './checkpoint-store.js'
44
+ export {
45
+ ExecutionContext,
46
+ runInContext,
47
+ getCurrentContext,
48
+ getDefaultContext,
49
+ resetDefaultContext
50
+ } from './execution-context.js'
51
+ export {
52
+ defineEffect,
53
+ withHandlers,
54
+ performEffect,
55
+ describeEffect,
56
+ listEffects,
57
+ describeHandlers,
58
+ getParallelHandler,
59
+ recordHandlers,
60
+ replayHandlers,
61
+ UnhandledEffectError,
62
+ EffectHandlerError,
63
+ RecursiveEffectError
64
+ } from './effects.js'
65
+ export {
66
+ EffectLog,
67
+ MemoryEffectLog,
68
+ FileEffectLog
69
+ } from './effect-log.js'
70
+
@@ -0,0 +1,62 @@
1
+ import { getCurrentContext, getDefaultContext } from './execution-context.js'
2
+
3
+ /*
4
+ * `inspect(items, label)` is a pure passthrough: it must never change what
5
+ * flows through the pipeline. It records how many items passed, how long
6
+ * that took, and derived throughput, then prints a small report (unless
7
+ * the execution context is marked quiet, e.g. under `node --test`).
8
+ */
9
+ export async function* inspect(items, label, meta = {}) {
10
+ const ctx = getCurrentContext() ?? getDefaultContext()
11
+ const start = process.hrtime.bigint()
12
+ const startedAt = new Date().toISOString()
13
+ let count = 0
14
+ let failed = false
15
+
16
+ try {
17
+ for await (const item of items) {
18
+ count++
19
+ yield item
20
+ }
21
+ } catch (error) {
22
+ failed = true
23
+ throw error
24
+ } finally {
25
+ const durationMs = Number(process.hrtime.bigint() - start) / 1e6
26
+ const durationSeconds = durationMs / 1000
27
+ const throughput = durationSeconds > 0 ? count / durationSeconds : count
28
+
29
+ const report = {
30
+ label,
31
+ id: meta.id ?? null,
32
+ pipelineId: meta.pipelineId ?? null,
33
+ items: count,
34
+ succeeded: failed ? Math.max(count - 1, 0) : count,
35
+ failed: failed ? 1 : 0,
36
+ durationMs,
37
+ throughput,
38
+ startedAt,
39
+ endedAt: new Date().toISOString(),
40
+ memoryUsageBytes: typeof process.memoryUsage === 'function' ? process.memoryUsage().heapUsed : undefined
41
+ }
42
+
43
+ ctx?.metrics?.recordInspect(label, report)
44
+
45
+ if (!ctx || !ctx.quiet) {
46
+ console.log(formatInspectReport(report))
47
+ }
48
+ }
49
+ }
50
+
51
+ export function formatInspectReport(report) {
52
+ const duration = (report.durationMs / 1000).toFixed(2)
53
+ const throughput = Math.round(report.throughput)
54
+
55
+ return [
56
+ report.label,
57
+ '-'.repeat(Math.max(report.label.length, 20)),
58
+ `items: ${report.items}`,
59
+ `throughput: ${throughput}/s`,
60
+ `duration: ${duration}s`
61
+ ].join('\n')
62
+ }
@@ -0,0 +1,41 @@
1
+ /*
2
+ * Aggregates per-node execution metrics for a single pipeline execution:
3
+ * item counts, cumulative duration, and derived throughput. Used by both
4
+ * `inspect()` (a single named checkpoint the user asked to observe) and the
5
+ * `jojo profile` compiler instrumentation (every node, automatically).
6
+ */
7
+ export class Metrics {
8
+ constructor() {
9
+ this.nodes = new Map()
10
+ this.inspections = []
11
+ }
12
+
13
+ record(id, type, items, durationMs) {
14
+ if (!id) return
15
+
16
+ const existing = this.nodes.get(id) ?? { id, type, items: 0, durationMs: 0 }
17
+ existing.items += items
18
+ existing.durationMs += durationMs
19
+ existing.type = type
20
+ this.nodes.set(id, existing)
21
+ }
22
+
23
+ recordInspect(label, report) {
24
+ this.inspections.push(report)
25
+ }
26
+
27
+ toJSON() {
28
+ return {
29
+ nodes: [...this.nodes.values()].map(node => ({
30
+ ...node,
31
+ throughput: throughputOf(node.items, node.durationMs)
32
+ })),
33
+ inspections: this.inspections
34
+ }
35
+ }
36
+ }
37
+
38
+ export function throughputOf(items, durationMs) {
39
+ const seconds = durationMs / 1000
40
+ return seconds > 0 ? items / seconds : items
41
+ }