@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.
- package/README.md +809 -418
- package/bin/jojo.js +430 -170
- package/package.json +50 -59
- package/src/compiler.js +227 -186
- package/src/effect-parser.js +223 -0
- package/src/index.js +14 -0
- package/src/pipeline-hash.js +27 -0
- package/src/pipeline-ir.js +157 -0
- package/src/pipeline-parser.js +583 -418
- package/src/runtime/checkpoint-store.js +122 -0
- package/src/runtime/checkpoint.js +69 -0
- package/src/runtime/collections.js +467 -327
- package/src/runtime/effect-log.js +90 -0
- package/src/runtime/effects.js +263 -0
- package/src/runtime/errors.js +29 -0
- package/src/runtime/execution-context.js +86 -0
- package/src/runtime/index.js +70 -29
- package/src/runtime/inspect.js +62 -0
- package/src/runtime/metrics.js +41 -0
- package/src/runtime/retry.js +67 -0
- package/src/runtime/trace.js +66 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { tokenize, significantTokens, maskNonCode } from './lexer.js'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Algebraic effects / effect handlers, source-level syntax:
|
|
5
|
+
*
|
|
6
|
+
* effect fetchUser(id)
|
|
7
|
+
*
|
|
8
|
+
* handle {
|
|
9
|
+
* fetchUser: id => http.get(`/users/${id}`)
|
|
10
|
+
* } {
|
|
11
|
+
* users |> map(user => fetchUser(user.id))
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* Both constructs compile to plain calls into the `@panagos/jojoscript/runtime`
|
|
15
|
+
* effect runtime (`defineEffect`/`withHandlers`), following the same
|
|
16
|
+
* "regex/token transform, no real AST" philosophy as `match-parser.js` and
|
|
17
|
+
* `transforms.js` rather than introducing a parallel parsing architecture.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function text(source, tokens) {
|
|
21
|
+
if (!tokens.length) return ''
|
|
22
|
+
return source.slice(tokens[0].start, tokens[tokens.length - 1].end)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* Only applies `regex` to matches that are real code (not inside a string,
|
|
27
|
+
* template literal, or comment). Mirrors `transforms.js`'s private
|
|
28
|
+
* `codeAwareReplace` helper; duplicated here (rather than exported from
|
|
29
|
+
* `transforms.js`) to keep each transform module self-contained.
|
|
30
|
+
*/
|
|
31
|
+
function codeAwareReplace(source, regex, replacer) {
|
|
32
|
+
const mask = maskNonCode(source)
|
|
33
|
+
|
|
34
|
+
return source.replace(regex, (...args) => {
|
|
35
|
+
const match = args[0]
|
|
36
|
+
const offset = args[args.length - 2]
|
|
37
|
+
|
|
38
|
+
if (mask.slice(offset, offset + match.length) !== match) {
|
|
39
|
+
return match
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return replacer(...args)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
* A deliberately simple, single-line declaration: `effect name(params)`,
|
|
48
|
+
* optionally `export`ed. Parameter names are plain identifiers (no default
|
|
49
|
+
* values/destructuring/nested parens) — enough to declare an effect's
|
|
50
|
+
* shape without needing a full expression parser.
|
|
51
|
+
*/
|
|
52
|
+
const EFFECT_DECLARATION = /^([ \t]*)(export\s+)?effect\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(([^()]*)\)\s*;?[ \t]*$/gm
|
|
53
|
+
|
|
54
|
+
function splitParams(params) {
|
|
55
|
+
return params.trim() ? params.split(',').map(p => p.trim()).filter(Boolean) : []
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/*
|
|
59
|
+
* Static scan for `effect name(params)` declarations. Used both by the
|
|
60
|
+
* compiler (to know which runtime imports are needed) and by pipeline
|
|
61
|
+
* inspection (`pipeline-ir.js`) to recognize effect names referenced from
|
|
62
|
+
* inside pipeline stages.
|
|
63
|
+
*/
|
|
64
|
+
export function findEffectDeclarations(source) {
|
|
65
|
+
const mask = maskNonCode(source)
|
|
66
|
+
const regex = new RegExp(EFFECT_DECLARATION.source, EFFECT_DECLARATION.flags)
|
|
67
|
+
const results = []
|
|
68
|
+
let match
|
|
69
|
+
|
|
70
|
+
while ((match = regex.exec(source))) {
|
|
71
|
+
const offset = match.index
|
|
72
|
+
if (mask.slice(offset, offset + match[0].length) !== match[0]) continue
|
|
73
|
+
|
|
74
|
+
results.push({
|
|
75
|
+
name: match[3],
|
|
76
|
+
params: splitParams(match[4]),
|
|
77
|
+
start: offset,
|
|
78
|
+
end: offset + match[0].length
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return results
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function transformEffectDeclarations(source) {
|
|
86
|
+
return codeAwareReplace(source, EFFECT_DECLARATION, (match, indent, exportPart, name, params) => {
|
|
87
|
+
const paramList = JSON.stringify(splitParams(params))
|
|
88
|
+
return `${indent}${exportPart || ''}const ${name} = defineEffect(${JSON.stringify(name)}, ${paramList})`
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/*
|
|
93
|
+
* Distinguishes the `handle` keyword from ordinary identifiers named
|
|
94
|
+
* "handle" (e.g. `stream.handle(...)`, `const handle = ...`).
|
|
95
|
+
*/
|
|
96
|
+
function isHandleKeyword(tokens, index) {
|
|
97
|
+
const token = tokens[index]
|
|
98
|
+
if (token.type !== 'identifier' || token.value !== 'handle') return false
|
|
99
|
+
|
|
100
|
+
const prev = tokens[index - 1]
|
|
101
|
+
if (prev && ['.', 'const', 'let', 'var', 'function', 'class'].includes(prev.value)) return false
|
|
102
|
+
|
|
103
|
+
const next = tokens[index + 1]
|
|
104
|
+
if (!next || next.value !== '{') return false
|
|
105
|
+
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function matchingBrace(tokens, openIndex) {
|
|
110
|
+
let depth = 0
|
|
111
|
+
for (let i = openIndex; i < tokens.length; i++) {
|
|
112
|
+
if (tokens[i].value === '{') depth++
|
|
113
|
+
else if (tokens[i].value === '}') {
|
|
114
|
+
depth--
|
|
115
|
+
if (depth === 0) return i
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
throw new SyntaxError('Unclosed `handle` block')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function splitTopLevelCommas(tokens) {
|
|
122
|
+
const result = []
|
|
123
|
+
let start = 0
|
|
124
|
+
let depth = 0
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
127
|
+
const value = tokens[i].value
|
|
128
|
+
if (value === '(' || value === '[' || value === '{') depth++
|
|
129
|
+
else if (value === ')' || value === ']' || value === '}') depth--
|
|
130
|
+
else if (value === ',' && depth === 0) {
|
|
131
|
+
result.push(tokens.slice(start, i))
|
|
132
|
+
start = i + 1
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (start < tokens.length) result.push(tokens.slice(start))
|
|
137
|
+
return result
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function extractHandlerNames(source, tokens) {
|
|
141
|
+
return splitTopLevelCommas(tokens)
|
|
142
|
+
.filter(group => group.length)
|
|
143
|
+
.map(group => {
|
|
144
|
+
const first = group[0]
|
|
145
|
+
if (first.type === 'identifier') return first.value
|
|
146
|
+
if (first.type === 'string') return first.value.slice(1, -1)
|
|
147
|
+
throw new SyntaxError(`Invalid handler entry in \`handle { ... }\`: ${text(source, group)}`)
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function locateHandleBlock(tokens, index) {
|
|
152
|
+
const handlerOpen = index + 1
|
|
153
|
+
const handlerClose = matchingBrace(tokens, handlerOpen)
|
|
154
|
+
|
|
155
|
+
const bodyOpen = handlerClose + 1
|
|
156
|
+
if (!tokens[bodyOpen] || tokens[bodyOpen].value !== '{') {
|
|
157
|
+
throw new SyntaxError('`handle { ... }` must be followed by a body block: `handle { ... } { ... }`')
|
|
158
|
+
}
|
|
159
|
+
const bodyClose = matchingBrace(tokens, bodyOpen)
|
|
160
|
+
|
|
161
|
+
return { keywordIndex: index, handlerOpen, handlerClose, bodyOpen, bodyClose }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/*
|
|
165
|
+
* Static scan for every `handle { handlers } { body }` block in `source`,
|
|
166
|
+
* used both by the compiler and by pipeline inspection (which handler
|
|
167
|
+
* names are statically declared, for a best-effort "is this effect
|
|
168
|
+
* handled?" signal).
|
|
169
|
+
*/
|
|
170
|
+
export function findHandleBlocks(source) {
|
|
171
|
+
const tokens = significantTokens(tokenize(source))
|
|
172
|
+
const blocks = []
|
|
173
|
+
|
|
174
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
175
|
+
if (!isHandleKeyword(tokens, i)) continue
|
|
176
|
+
|
|
177
|
+
const block = locateHandleBlock(tokens, i)
|
|
178
|
+
blocks.push({
|
|
179
|
+
start: tokens[block.keywordIndex].start,
|
|
180
|
+
end: tokens[block.bodyClose].end,
|
|
181
|
+
handlerNames: extractHandlerNames(source, tokens.slice(block.handlerOpen + 1, block.handlerClose))
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return blocks
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/*
|
|
189
|
+
* `handle { handlers } { body }` compiles to:
|
|
190
|
+
*
|
|
191
|
+
* (await withHandlers({ handlers }, async () => { body }))
|
|
192
|
+
*
|
|
193
|
+
* Processes the rightmost (innermost, for nested `handle` blocks) match
|
|
194
|
+
* first on every iteration, same strategy as `transformMatchExpressions`,
|
|
195
|
+
* so nested handlers compile inside-out.
|
|
196
|
+
*/
|
|
197
|
+
export function transformHandleBlocks(source) {
|
|
198
|
+
let result = source
|
|
199
|
+
|
|
200
|
+
for (let iteration = 0; iteration < 1000; iteration++) {
|
|
201
|
+
const tokens = significantTokens(tokenize(result))
|
|
202
|
+
|
|
203
|
+
let target = null
|
|
204
|
+
for (let i = tokens.length - 1; i >= 0; i--) {
|
|
205
|
+
if (!isHandleKeyword(tokens, i)) continue
|
|
206
|
+
target = locateHandleBlock(tokens, i)
|
|
207
|
+
break
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!target) return result
|
|
211
|
+
|
|
212
|
+
const handlerText = text(result, tokens.slice(target.handlerOpen, target.handlerClose + 1))
|
|
213
|
+
const bodyText = text(result, tokens.slice(target.bodyOpen, target.bodyClose + 1))
|
|
214
|
+
const replacement = `(await withHandlers(${handlerText}, async () => ${bodyText}))`
|
|
215
|
+
|
|
216
|
+
const start = tokens[target.keywordIndex].start
|
|
217
|
+
const end = tokens[target.bodyClose].end
|
|
218
|
+
|
|
219
|
+
result = result.slice(0, start) + replacement + result.slice(end)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
throw new SyntaxError('handle transformation exceeded 1000 iterations')
|
|
223
|
+
}
|
package/src/index.js
CHANGED
|
@@ -17,3 +17,17 @@ export {
|
|
|
17
17
|
export {
|
|
18
18
|
transformTypeAnnotations
|
|
19
19
|
} from './type-annotations.js'
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
analyzePipelines,
|
|
23
|
+
buildGraph,
|
|
24
|
+
renderGraphText,
|
|
25
|
+
renderGraphDot
|
|
26
|
+
} from './pipeline-ir.js'
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
transformEffectDeclarations,
|
|
30
|
+
transformHandleBlocks,
|
|
31
|
+
findEffectDeclarations,
|
|
32
|
+
findHandleBlocks
|
|
33
|
+
} from './effect-parser.js'
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* A pipeline's identity must be a deterministic hash of its *normalized*
|
|
5
|
+
* structure (stage names + arguments, whitespace-insensitive) rather than a
|
|
6
|
+
* random id, so the same program hashes to the same pipeline id across
|
|
7
|
+
* separate process runs. This is what lets checkpoint data saved by one run
|
|
8
|
+
* be found again by a later run (`jojo resume`, `jojo replay`).
|
|
9
|
+
*/
|
|
10
|
+
export function computePipelineId(pipeline) {
|
|
11
|
+
const normalized = {
|
|
12
|
+
expression: normalizeText(pipeline.expression),
|
|
13
|
+
stages: pipeline.stages.map(stage => ({
|
|
14
|
+
name: stage.name,
|
|
15
|
+
args: stage.args.map(normalizeText)
|
|
16
|
+
}))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return createHash('sha256')
|
|
20
|
+
.update(JSON.stringify(normalized))
|
|
21
|
+
.digest('hex')
|
|
22
|
+
.slice(0, 16)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeText(text) {
|
|
26
|
+
return text.replace(/\s+/g, ' ').trim()
|
|
27
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { findAllPipelines } from './pipeline-parser.js'
|
|
2
|
+
import { computePipelineId } from './pipeline-hash.js'
|
|
3
|
+
import { findEffectDeclarations, findHandleBlocks } from './effect-parser.js'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Best-effort, purely textual check for whether `effectName` is referenced
|
|
7
|
+
* anywhere in a stage's call (its name or its argument expressions), e.g.
|
|
8
|
+
* `map(user => fetchUser(user.id))` or a bare `saveUser` stage. This is
|
|
9
|
+
* static analysis over source text, not evaluation — it cannot tell
|
|
10
|
+
* whether the reference is actually reachable/called, only that the name
|
|
11
|
+
* appears with word boundaries.
|
|
12
|
+
*/
|
|
13
|
+
function effectsUsedByStage(stage, effectNames) {
|
|
14
|
+
if (!effectNames.length) return []
|
|
15
|
+
const haystack = stage.args.length ? `${stage.name} ${stage.args.join(' ')}` : stage.name
|
|
16
|
+
return effectNames.filter(name => new RegExp(`\\b${name}\\b`).test(haystack))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
* Static, execution-free representation of a pipeline: SOURCE, one node per
|
|
21
|
+
* stage, RESULT, chained by `dependencies`. Used by `jojo graph` (pure
|
|
22
|
+
* text/DOT rendering, no execution) and correlated at runtime with
|
|
23
|
+
* `jojo profile`'s collected metrics via the same `node-<n>` id scheme the
|
|
24
|
+
* compiler uses when emitting code (see `pipeline-parser.js`'s
|
|
25
|
+
* `emitPipeline`).
|
|
26
|
+
*
|
|
27
|
+
* `options.effectNames`/`options.handledEffectNames` (both plain arrays of
|
|
28
|
+
* effect names) let callers surface effect usage per node (`jojo graph`
|
|
29
|
+
* passes the whole file's `effect name(...)` declarations and any
|
|
30
|
+
* statically-visible `handle { name: ... }` keys) — see `analyzePipelines`.
|
|
31
|
+
*/
|
|
32
|
+
export function buildGraph(pipeline, pipelineId = computePipelineId(pipeline), options = {}) {
|
|
33
|
+
const effectNames = options.effectNames ?? []
|
|
34
|
+
const handledEffectNames = new Set(options.handledEffectNames ?? [])
|
|
35
|
+
const nodes = []
|
|
36
|
+
|
|
37
|
+
nodes.push({
|
|
38
|
+
id: 'node-0',
|
|
39
|
+
type: 'source',
|
|
40
|
+
label: pipeline.expression.trim(),
|
|
41
|
+
args: [],
|
|
42
|
+
dependencies: [],
|
|
43
|
+
metadata: {}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
pipeline.stages.forEach((stage, index) => {
|
|
47
|
+
const effects = effectsUsedByStage(stage, effectNames)
|
|
48
|
+
|
|
49
|
+
nodes.push({
|
|
50
|
+
id: `node-${index + 1}`,
|
|
51
|
+
type: stage.name,
|
|
52
|
+
label: stage.args.length ? `${stage.name}(${stage.args.join(', ')})` : stage.name,
|
|
53
|
+
args: stage.args,
|
|
54
|
+
dependencies: [`node-${index}`],
|
|
55
|
+
metadata: effects.length ? { effects } : {}
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const resultId = `node-${pipeline.stages.length + 1}`
|
|
60
|
+
nodes.push({
|
|
61
|
+
id: resultId,
|
|
62
|
+
type: 'result',
|
|
63
|
+
label: 'RESULT',
|
|
64
|
+
args: [],
|
|
65
|
+
dependencies: [`node-${pipeline.stages.length}`],
|
|
66
|
+
metadata: {}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/*
|
|
70
|
+
* Per-effect summary across the whole pipeline: name, which node(s)
|
|
71
|
+
* reference it, and a best-effort static "handled" signal — true only
|
|
72
|
+
* if a `handle { name: ... }` block naming this effect was found
|
|
73
|
+
* anywhere in the same file. A statically-declared handle block that is
|
|
74
|
+
* conditionally executed, or a handler installed dynamically at
|
|
75
|
+
* runtime, cannot be seen here; this is inspection, not proof.
|
|
76
|
+
*/
|
|
77
|
+
const effects = nodes
|
|
78
|
+
.filter(node => node.metadata.effects?.length)
|
|
79
|
+
.flatMap(node => node.metadata.effects.map(name => ({
|
|
80
|
+
name,
|
|
81
|
+
nodeId: node.id,
|
|
82
|
+
staticallyHandled: handledEffectNames.has(name)
|
|
83
|
+
})))
|
|
84
|
+
|
|
85
|
+
return { id: pipelineId, expression: pipeline.expression.trim(), nodes, effects }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/*
|
|
89
|
+
* Parses every pipeline statement in `source` and returns their graphs,
|
|
90
|
+
* each with a deterministic id derived from its normalized structure. Also
|
|
91
|
+
* scans `source` for `effect name(...)` declarations and `handle { ... }`
|
|
92
|
+
* handler-map keys, so returned graphs can expose which effects each
|
|
93
|
+
* pipeline uses and whether a handler for them is statically visible.
|
|
94
|
+
*/
|
|
95
|
+
export function analyzePipelines(source) {
|
|
96
|
+
const effectNames = findEffectDeclarations(source).map(declaration => declaration.name)
|
|
97
|
+
const handledEffectNames = findHandleBlocks(source).flatMap(block => block.handlerNames)
|
|
98
|
+
|
|
99
|
+
return findAllPipelines(source).map(pipeline =>
|
|
100
|
+
buildGraph(pipeline, undefined, { effectNames, handledEffectNames })
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function renderGraphText(graph) {
|
|
105
|
+
const lines = []
|
|
106
|
+
|
|
107
|
+
graph.nodes.forEach((node, index) => {
|
|
108
|
+
let label
|
|
109
|
+
if (node.type === 'source') {
|
|
110
|
+
label = `SOURCE ${node.label}`
|
|
111
|
+
} else if (node.type === 'result') {
|
|
112
|
+
label = 'RESULT'
|
|
113
|
+
} else {
|
|
114
|
+
label = node.args.length
|
|
115
|
+
? `${node.type.toUpperCase()} ${node.args.join(', ')}`
|
|
116
|
+
: node.type.toUpperCase()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (node.metadata.effects?.length) {
|
|
120
|
+
label += ` [effects: ${node.metadata.effects.join(', ')}]`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
lines.push(label)
|
|
124
|
+
|
|
125
|
+
if (index < graph.nodes.length - 1) {
|
|
126
|
+
lines.push(' │')
|
|
127
|
+
lines.push(' ▼')
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
return lines.join('\n')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function renderGraphDot(graph) {
|
|
135
|
+
const lines = ['digraph pipeline {']
|
|
136
|
+
|
|
137
|
+
for (const node of graph.nodes) {
|
|
138
|
+
let dotLabel = node.args.length
|
|
139
|
+
? `${node.type}\\n${node.args.join(', ').replace(/"/g, '\\"')}`
|
|
140
|
+
: node.type
|
|
141
|
+
|
|
142
|
+
if (node.metadata.effects?.length) {
|
|
143
|
+
dotLabel += `\\n[effects: ${node.metadata.effects.join(', ')}]`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
lines.push(` "${node.id}" [label="${dotLabel}"];`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const node of graph.nodes) {
|
|
150
|
+
for (const dependency of node.dependencies) {
|
|
151
|
+
lines.push(` "${dependency}" -> "${node.id}";`)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
lines.push('}')
|
|
156
|
+
return lines.join('\n')
|
|
157
|
+
}
|