@panagos/jojoscript 0.7.1 → 0.8.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,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,51 @@
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
+
@@ -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
+ }
@@ -0,0 +1,67 @@
1
+ import { NonRetryableError } from './errors.js'
2
+
3
+ /*
4
+ * Wraps `fn` so calling the returned function retries on failure, up to
5
+ * `options.attempts` total tries, with an optional fixed or exponential
6
+ * delay between attempts. Errors marked non-retryable (`NonRetryableError`,
7
+ * or any error with `error.retryable === false`) are never retried and
8
+ * propagate immediately.
9
+ *
10
+ * Used two ways by compiled pipelines:
11
+ * - fused into a preceding `map`/`mapAsync`/`filterAsync`/`tap` stage, so
12
+ * each *item* is retried independently: `map(items, retry(fetch, opts))`.
13
+ * - standalone, wrapping the whole pipeline expression built so far as a
14
+ * zero-argument thunk: `retry(() => (expr), opts)()`.
15
+ */
16
+ export function retry(fn, options = {}, meta = {}) {
17
+ const attempts = options.attempts ?? 1
18
+ const backoff = options.backoff ?? 'fixed'
19
+ const delayMs = options.delay ?? 0
20
+
21
+ return async function retried(...args) {
22
+ let attempt = 1
23
+
24
+ while (true) {
25
+ try {
26
+ return await fn(...args)
27
+ } catch (error) {
28
+ if (error instanceof NonRetryableError || error?.retryable === false) {
29
+ annotate(error, meta, attempt)
30
+ throw error
31
+ }
32
+ if (attempt >= attempts) {
33
+ annotate(error, meta, attempt)
34
+ throw error
35
+ }
36
+
37
+ const wait = backoff === 'exponential'
38
+ ? delayMs * 2 ** (attempt - 1)
39
+ : delayMs
40
+
41
+ if (wait > 0) await sleep(wait)
42
+ attempt++
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ function sleep(ms) {
49
+ return new Promise(resolve => setTimeout(resolve, ms))
50
+ }
51
+
52
+ /*
53
+ * Attaches structured failure context (§16: pipelineId, nodeId, retryCount)
54
+ * to the error without changing its type/instanceof behavior, so the CLI
55
+ * (or user code) can report exactly which pipeline node exhausted retries.
56
+ */
57
+ function annotate(error, meta, retryCount) {
58
+ if (error && typeof error === 'object') {
59
+ error.jojo = {
60
+ pipelineId: meta.pipelineId ?? null,
61
+ nodeId: meta.id ?? null,
62
+ operation: 'retry',
63
+ retryCount,
64
+ timestamp: new Date().toISOString()
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,66 @@
1
+ import { getCurrentContext, getDefaultContext } from './execution-context.js'
2
+
3
+ /*
4
+ * Generic per-node instrumentation used only by `jojo profile` (the
5
+ * compiler wraps every stage's output with a call to this function when
6
+ * compiled with `{ profile: true }`). It must be transparent to *type*,
7
+ * not just to values: eager results (arrays, Maps, Sets, scalars) are
8
+ * counted and returned completely unchanged, while lazy
9
+ * generator/async-generator results are wrapped in an equivalent
10
+ * generator that counts items as they're pulled through — never
11
+ * materializing anything itself.
12
+ */
13
+ export function traceNode(value, meta = {}) {
14
+ const ctx = getCurrentContext() ?? getDefaultContext()
15
+ const start = process.hrtime.bigint()
16
+
17
+ function finish(count) {
18
+ const durationMs = Number(process.hrtime.bigint() - start) / 1e6
19
+ ctx.metrics.record(meta.id, meta.type, count, durationMs)
20
+ }
21
+
22
+ const isAsyncGenerator = value != null &&
23
+ typeof value.next === 'function' &&
24
+ typeof value[Symbol.asyncIterator] === 'function'
25
+
26
+ const isSyncGenerator = !isAsyncGenerator && value != null &&
27
+ typeof value.next === 'function' &&
28
+ typeof value[Symbol.iterator] === 'function'
29
+
30
+ if (isAsyncGenerator) {
31
+ return (async function* () {
32
+ let count = 0
33
+ try {
34
+ for await (const item of value) {
35
+ count++
36
+ yield item
37
+ }
38
+ } finally {
39
+ finish(count)
40
+ }
41
+ })()
42
+ }
43
+
44
+ if (isSyncGenerator) {
45
+ return (function* () {
46
+ let count = 0
47
+ try {
48
+ for (const item of value) {
49
+ count++
50
+ yield item
51
+ }
52
+ } finally {
53
+ finish(count)
54
+ }
55
+ })()
56
+ }
57
+
58
+ const count = Array.isArray(value)
59
+ ? value.length
60
+ : value instanceof Map || value instanceof Set
61
+ ? value.size
62
+ : 1
63
+
64
+ finish(count)
65
+ return value
66
+ }