@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.
@@ -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(parseStage(statement, stageTokens))
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, JSON.stringify(checkpoint), 'utf8')
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 = JSON.parse(fs.readFileSync(file, 'utf8'))
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
  }
@@ -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
- const frame = currentFrames().find(f => 'PARALLEL' in f.handlers)
285
- return frame ? frame.handlers.PARALLEL.handle : null
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
  /*
@@ -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
+ }
@@ -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,
@@ -159,7 +159,7 @@ export class McpServer {
159
159
  constructor({
160
160
  tools = {},
161
161
  name = 'JojoScript',
162
- version = '0.10.1',
162
+ version = '1.0.0',
163
163
  protocolVersion = MCP_PROTOCOL_VERSION
164
164
  } = {}) {
165
165
  this.tools = new Map(
@@ -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
+ }