@panagos/jojoscript 0.10.1 → 1.0.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 +677 -11
- package/bin/jojo.js +257 -23
- package/package.json +1 -1
- package/src/check.js +253 -0
- package/src/compiler.js +36 -37
- package/src/contracts.js +124 -0
- package/src/describe.js +63 -0
- package/src/effect-parser.js +4 -2
- package/src/formatter.js +105 -0
- package/src/index.js +31 -0
- package/src/match-parser.js +212 -14
- package/src/named-pipelines.js +87 -0
- package/src/parse.js +124 -0
- package/src/pipeline-explain.js +132 -0
- package/src/pipeline-ir.js +9 -7
- package/src/pipeline-parser.js +24 -2
- package/src/pipeline-plan.js +203 -0
- package/src/purity.js +101 -0
- package/src/runtime/checkpoint-store.js +5 -3
- package/src/runtime/checkpoint.js +3 -2
- package/src/runtime/effects.js +73 -3
- package/src/runtime/errors.js +14 -0
- package/src/runtime/index.js +8 -2
- package/src/runtime/mcp.js +1 -1
- package/src/runtime/serialization.js +97 -0
- package/src/runtime/tagged-unions.js +29 -0
- package/src/simulate.js +156 -0
- package/src/type-declarations.js +83 -0
- package/src/why.js +146 -0
package/src/simulate.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { explainPipelines } from './pipeline-explain.js'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_INPUT = 1000
|
|
4
|
+
const EFFECT_VOLUME_WARNING = 1000
|
|
5
|
+
const MEMORY_WARNING = 10000
|
|
6
|
+
|
|
7
|
+
const NARROWING_STAGES = new Set(['filter', 'filterAsync', 'unique', 'distinctBy'])
|
|
8
|
+
const SINGLE_RESULT_STAGES = new Set(['count', 'first', 'find', 'some', 'every', 'reduce'])
|
|
9
|
+
const MATERIALIZING_STAGES = new Set(['sort', 'sortBy', 'groupBy', 'partition'])
|
|
10
|
+
|
|
11
|
+
function integerArgument(stage) {
|
|
12
|
+
return /^\d+$/.test(stage.args[0] ?? '') ? Number(stage.args[0]) : null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
* Upper bound on the items a stage emits, given the items it receives.
|
|
17
|
+
* `null` means the count cannot be known statically.
|
|
18
|
+
*/
|
|
19
|
+
function itemsOut(stage, items) {
|
|
20
|
+
if (items === null) return SINGLE_RESULT_STAGES.has(stage.type) ? 1 : null
|
|
21
|
+
const size = integerArgument(stage)
|
|
22
|
+
|
|
23
|
+
switch (stage.type) {
|
|
24
|
+
case 'take': return size === null ? null : Math.min(size, items)
|
|
25
|
+
case 'skip': return size === null ? null : Math.max(items - size, 0)
|
|
26
|
+
case 'chunk':
|
|
27
|
+
case 'batch': return size === null ? null : Math.ceil(items / size)
|
|
28
|
+
case 'window': return size === null ? null : Math.max(items - size + 1, 0)
|
|
29
|
+
case 'flatMap':
|
|
30
|
+
case 'groupBy': return null
|
|
31
|
+
case 'partition': return 2
|
|
32
|
+
default: return SINGLE_RESULT_STAGES.has(stage.type) ? 1 : items
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function exactOut(stage, exact) {
|
|
37
|
+
return exact && !NARROWING_STAGES.has(stage.type)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function format(items, exact) {
|
|
41
|
+
if (items === null) return 'unknown'
|
|
42
|
+
return `${exact ? '' : '≤'}${items.toLocaleString('en-US')}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function simulateStages(explanation, input) {
|
|
46
|
+
let items = input
|
|
47
|
+
let exact = true
|
|
48
|
+
|
|
49
|
+
return explanation.stages.slice(1).map(stage => {
|
|
50
|
+
const effects = [...stage.effects, ...stage.effectfulFunctions]
|
|
51
|
+
const simulated = {
|
|
52
|
+
index: stage.index,
|
|
53
|
+
id: stage.id,
|
|
54
|
+
type: stage.type,
|
|
55
|
+
items,
|
|
56
|
+
exact,
|
|
57
|
+
workers: stage.type === 'parallel' ? integerArgument(stage) : null,
|
|
58
|
+
effectCalls: Object.fromEntries(effects.map(name => [name, items])),
|
|
59
|
+
checkpoint: stage.type === 'checkpoint' ? stage.detail : null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
exact = exactOut(stage, exact)
|
|
63
|
+
items = itemsOut(stage, items)
|
|
64
|
+
return simulated
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findProblems(stages) {
|
|
69
|
+
const problems = []
|
|
70
|
+
const checkpoints = stages.filter(stage => stage.checkpoint)
|
|
71
|
+
|
|
72
|
+
for (const stage of stages) {
|
|
73
|
+
if (MATERIALIZING_STAGES.has(stage.type) && stage.items >= MEMORY_WARNING) {
|
|
74
|
+
const amount = stage.exact ? `all ${format(stage.items, true)}` : `up to ${format(stage.items, true)}`
|
|
75
|
+
problems.push({ stage: stage.index, message: `${stage.type} holds ${amount} items in memory before continuing` })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const [name, calls] of Object.entries(stage.effectCalls)) {
|
|
79
|
+
if (calls !== null && calls >= EFFECT_VOLUME_WARNING) {
|
|
80
|
+
problems.push({ stage: stage.index, message: `up to ${format(calls, true)} calls to effect "${name}"` })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const nextCheckpoint = checkpoints.find(checkpoint => checkpoint.index > stage.index)
|
|
84
|
+
if (nextCheckpoint) {
|
|
85
|
+
problems.push({
|
|
86
|
+
stage: stage.index,
|
|
87
|
+
message: `effect "${name}" runs before checkpoint "${nextCheckpoint.checkpoint}": items processed before a failure are fetched again on retry (at-least-once)`
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!checkpoints.length && stages.some(stage => Object.keys(stage.effectCalls).length)) {
|
|
94
|
+
problems.push({ stage: null, message: 'pipeline has effects but no checkpoint: a failure re-runs every effect from the start' })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return problems
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function totalEffectCalls(stages) {
|
|
101
|
+
const totals = {}
|
|
102
|
+
for (const stage of stages) {
|
|
103
|
+
for (const [name, calls] of Object.entries(stage.effectCalls)) {
|
|
104
|
+
totals[name] = name in totals && (totals[name] === null || calls === null) ? null : (totals[name] ?? 0) + calls
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return totals
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/*
|
|
111
|
+
* A static what-if for `input` records: no stage or effect is executed.
|
|
112
|
+
* Counts are upper bounds once a filtering stage has run.
|
|
113
|
+
*/
|
|
114
|
+
export function simulatePipelines(source, options = {}) {
|
|
115
|
+
const input = options.input ?? DEFAULT_INPUT
|
|
116
|
+
|
|
117
|
+
return explainPipelines(source).map(explanation => {
|
|
118
|
+
const stages = simulateStages(explanation, input)
|
|
119
|
+
return {
|
|
120
|
+
name: explanation.name,
|
|
121
|
+
input,
|
|
122
|
+
stages,
|
|
123
|
+
effectCalls: totalEffectCalls(stages),
|
|
124
|
+
checkpoints: stages.filter(stage => stage.checkpoint).length,
|
|
125
|
+
problems: findProblems(stages)
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function describeOperation(stage) {
|
|
131
|
+
const parts = [`${format(stage.items, stage.exact)} items in`]
|
|
132
|
+
if (stage.workers !== null) parts.push(`${stage.workers} workers`)
|
|
133
|
+
for (const [name, calls] of Object.entries(stage.effectCalls)) parts.push(`${format(calls, stage.exact)} calls to ${name}`)
|
|
134
|
+
if (stage.checkpoint) parts.push(`saved as "${stage.checkpoint}"`)
|
|
135
|
+
return parts.join(', ') + (stage.id ? ` @${stage.id}` : '')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function renderSimulationText(simulation) {
|
|
139
|
+
const width = Math.max(...simulation.stages.map(stage => stage.type.length))
|
|
140
|
+
const problems = simulation.problems.length
|
|
141
|
+
? simulation.problems.map(problem => ` ⚠ ${problem.stage === null ? '' : `stage ${problem.stage}: `}${problem.message}`)
|
|
142
|
+
: [' none']
|
|
143
|
+
|
|
144
|
+
return [
|
|
145
|
+
`SIMULATION ${simulation.name}`,
|
|
146
|
+
'',
|
|
147
|
+
'INPUT',
|
|
148
|
+
` ${simulation.input.toLocaleString('en-US')} records`,
|
|
149
|
+
'',
|
|
150
|
+
'OPERATIONS',
|
|
151
|
+
...simulation.stages.map(stage => ` ${stage.index} ${stage.type.padEnd(width)} ${describeOperation(stage)}`),
|
|
152
|
+
'',
|
|
153
|
+
'POTENTIAL PROBLEMS',
|
|
154
|
+
...problems
|
|
155
|
+
].join('\n')
|
|
156
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { maskNonCode } from './lexer.js'
|
|
2
|
+
|
|
3
|
+
const VARIANT = String.raw`[A-Za-z_$][\w$]*(?:\s*\([^()]*\))?`
|
|
4
|
+
const TYPE_DECLARATION = new RegExp(
|
|
5
|
+
String.raw`^([ \t]*)(export[ \t]+)?type[ \t]+([A-Za-z_$][\w$]*)[ \t]*=\s*(\|?\s*${VARIANT}(?:\s*\|\s*${VARIANT})*)`,
|
|
6
|
+
'gm'
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* `type Result = | Ok(value) | Failed(message)` declares a tagged union.
|
|
11
|
+
* Variant names must start with an uppercase letter so `match` can tell a
|
|
12
|
+
* constructor pattern such as `Ok(v)` apart from an ordinary call compared by value.
|
|
13
|
+
*/
|
|
14
|
+
export function findTypeDeclarations(source) {
|
|
15
|
+
const mask = maskNonCode(source)
|
|
16
|
+
const regex = new RegExp(TYPE_DECLARATION.source, TYPE_DECLARATION.flags)
|
|
17
|
+
const declarations = []
|
|
18
|
+
let match
|
|
19
|
+
|
|
20
|
+
while ((match = regex.exec(source))) {
|
|
21
|
+
if (mask.slice(match.index, match.index + match[0].length) !== match[0]) continue
|
|
22
|
+
|
|
23
|
+
const [text, indent, exportPart, name, body] = match
|
|
24
|
+
declarations.push({
|
|
25
|
+
name,
|
|
26
|
+
variants: parseVariants(name, body),
|
|
27
|
+
indent,
|
|
28
|
+
exported: Boolean(exportPart),
|
|
29
|
+
start: match.index,
|
|
30
|
+
end: match.index + text.length
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return declarations
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseVariants(typeName, body) {
|
|
38
|
+
const variants = body.split('|').map(part => part.trim()).filter(Boolean).map(part => {
|
|
39
|
+
const [, name, params] = part.match(/^([A-Za-z_$][\w$]*)\s*(?:\(([^()]*)\))?$/)
|
|
40
|
+
if (!/^[A-Z]/.test(name)) {
|
|
41
|
+
throw new SyntaxError(`Variant "${name}" of type "${typeName}" must start with an uppercase letter`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fields = params?.trim() ? params.split(',').map(field => field.trim()) : []
|
|
45
|
+
const duplicateField = fields.find((field, index) => fields.indexOf(field) !== index)
|
|
46
|
+
if (duplicateField) {
|
|
47
|
+
throw new SyntaxError(`Variant "${name}" declares field "${duplicateField}" more than once`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { name, fields }
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const names = variants.map(variant => variant.name)
|
|
54
|
+
const duplicate = names.find((name, index) => names.indexOf(name) !== index)
|
|
55
|
+
if (duplicate) {
|
|
56
|
+
throw new SyntaxError(`Type "${typeName}" declares variant "${duplicate}" more than once`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return variants
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function transformTypeDeclarations(source) {
|
|
63
|
+
let result = source
|
|
64
|
+
|
|
65
|
+
for (const declaration of findTypeDeclarations(source).reverse()) {
|
|
66
|
+
const exportPart = declaration.exported ? 'export ' : ''
|
|
67
|
+
const variants = declaration.variants
|
|
68
|
+
.map(variant => `${variant.name}: ${JSON.stringify(variant.fields).replaceAll(',', ', ')}`)
|
|
69
|
+
.join(', ')
|
|
70
|
+
const names = declaration.variants.map(variant => variant.name).join(', ')
|
|
71
|
+
|
|
72
|
+
// Keeping the declaration's line count keeps later line numbers stable.
|
|
73
|
+
const lineBreaks = '\n'.repeat(source.slice(declaration.start, declaration.end).split('\n').length - 1)
|
|
74
|
+
|
|
75
|
+
const replacement =
|
|
76
|
+
`${declaration.indent}${exportPart}const ${declaration.name} = defineType(${JSON.stringify(declaration.name)}, { ${variants} }); ` +
|
|
77
|
+
`${exportPart}const { ${names} } = ${declaration.name}${lineBreaks}`
|
|
78
|
+
|
|
79
|
+
result = result.slice(0, declaration.start) + replacement + result.slice(declaration.end)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result
|
|
83
|
+
}
|
package/src/why.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { explainPipelines } from './pipeline-explain.js'
|
|
2
|
+
|
|
3
|
+
const MATERIALIZING_STAGES = new Set(['sort', 'sortBy', 'groupBy', 'partition'])
|
|
4
|
+
const NARROWING_STAGES = new Set(['filter', 'filterAsync', 'unique', 'distinctBy', 'take', 'skip'])
|
|
5
|
+
|
|
6
|
+
function label(stage) {
|
|
7
|
+
return `stage ${stage.index} (${stage.type})`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function capitalize(text) {
|
|
11
|
+
return text[0].toUpperCase() + text.slice(1)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isEffectful(stage) {
|
|
15
|
+
return stage.effects.length > 0 || stage.effectfulFunctions.length > 0
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function uses(stage) {
|
|
19
|
+
return [
|
|
20
|
+
stage.effects.length ? `effect ${stage.effects.join(', ')}` : null,
|
|
21
|
+
stage.effectfulFunctions.length ? `effectful function ${stage.effectfulFunctions.join(', ')}` : null
|
|
22
|
+
].filter(Boolean).join(' and ')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function determinism(stages) {
|
|
26
|
+
const effectful = stages.filter(isEffectful)
|
|
27
|
+
if (!effectful.length) {
|
|
28
|
+
return {
|
|
29
|
+
question: 'Why is this pipeline deterministic?',
|
|
30
|
+
answer: 'No stage uses a declared effect or an effectful function. Plain JavaScript calls inside stages are not analyzed.'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
question: 'Why is this pipeline not deterministic?',
|
|
35
|
+
answer: capitalize(`${effectful.map(stage => `${label(stage)} uses ${uses(stage)}`).join('; ')}.`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function laziness(stages) {
|
|
40
|
+
const blocking = stages.find(stage => MATERIALIZING_STAGES.has(stage.type))
|
|
41
|
+
if (!blocking) {
|
|
42
|
+
return {
|
|
43
|
+
question: 'Why is this pipeline lazy?',
|
|
44
|
+
answer: 'Every stage passes items on one at a time, so no stage waits for the complete input.'
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
question: 'Why is this pipeline not lazy?',
|
|
49
|
+
answer: `${capitalize(label(blocking))} needs the complete input before it can emit anything.`
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resumability(checkpoints) {
|
|
54
|
+
if (!checkpoints.length) {
|
|
55
|
+
return {
|
|
56
|
+
question: 'Why is this pipeline not resumable?',
|
|
57
|
+
answer: 'It has no checkpoint stage, so a failure restarts it from the source.'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sentences = checkpoints.map(checkpoint => {
|
|
62
|
+
const repeated = checkpoint.index > 2 ? `repeating stages 2-${checkpoint.index - 1}` : 'reading the source again'
|
|
63
|
+
return `Checkpoint "${checkpoint.detail}" (stage ${checkpoint.index}) saves its items once they all complete; a rerun restores them instead of ${repeated}.`
|
|
64
|
+
})
|
|
65
|
+
return { question: 'Why is this pipeline resumable?', answer: sentences.join(' ') }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ordering(stages) {
|
|
69
|
+
return stages.filter(isEffectful).flatMap(stage => stages
|
|
70
|
+
.filter(narrowing => NARROWING_STAGES.has(narrowing.type) && narrowing.index < stage.index)
|
|
71
|
+
.map(narrowing => ({
|
|
72
|
+
question: `Why can't stage ${stage.index} be moved before stage ${narrowing.index}?`,
|
|
73
|
+
answer: `${capitalize(label(stage))} uses ${uses(stage)} and ${label(narrowing)} decides which items reach it: moving it earlier would also run it for items that stage ${narrowing.index} drops.`
|
|
74
|
+
})))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parallelism(stages) {
|
|
78
|
+
return stages
|
|
79
|
+
.filter(stage => stage.type === 'parallel' && stages.some(next => next.index === stage.index + 1))
|
|
80
|
+
.map(stage => {
|
|
81
|
+
const next = stages.find(candidate => candidate.index === stage.index + 1)
|
|
82
|
+
const concurrency = stage.args[0]
|
|
83
|
+
return {
|
|
84
|
+
question: `Why does stage ${stage.index} run in parallel?`,
|
|
85
|
+
answer: `parallel(${concurrency}) lets up to ${concurrency} items of ${label(next)} be processed at once; results keep their input order.`
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function replaySafety(stages, checkpoints) {
|
|
91
|
+
const beforeCheckpoints = checkpoints.flatMap(checkpoint => {
|
|
92
|
+
const effects = stages
|
|
93
|
+
.filter(stage => isEffectful(stage) && stage.index < checkpoint.index)
|
|
94
|
+
.flatMap(stage => [...stage.effects, ...stage.effectfulFunctions].map(name => ({ name, index: stage.index })))
|
|
95
|
+
if (!effects.length) return []
|
|
96
|
+
|
|
97
|
+
const subject = effects.length === 1
|
|
98
|
+
? `Effect ${effects[0].name} runs before it (stage ${effects[0].index})`
|
|
99
|
+
: `Effects ${effects.map(effect => `${effect.name} (stage ${effect.index})`).join(' and ')} run before it`
|
|
100
|
+
const pronoun = effects.length === 1 ? 'it runs' : 'they run'
|
|
101
|
+
return [{
|
|
102
|
+
question: `Why does checkpoint "${checkpoint.detail}" not guarantee replay safety?`,
|
|
103
|
+
answer: `${subject}: if a run fails before "${checkpoint.detail}" completes, ${pronoun} again on retry (at-least-once, not exactly-once).`
|
|
104
|
+
}]
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const last = checkpoints.at(-1)
|
|
108
|
+
const afterLast = last
|
|
109
|
+
? stages.filter(stage => isEffectful(stage) && stage.index > last.index).map(stage => ({
|
|
110
|
+
question: `Why can stage ${stage.index} repeat after a failure?`,
|
|
111
|
+
answer: `${capitalize(label(stage))} uses ${uses(stage)} after the last checkpoint "${last.detail}": a rerun restores the checkpoint and runs it again for every restored item.`
|
|
112
|
+
}))
|
|
113
|
+
: []
|
|
114
|
+
|
|
115
|
+
return [...beforeCheckpoints, ...afterLast]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/*
|
|
119
|
+
* Plain-language answers derived from the same static analysis as
|
|
120
|
+
* `jojo explain`, using its stage numbers (the source is stage 1).
|
|
121
|
+
*/
|
|
122
|
+
export function whyPipelines(source) {
|
|
123
|
+
return explainPipelines(source).map(explanation => {
|
|
124
|
+
const stages = explanation.stages.slice(1)
|
|
125
|
+
const checkpoints = stages.filter(stage => stage.type === 'checkpoint')
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
name: explanation.name,
|
|
129
|
+
answers: [
|
|
130
|
+
determinism(stages),
|
|
131
|
+
laziness(stages),
|
|
132
|
+
resumability(checkpoints),
|
|
133
|
+
...ordering(stages),
|
|
134
|
+
...parallelism(stages),
|
|
135
|
+
...replaySafety(stages, checkpoints)
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function renderWhyText(why) {
|
|
142
|
+
return [
|
|
143
|
+
`WHY ${why.name}`,
|
|
144
|
+
...why.answers.flatMap(({ question, answer }) => ['', question, `→ ${answer}`])
|
|
145
|
+
].join('\n')
|
|
146
|
+
}
|