@panagos/jojoscript 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ /*
5
+ * Storage interface for checkpoints. Deliberately storage-agnostic: the
6
+ * `checkpoint()` runtime operator only ever talks to this interface, never
7
+ * to a filesystem or database directly, so new backends (S3, a database,
8
+ * ...) can be added later without touching pipeline code.
9
+ */
10
+ export class CheckpointStore {
11
+ async save(_checkpoint) {
12
+ throw new Error('CheckpointStore.save() is not implemented')
13
+ }
14
+
15
+ async load(_pipelineId, _checkpointId) {
16
+ throw new Error('CheckpointStore.load() is not implemented')
17
+ }
18
+
19
+ async exists(_pipelineId, _checkpointId) {
20
+ throw new Error('CheckpointStore.exists() is not implemented')
21
+ }
22
+
23
+ async delete(_pipelineId, _checkpointId) {
24
+ throw new Error('CheckpointStore.delete() is not implemented')
25
+ }
26
+ }
27
+
28
+ function key(pipelineId, checkpointId) {
29
+ return `${pipelineId}::${checkpointId}`
30
+ }
31
+
32
+ /*
33
+ * In-memory store. Useful for tests and for short-lived processes where
34
+ * durability across restarts is not required.
35
+ */
36
+ export class MemoryCheckpointStore extends CheckpointStore {
37
+ constructor() {
38
+ super()
39
+ this.data = new Map()
40
+ }
41
+
42
+ async save(checkpoint) {
43
+ this.data.set(key(checkpoint.pipelineId, checkpoint.checkpointId), checkpoint)
44
+ }
45
+
46
+ async load(pipelineId, checkpointId) {
47
+ const found = this.data.get(key(pipelineId, checkpointId))
48
+ if (!found) throw new Error(`Checkpoint not found: ${checkpointId}`)
49
+ return found
50
+ }
51
+
52
+ async exists(pipelineId, checkpointId) {
53
+ return this.data.has(key(pipelineId, checkpointId))
54
+ }
55
+
56
+ async delete(pipelineId, checkpointId) {
57
+ this.data.delete(key(pipelineId, checkpointId))
58
+ }
59
+
60
+ list(pipelineId) {
61
+ return [...this.data.values()]
62
+ .filter(checkpoint => checkpoint.pipelineId === pipelineId)
63
+ .map(checkpoint => checkpoint.checkpointId)
64
+ }
65
+ }
66
+
67
+ /*
68
+ * JSON-on-disk store, one file per (pipelineId, checkpointId). Writes go to
69
+ * a temp file first and are then renamed into place, so a crash mid-write
70
+ * can never leave a corrupted checkpoint file behind that would silently
71
+ * poison a later resume.
72
+ */
73
+ export class FileCheckpointStore extends CheckpointStore {
74
+ constructor(baseDir = '.jojo-checkpoints') {
75
+ super()
76
+ this.baseDir = baseDir
77
+ }
78
+
79
+ filePath(pipelineId, checkpointId) {
80
+ return path.join(this.baseDir, pipelineId, `${checkpointId}.json`)
81
+ }
82
+
83
+ async save(checkpoint) {
84
+ const file = this.filePath(checkpoint.pipelineId, checkpoint.checkpointId)
85
+ fs.mkdirSync(path.dirname(file), { recursive: true })
86
+
87
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
88
+ fs.writeFileSync(tmp, JSON.stringify(checkpoint), 'utf8')
89
+ fs.renameSync(tmp, file)
90
+ }
91
+
92
+ async load(pipelineId, checkpointId) {
93
+ const file = this.filePath(pipelineId, checkpointId)
94
+ if (!fs.existsSync(file)) throw new Error(`Checkpoint not found: ${checkpointId}`)
95
+
96
+ let raw
97
+ try {
98
+ raw = fs.readFileSync(file, 'utf8')
99
+ return JSON.parse(raw)
100
+ } catch (error) {
101
+ throw new Error(`Corrupted checkpoint "${checkpointId}": ${error.message}`)
102
+ }
103
+ }
104
+
105
+ async exists(pipelineId, checkpointId) {
106
+ return fs.existsSync(this.filePath(pipelineId, checkpointId))
107
+ }
108
+
109
+ async delete(pipelineId, checkpointId) {
110
+ const file = this.filePath(pipelineId, checkpointId)
111
+ if (fs.existsSync(file)) fs.unlinkSync(file)
112
+ }
113
+
114
+ list(pipelineId) {
115
+ const dir = path.join(this.baseDir, pipelineId)
116
+ if (!fs.existsSync(dir)) return []
117
+
118
+ return fs.readdirSync(dir)
119
+ .filter(name => name.endsWith('.json'))
120
+ .map(name => name.slice(0, -'.json'.length))
121
+ }
122
+ }
@@ -0,0 +1,69 @@
1
+ import { getCurrentContext, getDefaultContext } from './execution-context.js'
2
+
3
+ /*
4
+ * A pipeline checkpoint. `items` flow through unchanged; the operator's job
5
+ * is purely persistence:
6
+ *
7
+ * - if a completed checkpoint already exists for this (pipelineId, name),
8
+ * the upstream `items` iterable is never touched at all (it may be an
9
+ * expensive, not-yet-started generator) — the saved items are yielded
10
+ * instead. This is the whole resume mechanism: re-running the same
11
+ * program after a crash just naturally skips completed work.
12
+ * - otherwise, items are streamed through as usual (never buffered ahead
13
+ * of the consumer) and also collected so they can be durably saved once
14
+ * the full checkpoint has completed. A checkpoint is only ever written
15
+ * after every item has passed through, so a crash mid-checkpoint never
16
+ * leaves a partial/misleading checkpoint on disk.
17
+ *
18
+ * `meta.checkpointOrder`/`meta.checkpointIndex` are injected by the
19
+ * compiler: the full, statically-known list of checkpoint names in this
20
+ * pipeline, and this call's position within it. They let `replay(--from)`
21
+ * decide whether a *later* checkpoint must be invalidated/recomputed
22
+ * without depending on the runtime order lazy generators happen to be
23
+ * pulled in (nested generators lazily run their upstream checks only once
24
+ * pulled, which can't be relied on for that decision).
25
+ */
26
+ export async function* checkpoint(items, name, meta = {}) {
27
+ const ctx = getCurrentContext() ?? getDefaultContext()
28
+ const pipelineId = ctx.pipelineId ?? meta.pipelineId ?? 'default'
29
+ const store = ctx.checkpointStore
30
+
31
+ const replayTargetIndex = ctx.replayFrom && meta.checkpointOrder
32
+ ? meta.checkpointOrder.indexOf(ctx.replayFrom)
33
+ : -1
34
+
35
+ const isReplayTarget = ctx.replayFrom === name
36
+ const isAfterReplayTarget =
37
+ replayTargetIndex !== -1 &&
38
+ typeof meta.checkpointIndex === 'number' &&
39
+ meta.checkpointIndex > replayTargetIndex
40
+
41
+ if (isReplayTarget) {
42
+ if (!(await store.exists(pipelineId, name))) {
43
+ throw new Error(`Cannot replay from checkpoint "${name}": no such checkpoint exists`)
44
+ }
45
+ const saved = await store.load(pipelineId, name)
46
+ for (const item of saved.items) yield item
47
+ return
48
+ }
49
+
50
+ if (!isAfterReplayTarget && await store.exists(pipelineId, name)) {
51
+ const saved = await store.load(pipelineId, name)
52
+ for (const item of saved.items) yield item
53
+ return
54
+ }
55
+
56
+ const buffer = []
57
+ for await (const item of items) {
58
+ buffer.push(item)
59
+ yield item
60
+ }
61
+
62
+ await store.save({
63
+ pipelineId,
64
+ checkpointId: name,
65
+ items: buffer,
66
+ itemCount: buffer.length,
67
+ createdAt: new Date().toISOString()
68
+ })
69
+ }