@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/pipeline-parser.js
CHANGED
|
@@ -234,16 +234,38 @@ function parsePipelineStatement(source, statementStart, statementEnd) {
|
|
|
234
234
|
throw new SyntaxError(`Empty pipeline stage in:\n${statement}`)
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
stages.push(
|
|
237
|
+
stages.push(parseStageWithId(statement, stageTokens))
|
|
238
238
|
i = stageEnd - 1
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
const ids = stages.map(stage => stage.id).filter(Boolean)
|
|
242
|
+
const duplicate = ids.find((id, index) => ids.indexOf(id) !== index)
|
|
243
|
+
if (duplicate) throw new SyntaxError(`Duplicate stage id: @${duplicate}`)
|
|
244
|
+
|
|
241
245
|
const prefixTokens = tokens.slice(0, inputStartIndex)
|
|
242
246
|
const prefix = prefixTokens.length ? text(statement, prefixTokens).trim() : ''
|
|
243
247
|
|
|
244
248
|
return { expression, stages, prefix }
|
|
245
249
|
}
|
|
246
250
|
|
|
251
|
+
export const STAGE_ID = /^[A-Za-z_][\w-]*$/
|
|
252
|
+
|
|
253
|
+
// A trailing `@id` labels a stage without changing what it computes.
|
|
254
|
+
function parseStageWithId(source, tokens) {
|
|
255
|
+
let depth = 0
|
|
256
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
257
|
+
const value = tokens[i].value
|
|
258
|
+
if (value === '(' || value === '[' || value === '{') depth++
|
|
259
|
+
else if (value === ')' || value === ']' || value === '}') depth--
|
|
260
|
+
else if (value === '@' && depth === 0) {
|
|
261
|
+
const id = source.slice(tokens[i].end, tokens[tokens.length - 1].end).trim()
|
|
262
|
+
if (!STAGE_ID.test(id)) throw new SyntaxError(`Invalid stage id: @${id}`)
|
|
263
|
+
return { ...parseStage(source, tokens.slice(0, i)), id }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return parseStage(source, tokens)
|
|
267
|
+
}
|
|
268
|
+
|
|
247
269
|
function metaLiteral(pipelineId, nodeId, type, extra = {}) {
|
|
248
270
|
return JSON.stringify({ id: nodeId, pipelineId, type, ...extra })
|
|
249
271
|
}
|
|
@@ -312,7 +334,7 @@ export function emitPipeline(pipeline, options = {}) {
|
|
|
312
334
|
|
|
313
335
|
const stagesWithIds = pipeline.stages.map((stage, index) => ({
|
|
314
336
|
...stage,
|
|
315
|
-
nodeId: `node-${index + 1}`
|
|
337
|
+
nodeId: stage.id ?? `node-${index + 1}`
|
|
316
338
|
}))
|
|
317
339
|
|
|
318
340
|
const stages = fuseRetryStages(stagesWithIds, pipelineId)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { compile } from './compiler.js'
|
|
2
|
+
import { findAllPipelines, STAGE_ID } from './pipeline-parser.js'
|
|
3
|
+
import { computePipelineId } from './pipeline-hash.js'
|
|
4
|
+
import { inferPipelineName } from './pipeline-explain.js'
|
|
5
|
+
import { transformNamedPipelines } from './named-pipelines.js'
|
|
6
|
+
|
|
7
|
+
const PLAN_VERSION = 1
|
|
8
|
+
|
|
9
|
+
const EXPRESSION_STAGES = new Set([
|
|
10
|
+
'map', 'filter', 'flatMap', 'tap', 'mapAsync', 'filterAsync', 'find', 'some',
|
|
11
|
+
'every', 'sortBy', 'distinctBy', 'groupBy', 'partition', 'catch'
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
const INTEGER_STAGE_FIELDS = { parallel: 'concurrency', batch: 'size' }
|
|
15
|
+
|
|
16
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/
|
|
17
|
+
const STAGE_NAME = /^\.?[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/
|
|
18
|
+
const SIMPLE_STRING = /^(["'])([^"'\\]*)\1$/
|
|
19
|
+
|
|
20
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
|
21
|
+
|
|
22
|
+
class PlanValidationError extends Error {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(`Invalid plan: ${message}`)
|
|
25
|
+
this.name = 'PlanValidationError'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/*
|
|
30
|
+
* The canonical, machine-readable form of every pipeline in `source`.
|
|
31
|
+
* Arguments stay as source text, so the plan describes computation without
|
|
32
|
+
* evaluating it and `planToJojo` can reproduce it.
|
|
33
|
+
*/
|
|
34
|
+
export function planPipelines(jojoSource) {
|
|
35
|
+
const source = transformNamedPipelines(jojoSource)
|
|
36
|
+
return {
|
|
37
|
+
version: PLAN_VERSION,
|
|
38
|
+
pipelines: findAllPipelines(source).map(pipeline => ({
|
|
39
|
+
pipeline: inferPipelineName(source, pipeline),
|
|
40
|
+
id: computePipelineId(pipeline),
|
|
41
|
+
stages: [{ type: 'source', expression: pipeline.expression }, ...pipeline.stages.map(planStage)]
|
|
42
|
+
}))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function planStage(stage) {
|
|
47
|
+
const planned = plannedStage(stage)
|
|
48
|
+
return stage.id ? { ...planned, id: stage.id } : planned
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function plannedStage(stage) {
|
|
52
|
+
const { name: type, args } = stage
|
|
53
|
+
const integerField = INTEGER_STAGE_FIELDS[type]
|
|
54
|
+
|
|
55
|
+
if (integerField && args.length === 1 && /^[1-9]\d*$/.test(args[0])) {
|
|
56
|
+
return { type, [integerField]: Number(args[0]) }
|
|
57
|
+
}
|
|
58
|
+
if (type === 'checkpoint' && args.length === 1 && SIMPLE_STRING.test(args[0])) {
|
|
59
|
+
return { type, name: args[0].match(SIMPLE_STRING)[2] }
|
|
60
|
+
}
|
|
61
|
+
if (EXPRESSION_STAGES.has(type) && args.length === 1) {
|
|
62
|
+
return { type, expression: args[0] }
|
|
63
|
+
}
|
|
64
|
+
if (stage.propertyCall || args.length) return { type, args }
|
|
65
|
+
return { type }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function planToJojo(plan) {
|
|
69
|
+
validatePlan(plan)
|
|
70
|
+
|
|
71
|
+
return plan.pipelines.map((pipeline, index) => {
|
|
72
|
+
const source = emitPipeline(pipeline)
|
|
73
|
+
assertCompiles(source, `pipelines[${index}]`)
|
|
74
|
+
return source
|
|
75
|
+
}).join('\n\n') + '\n'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function emitPipeline({ pipeline, stages: [source, ...stages] }) {
|
|
79
|
+
const head = pipeline ? `${pipeline} := ${source.expression}` : source.expression
|
|
80
|
+
return [head, ...stages.map(stage => ` |> ${emitStage(stage)}`)].join('\n')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function emitStage(stage) {
|
|
84
|
+
const args = stageArgs(stage)
|
|
85
|
+
const call = args ? `${stage.type}(${args.join(', ')})` : stage.type
|
|
86
|
+
return stage.id ? `${call} @${stage.id}` : call
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function stageArgs(stage) {
|
|
90
|
+
if ('expression' in stage) return [stage.expression]
|
|
91
|
+
if ('name' in stage) return [JSON.stringify(stage.name)]
|
|
92
|
+
if ('concurrency' in stage) return [String(stage.concurrency)]
|
|
93
|
+
if ('size' in stage) return [String(stage.size)]
|
|
94
|
+
return stage.args ?? null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Building the function only parses the generated code; it is never called.
|
|
98
|
+
function assertCompiles(source, path) {
|
|
99
|
+
const compiled = compile(source).replace(/^import .*\n/, '')
|
|
100
|
+
try {
|
|
101
|
+
new AsyncFunction(compiled)
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw new PlanValidationError(`${path} does not compile to valid JavaScript (${error.message})`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function validatePlan(plan) {
|
|
108
|
+
if (!isObject(plan)) throw new PlanValidationError('plan must be an object')
|
|
109
|
+
assertKnownFields(plan, ['version', 'pipelines'], '')
|
|
110
|
+
if (plan.version !== PLAN_VERSION) throw new PlanValidationError(`version must be ${PLAN_VERSION}`)
|
|
111
|
+
if (!Array.isArray(plan.pipelines)) throw new PlanValidationError('pipelines must be an array')
|
|
112
|
+
|
|
113
|
+
const names = new Set()
|
|
114
|
+
plan.pipelines.forEach((pipeline, index) => {
|
|
115
|
+
const path = `pipelines[${index}]`
|
|
116
|
+
if (!isObject(pipeline)) throw new PlanValidationError(`${path} must be an object`)
|
|
117
|
+
assertKnownFields(pipeline, ['pipeline', 'id', 'stages'], path)
|
|
118
|
+
|
|
119
|
+
if (pipeline.pipeline !== null && !IDENTIFIER.test(pipeline.pipeline ?? '')) {
|
|
120
|
+
throw new PlanValidationError(`${path}.pipeline must be an identifier or null`)
|
|
121
|
+
}
|
|
122
|
+
if (names.has(pipeline.pipeline)) {
|
|
123
|
+
throw new PlanValidationError(`${path}.pipeline "${pipeline.pipeline}" is already declared`)
|
|
124
|
+
}
|
|
125
|
+
if (pipeline.pipeline) names.add(pipeline.pipeline)
|
|
126
|
+
|
|
127
|
+
if (!Array.isArray(pipeline.stages) || !pipeline.stages.length) {
|
|
128
|
+
throw new PlanValidationError(`${path}.stages must be a non-empty array`)
|
|
129
|
+
}
|
|
130
|
+
const stageIds = new Set()
|
|
131
|
+
pipeline.stages.forEach((stage, stageIndex) => {
|
|
132
|
+
const stagePath = `${path}.stages[${stageIndex}]`
|
|
133
|
+
validateStage(stage, stageIndex, stagePath)
|
|
134
|
+
if (stage.id === undefined) return
|
|
135
|
+
if (stageIds.has(stage.id)) throw new PlanValidationError(`${stagePath}.id "${stage.id}" is already used in this pipeline`)
|
|
136
|
+
stageIds.add(stage.id)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function validateStage(stage, index, path) {
|
|
142
|
+
if (!isObject(stage)) throw new PlanValidationError(`${path} must be an object`)
|
|
143
|
+
|
|
144
|
+
if (index === 0) {
|
|
145
|
+
if (stage.type !== 'source') throw new PlanValidationError(`${path} must be a source stage`)
|
|
146
|
+
assertKnownFields(stage, ['type', 'expression'], path)
|
|
147
|
+
assertNonEmptyString(stage.expression, `${path}.expression`)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (stage.type === 'source') throw new PlanValidationError(`${path} must not be a source stage`)
|
|
152
|
+
if (typeof stage.type !== 'string' || !STAGE_NAME.test(stage.type)) {
|
|
153
|
+
throw new PlanValidationError(`${path}.type must be a stage name such as "map" or ".trim"`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const fields = allowedFields(stage.type)
|
|
157
|
+
assertKnownFields(stage, ['type', 'id', ...fields], path)
|
|
158
|
+
if (stage.id !== undefined && (typeof stage.id !== 'string' || !STAGE_ID.test(stage.id))) {
|
|
159
|
+
throw new PlanValidationError(`${path}.id must be a stage id such as "enrich"`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const used = fields.filter(field => field in stage)
|
|
163
|
+
if (used.length > 1) {
|
|
164
|
+
throw new PlanValidationError(`${path} must use only one of ${used.map(field => `"${field}"`).join(' or ')}`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const [field] = used
|
|
168
|
+
if (field === 'concurrency' || field === 'size') {
|
|
169
|
+
if (!Number.isInteger(stage[field]) || stage[field] <= 0) {
|
|
170
|
+
throw new PlanValidationError(`${path}.${field} must be a positive integer`)
|
|
171
|
+
}
|
|
172
|
+
} else if (field === 'args') {
|
|
173
|
+
if (!Array.isArray(stage.args) || !stage.args.every(arg => typeof arg === 'string' && arg.trim())) {
|
|
174
|
+
throw new PlanValidationError(`${path}.args must be an array of non-empty strings`)
|
|
175
|
+
}
|
|
176
|
+
} else if (field) {
|
|
177
|
+
assertNonEmptyString(stage[field], `${path}.${field}`)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function allowedFields(type) {
|
|
182
|
+
if (INTEGER_STAGE_FIELDS[type]) return [INTEGER_STAGE_FIELDS[type], 'args']
|
|
183
|
+
if (type === 'checkpoint') return ['name', 'args']
|
|
184
|
+
if (EXPRESSION_STAGES.has(type)) return ['expression', 'args']
|
|
185
|
+
return ['args']
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function assertKnownFields(value, fields, path) {
|
|
189
|
+
const unknown = Object.keys(value).find(key => !fields.includes(key))
|
|
190
|
+
if (unknown) {
|
|
191
|
+
throw new PlanValidationError(path ? `${path} has unknown field "${unknown}"` : `unknown field "${unknown}"`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function assertNonEmptyString(value, path) {
|
|
196
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
197
|
+
throw new PlanValidationError(`${path} must be a non-empty string`)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isObject(value) {
|
|
202
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
203
|
+
}
|
package/src/purity.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { maskNonCode } from './lexer.js'
|
|
2
|
+
import { findEffectDeclarations } from './effect-parser.js'
|
|
3
|
+
|
|
4
|
+
const PURITY_DECLARATION = /^([ \t]*)(export[ \t]+)?(async[ \t]+)?(pure|effectful)[ \t]+(async[ \t]+)?fn[ \t]+([A-Za-z_$][\w$]*)/gm
|
|
5
|
+
|
|
6
|
+
function lineAt(source, offset) {
|
|
7
|
+
return source.slice(0, offset).split('\n').length
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function matchingBrace(code, openIndex) {
|
|
11
|
+
let depth = 0
|
|
12
|
+
for (let i = openIndex; i < code.length; i++) {
|
|
13
|
+
if (code[i] === '{' || code[i] === '(') depth++
|
|
14
|
+
else if ((code[i] === '}' || code[i] === ')') && --depth === 0) return i
|
|
15
|
+
}
|
|
16
|
+
return code.length
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
* Body bounds come from the masked source so braces inside strings and
|
|
21
|
+
* comments never end a body early.
|
|
22
|
+
*/
|
|
23
|
+
function bodyRange(code, nameEnd) {
|
|
24
|
+
const paramsOpen = code.indexOf('(', nameEnd)
|
|
25
|
+
const paramsClose = matchingBrace(code, paramsOpen)
|
|
26
|
+
const rest = code.slice(paramsClose + 1)
|
|
27
|
+
const head = rest.search(/[{=]/)
|
|
28
|
+
const start = paramsClose + 1 + head
|
|
29
|
+
|
|
30
|
+
if (code[start] === '=') {
|
|
31
|
+
const lineEnd = code.indexOf('\n', start)
|
|
32
|
+
return { start, end: lineEnd === -1 ? code.length : lineEnd }
|
|
33
|
+
}
|
|
34
|
+
return { start, end: matchingBrace(code, start) + 1 }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function findPurityDeclarations(source) {
|
|
38
|
+
const code = maskNonCode(source)
|
|
39
|
+
const regex = new RegExp(PURITY_DECLARATION.source, PURITY_DECLARATION.flags)
|
|
40
|
+
const declarations = []
|
|
41
|
+
let match
|
|
42
|
+
|
|
43
|
+
while ((match = regex.exec(code))) {
|
|
44
|
+
const nameEnd = match.index + match[0].length
|
|
45
|
+
declarations.push({
|
|
46
|
+
name: match[6],
|
|
47
|
+
purity: match[4],
|
|
48
|
+
line: lineAt(source, match.index),
|
|
49
|
+
body: bodyRange(code, nameEnd)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return declarations
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/*
|
|
57
|
+
* Static and name-based: a pure function may not mention a declared effect
|
|
58
|
+
* or an effectful function outside strings, comments, and property access.
|
|
59
|
+
*/
|
|
60
|
+
export function findPurityViolations(source) {
|
|
61
|
+
const code = maskNonCode(source)
|
|
62
|
+
const declarations = findPurityDeclarations(source)
|
|
63
|
+
const forbidden = [
|
|
64
|
+
...findEffectDeclarations(source).map(declaration => ({ name: declaration.name, kind: 'effect' })),
|
|
65
|
+
...declarations
|
|
66
|
+
.filter(declaration => declaration.purity === 'effectful')
|
|
67
|
+
.map(declaration => ({ name: declaration.name, kind: 'effectful function' }))
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
return declarations
|
|
71
|
+
.filter(declaration => declaration.purity === 'pure')
|
|
72
|
+
.flatMap(declaration => {
|
|
73
|
+
const body = code.slice(declaration.body.start, declaration.body.end)
|
|
74
|
+
|
|
75
|
+
return forbidden
|
|
76
|
+
.filter(({ name }) => name !== declaration.name)
|
|
77
|
+
.map(({ name, kind }) => ({ kind, name, index: body.search(new RegExp(`(?<![.\\w$])${name}(?![\\w$])`)) }))
|
|
78
|
+
.filter(({ index }) => index !== -1)
|
|
79
|
+
.map(({ name, kind, index }) => ({
|
|
80
|
+
function: declaration.name,
|
|
81
|
+
uses: name,
|
|
82
|
+
kind,
|
|
83
|
+
line: lineAt(source, declaration.body.start + index)
|
|
84
|
+
}))
|
|
85
|
+
})
|
|
86
|
+
.sort((a, b) => a.line - b.line)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function transformPurityModifiers(source) {
|
|
90
|
+
const violation = findPurityViolations(source)[0]
|
|
91
|
+
if (violation) {
|
|
92
|
+
throw new SyntaxError(`pure fn "${violation.function}" uses ${violation.kind} "${violation.uses}" (line ${violation.line})`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const code = maskNonCode(source)
|
|
96
|
+
return source.replace(PURITY_DECLARATION, (match, ...args) => {
|
|
97
|
+
const offset = args[args.length - 2]
|
|
98
|
+
if (code.slice(offset, offset + match.length) !== match) return match
|
|
99
|
+
return match.replace(/(pure|effectful)[ \t]+(?=(?:async[ \t]+)?fn[ \t])/, '')
|
|
100
|
+
})
|
|
101
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
-
import path from 'node:path'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { toJson, fromJson } from './serialization.js'
|
|
3
5
|
|
|
4
6
|
/*
|
|
5
7
|
* Storage interface for checkpoints. Deliberately storage-agnostic: the
|
|
@@ -102,7 +104,7 @@ export class FileCheckpointStore extends CheckpointStore {
|
|
|
102
104
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
103
105
|
|
|
104
106
|
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
|
|
105
|
-
fs.writeFileSync(tmp,
|
|
107
|
+
fs.writeFileSync(tmp, toJson(checkpoint), 'utf8')
|
|
106
108
|
fs.renameSync(tmp, file)
|
|
107
109
|
}
|
|
108
110
|
|
|
@@ -112,7 +114,7 @@ export class FileCheckpointStore extends CheckpointStore {
|
|
|
112
114
|
|
|
113
115
|
let checkpoint
|
|
114
116
|
try {
|
|
115
|
-
checkpoint =
|
|
117
|
+
checkpoint = fromJson(fs.readFileSync(file, 'utf8'))
|
|
116
118
|
} catch (error) {
|
|
117
119
|
throw new Error(`Corrupted checkpoint "${checkpointId}": ${error.message}`)
|
|
118
120
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { getCurrentContext, getDefaultContext } from './execution-context.js'
|
|
1
|
+
import { getCurrentContext, getDefaultContext } from './execution-context.js'
|
|
2
|
+
import { now } from './effects.js'
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
* A pipeline checkpoint. `items` flow through unchanged; the operator's job
|
|
@@ -64,6 +65,6 @@ export async function* checkpoint(items, name, meta = {}) {
|
|
|
64
65
|
checkpointId: name,
|
|
65
66
|
items: buffer,
|
|
66
67
|
itemCount: buffer.length,
|
|
67
|
-
createdAt: new Date().toISOString()
|
|
68
|
+
createdAt: new Date(now()).toISOString()
|
|
68
69
|
})
|
|
69
70
|
}
|
package/src/runtime/effects.js
CHANGED
|
@@ -25,6 +25,7 @@ import { getCurrentContext } from './execution-context.js'
|
|
|
25
25
|
const handlerStorage = new AsyncLocalStorage()
|
|
26
26
|
const invocationStorage = new AsyncLocalStorage()
|
|
27
27
|
const authorizationStorage = new AsyncLocalStorage()
|
|
28
|
+
const deterministicStorage = new AsyncLocalStorage()
|
|
28
29
|
|
|
29
30
|
/*
|
|
30
31
|
* name -> { name, params, sideEffecting, replayable, builtin? }.
|
|
@@ -70,6 +71,15 @@ export class EffectAuthorizationError extends Error {
|
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
export class NondeterministicEffectError extends Error {
|
|
75
|
+
constructor(name) {
|
|
76
|
+
super(`Effect "${name}" has no deterministic handler; inside deterministic mode use { handle, deterministic: true } or replayHandlers`)
|
|
77
|
+
this.name = 'NondeterministicEffectError'
|
|
78
|
+
this.effectName = name
|
|
79
|
+
this.retryable = false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
73
83
|
/*
|
|
74
84
|
* Declares a named effect and returns the callable used to *perform* it.
|
|
75
85
|
* `options.sideEffecting` (default true) and `options.replayable` (default
|
|
@@ -221,9 +231,13 @@ export async function performEffect(name, args) {
|
|
|
221
231
|
throw new UnhandledEffectError(name)
|
|
222
232
|
}
|
|
223
233
|
|
|
234
|
+
const entry = frame.handlers[name]
|
|
235
|
+
if (deterministicStorage.getStore() && entry.deterministic !== true) {
|
|
236
|
+
throw new NondeterministicEffectError(name)
|
|
237
|
+
}
|
|
238
|
+
|
|
224
239
|
await authorizeEffect(name, args)
|
|
225
240
|
|
|
226
|
-
const entry = frame.handlers[name]
|
|
227
241
|
const nextInFlight = new Set(inFlight)
|
|
228
242
|
nextInFlight.add(name)
|
|
229
243
|
|
|
@@ -280,9 +294,65 @@ effectRegistry.set('PARALLEL', {
|
|
|
280
294
|
builtin: true
|
|
281
295
|
})
|
|
282
296
|
|
|
297
|
+
for (const [name, params] of [['TIME', []], ['RANDOM', []]]) {
|
|
298
|
+
effectRegistry.set(name, { name, params, sideEffecting: false, replayable: false, builtin: true })
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function builtinHandler(name) {
|
|
302
|
+
const frame = currentFrames().find(f => name in f.handlers)
|
|
303
|
+
return frame ? frame.handlers[name].handle : null
|
|
304
|
+
}
|
|
305
|
+
|
|
283
306
|
export function getParallelHandler() {
|
|
284
|
-
|
|
285
|
-
|
|
307
|
+
return builtinHandler('PARALLEL')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/*
|
|
311
|
+
* `now()` and `random()` are built-in capabilities like `PARALLEL`: they are
|
|
312
|
+
* synchronous, fall back to `Date.now()` / `Math.random()`, and can be
|
|
313
|
+
* replaced with `handle { TIME: ..., RANDOM: ... }` or `deterministic()`.
|
|
314
|
+
*/
|
|
315
|
+
export function now() {
|
|
316
|
+
const handler = builtinHandler('TIME')
|
|
317
|
+
return handler ? handler() : Date.now()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function random() {
|
|
321
|
+
const handler = builtinHandler('RANDOM')
|
|
322
|
+
return handler ? handler() : Math.random()
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// mulberry32: a small, well-distributed seeded generator.
|
|
326
|
+
function seededRandom(seed) {
|
|
327
|
+
let state = seed >>> 0
|
|
328
|
+
return () => {
|
|
329
|
+
state = (state + 0x6D2B79F5) >>> 0
|
|
330
|
+
let t = state
|
|
331
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
332
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
333
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function* runSequentially({ items, fn }) {
|
|
338
|
+
for await (const item of items) yield await fn(item)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/*
|
|
342
|
+
* Runs `fn` so that the same inputs always produce the same outputs: a seeded
|
|
343
|
+
* `random()`, a clock that starts at `startTime` and advances 1ms per read,
|
|
344
|
+
* sequential `parallel()`, and only effects whose handler is declared
|
|
345
|
+
* deterministic (including replay handlers).
|
|
346
|
+
*/
|
|
347
|
+
export function deterministic(fn, options = {}) {
|
|
348
|
+
let clock = options.startTime ?? 0
|
|
349
|
+
const handlers = {
|
|
350
|
+
TIME: { handle: () => clock++, deterministic: true },
|
|
351
|
+
RANDOM: { handle: seededRandom(options.seed ?? 1), deterministic: true },
|
|
352
|
+
PARALLEL: { handle: runSequentially, deterministic: true }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return deterministicStorage.run(true, () => withHandlers(handlers, fn))
|
|
286
356
|
}
|
|
287
357
|
|
|
288
358
|
/*
|
package/src/runtime/errors.js
CHANGED
|
@@ -27,3 +27,17 @@ export class PipelineError extends Error {
|
|
|
27
27
|
this.timestamp = details.timestamp ?? new Date().toISOString()
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
* A broken `requires`/`ensures` clause is a programming error, so retrying
|
|
33
|
+
* the same call can never succeed.
|
|
34
|
+
*/
|
|
35
|
+
export class ContractError extends NonRetryableError {
|
|
36
|
+
constructor(functionName, kind, condition) {
|
|
37
|
+
super(`Contract violated: ${functionName} ${kind} ${condition}`)
|
|
38
|
+
this.name = 'ContractError'
|
|
39
|
+
this.functionName = functionName
|
|
40
|
+
this.kind = kind
|
|
41
|
+
this.condition = condition
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/runtime/index.js
CHANGED
|
@@ -31,10 +31,12 @@ export {
|
|
|
31
31
|
} from './collections.js'
|
|
32
32
|
|
|
33
33
|
export { checkpoint } from './checkpoint.js'
|
|
34
|
+
export { toJson, fromJson } from './serialization.js'
|
|
35
|
+
export { defineType } from './tagged-unions.js'
|
|
34
36
|
export { inspect, formatInspectReport } from './inspect.js'
|
|
35
37
|
export { retry } from './retry.js'
|
|
36
38
|
export { traceNode } from './trace.js'
|
|
37
|
-
export { PipelineError, NonRetryableError } from './errors.js'
|
|
39
|
+
export { PipelineError, NonRetryableError, ContractError } from './errors.js'
|
|
38
40
|
export { Metrics, throughputOf } from './metrics.js'
|
|
39
41
|
export {
|
|
40
42
|
CheckpointStore,
|
|
@@ -62,7 +64,11 @@ export {
|
|
|
62
64
|
UnhandledEffectError,
|
|
63
65
|
EffectHandlerError,
|
|
64
66
|
RecursiveEffectError,
|
|
65
|
-
EffectAuthorizationError
|
|
67
|
+
EffectAuthorizationError,
|
|
68
|
+
NondeterministicEffectError,
|
|
69
|
+
deterministic,
|
|
70
|
+
now,
|
|
71
|
+
random
|
|
66
72
|
} from './effects.js'
|
|
67
73
|
export {
|
|
68
74
|
EffectLog,
|
package/src/runtime/mcp.js
CHANGED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Canonical JSON for JojoScript values. Object keys are sorted so equal
|
|
3
|
+
* values always produce identical text, and values plain JSON would lose
|
|
4
|
+
* are encoded as `{ "$type": ... }` objects. An ordinary object that has its
|
|
5
|
+
* own `$type` key is wrapped, so a tag is never ambiguous.
|
|
6
|
+
*/
|
|
7
|
+
export function toJson(value, options = {}) {
|
|
8
|
+
return JSON.stringify(encode(value, new Set()), null, options.indent)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function fromJson(text) {
|
|
12
|
+
return decode(JSON.parse(text))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ERROR_BUILT_INS = new Set(['name', 'message', 'stack', 'cause'])
|
|
16
|
+
|
|
17
|
+
function encodeNumber(value) {
|
|
18
|
+
if (Number.isNaN(value) || !Number.isFinite(value) || Object.is(value, -0)) {
|
|
19
|
+
return { $type: 'Number', value: Object.is(value, -0) ? '-0' : String(value) }
|
|
20
|
+
}
|
|
21
|
+
return value
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function encodeObject(value, ancestors) {
|
|
25
|
+
const result = {}
|
|
26
|
+
for (const key of Object.keys(value).sort()) result[key] = encode(value[key], ancestors)
|
|
27
|
+
return Object.hasOwn(value, '$type') ? { $type: 'Object', value: result } : result
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function encodeError(error, ancestors) {
|
|
31
|
+
const fields = {}
|
|
32
|
+
for (const key of Object.keys(error).sort()) {
|
|
33
|
+
if (!ERROR_BUILT_INS.has(key)) fields[key] = encode(error[key], ancestors)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const encoded = { $type: 'Error', name: error.name, message: error.message, fields }
|
|
37
|
+
if ('cause' in error) encoded.cause = encode(error.cause, ancestors)
|
|
38
|
+
return encoded
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function encode(value, ancestors) {
|
|
42
|
+
if (value === undefined) return { $type: 'undefined' }
|
|
43
|
+
if (typeof value === 'number') return encodeNumber(value)
|
|
44
|
+
if (typeof value === 'bigint') return { $type: 'BigInt', value: String(value) }
|
|
45
|
+
if (typeof value === 'function') throw new TypeError('Cannot serialize a function')
|
|
46
|
+
if (typeof value === 'symbol') throw new TypeError('Cannot serialize a symbol')
|
|
47
|
+
if (value === null || typeof value !== 'object') return value
|
|
48
|
+
|
|
49
|
+
if (ancestors.has(value)) throw new TypeError('Cannot serialize a circular structure')
|
|
50
|
+
ancestors.add(value)
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
if (value instanceof Date) return { $type: 'Date', value: value.toISOString() }
|
|
54
|
+
if (value instanceof Map) {
|
|
55
|
+
return { $type: 'Map', entries: [...value].map(([key, item]) => [encode(key, ancestors), encode(item, ancestors)]) }
|
|
56
|
+
}
|
|
57
|
+
if (value instanceof Set) return { $type: 'Set', values: [...value].map(item => encode(item, ancestors)) }
|
|
58
|
+
if (value instanceof Error) return encodeError(value, ancestors)
|
|
59
|
+
if (Array.isArray(value)) return value.map(item => encode(item, ancestors))
|
|
60
|
+
if (typeof value.toJSON === 'function') return encode(value.toJSON(), ancestors)
|
|
61
|
+
return encodeObject(value, ancestors)
|
|
62
|
+
} finally {
|
|
63
|
+
ancestors.delete(value)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function decodeEntries(object) {
|
|
68
|
+
const result = {}
|
|
69
|
+
for (const [key, item] of Object.entries(object)) result[key] = decode(item)
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function decodeError(encoded) {
|
|
74
|
+
const error = new Error(encoded.message)
|
|
75
|
+
error.name = encoded.name
|
|
76
|
+
Object.assign(error, decodeEntries(encoded.fields ?? {}))
|
|
77
|
+
if ('cause' in encoded) error.cause = decode(encoded.cause)
|
|
78
|
+
return error
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function decode(value) {
|
|
82
|
+
if (Array.isArray(value)) return value.map(decode)
|
|
83
|
+
if (value === null || typeof value !== 'object') return value
|
|
84
|
+
if (!Object.hasOwn(value, '$type')) return decodeEntries(value)
|
|
85
|
+
|
|
86
|
+
switch (value.$type) {
|
|
87
|
+
case 'undefined': return undefined
|
|
88
|
+
case 'Number': return Number(value.value)
|
|
89
|
+
case 'BigInt': return BigInt(value.value)
|
|
90
|
+
case 'Date': return new Date(value.value)
|
|
91
|
+
case 'Map': return new Map(value.entries.map(([key, item]) => [decode(key), decode(item)]))
|
|
92
|
+
case 'Set': return new Set(value.values.map(decode))
|
|
93
|
+
case 'Error': return decodeError(value)
|
|
94
|
+
case 'Object': return decodeEntries(value.value)
|
|
95
|
+
default: throw new TypeError(`Unknown serialized type "${value.$type}"`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tagged union values are frozen plain objects (`{ tag: "Ok", value: 1 }`),
|
|
3
|
+
* so they serialize to JSON and still match constructor patterns afterwards.
|
|
4
|
+
*/
|
|
5
|
+
export function defineType(typeName, variants) {
|
|
6
|
+
const constructors = {}
|
|
7
|
+
|
|
8
|
+
for (const [tag, fields] of Object.entries(variants)) {
|
|
9
|
+
if (fields.includes('tag')) {
|
|
10
|
+
throw new TypeError(`Variant "${tag}" cannot declare a field named "tag"`)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const construct = (...args) => {
|
|
14
|
+
if (args.length !== fields.length) {
|
|
15
|
+
const expected = fields.length === 1 ? '1 argument' : `${fields.length} arguments`
|
|
16
|
+
const names = fields.length ? ` (${fields.join(', ')})` : ''
|
|
17
|
+
throw new TypeError(`${tag} expects ${expected}${names}, got ${args.length}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const value = { tag }
|
|
21
|
+
fields.forEach((field, index) => { value[field] = args[index] })
|
|
22
|
+
return Object.freeze(value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
constructors[tag] = Object.assign(construct, { tag, type: typeName, fields: Object.freeze([...fields]) })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return Object.freeze(constructors)
|
|
29
|
+
}
|