mundane-sdk 0.0.1

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 ADDED
@@ -0,0 +1,29 @@
1
+ # mundane-sdk (TypeScript)
2
+
3
+ See [`../SPEC.md`](../SPEC.md) for the cross-runtime contract.
4
+
5
+ ```ts
6
+ import { run } from "mundane-sdk";
7
+
8
+ await run("task.db", async (ctx) => {
9
+ const user = await ctx.step("fetch", async () => ({ name: "alice" }));
10
+ await ctx.sleep("cool-off", "100ms");
11
+ await ctx.step("notify", async () => `hi ${user.name}`);
12
+ });
13
+ ```
14
+
15
+ ## Implementation notes
16
+
17
+ - **Locking.** The runtime opens the SQLite file and takes
18
+ `flock(LOCK_EX | LOCK_NB)` on its fd via the `fs-ext` native binding —
19
+ no subprocess. It is the same `flock(2)` the bash, Go, and Python
20
+ runtimes use, so a lock held by one is visible to the rest, and the
21
+ kernel drops it automatically if the process dies.
22
+ - **No redundant SQLite locking.** The DB is opened with the `unix-none`
23
+ VFS (`file:…?vfs=unix-none`), which turns off SQLite's own file locking.
24
+ Our `flock` already guarantees a single writer for the whole run, so
25
+ SQLite's locking is redundant — and on platforms where SQLite locks via
26
+ `flock(2)` (e.g. macOS) it would collide with ours on the same file and
27
+ deadlock. `unix-none` leaves our flock as the sole authority.
28
+ - **DB binding.** Built on `node-sqlite3`, wrapped in a thin promise layer
29
+ (see `src/db.ts`).
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Thin promise wrapper over node-sqlite3.
3
+ *
4
+ * The database is opened with the `unix-none` VFS, which disables SQLite's own
5
+ * file locking. We don't need it: mundane already holds an exclusive
6
+ * `flock(LOCK_EX)` on the file for the whole run (see lock.ts), so single-writer
7
+ * is guaranteed externally and visible across all runtimes. Leaving SQLite's
8
+ * locking on would be redundant — and on platforms where SQLite locks via
9
+ * `flock(2)` (e.g. macOS), it would collide with our own flock on the same file
10
+ * and deadlock ("database is locked"). `unix-none` sidesteps that entirely.
11
+ */
12
+ export interface Db {
13
+ run(sql: string, params?: unknown[]): Promise<void>;
14
+ get<T>(sql: string, params?: unknown[]): Promise<T | undefined>;
15
+ all<T>(sql: string, params?: unknown[]): Promise<T[]>;
16
+ exec(sql: string): Promise<void>;
17
+ close(): Promise<void>;
18
+ }
19
+ export declare function openDb(path: string, opts?: {
20
+ readonly?: boolean;
21
+ }): Promise<Db>;
package/dist/src/db.js ADDED
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ /**
3
+ * Thin promise wrapper over node-sqlite3.
4
+ *
5
+ * The database is opened with the `unix-none` VFS, which disables SQLite's own
6
+ * file locking. We don't need it: mundane already holds an exclusive
7
+ * `flock(LOCK_EX)` on the file for the whole run (see lock.ts), so single-writer
8
+ * is guaranteed externally and visible across all runtimes. Leaving SQLite's
9
+ * locking on would be redundant — and on platforms where SQLite locks via
10
+ * `flock(2)` (e.g. macOS), it would collide with our own flock on the same file
11
+ * and deadlock ("database is locked"). `unix-none` sidesteps that entirely.
12
+ */
13
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ var desc = Object.getOwnPropertyDescriptor(m, k);
16
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
17
+ desc = { enumerable: true, get: function() { return m[k]; } };
18
+ }
19
+ Object.defineProperty(o, k2, desc);
20
+ }) : (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ o[k2] = m[k];
23
+ }));
24
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
25
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
26
+ }) : function(o, v) {
27
+ o["default"] = v;
28
+ });
29
+ var __importStar = (this && this.__importStar) || (function () {
30
+ var ownKeys = function(o) {
31
+ ownKeys = Object.getOwnPropertyNames || function (o) {
32
+ var ar = [];
33
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
34
+ return ar;
35
+ };
36
+ return ownKeys(o);
37
+ };
38
+ return function (mod) {
39
+ if (mod && mod.__esModule) return mod;
40
+ var result = {};
41
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
42
+ __setModuleDefault(result, mod);
43
+ return result;
44
+ };
45
+ })();
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.openDb = openDb;
48
+ const node_url_1 = require("node:url");
49
+ const sqlite3 = __importStar(require("sqlite3"));
50
+ function openDb(path, opts = {}) {
51
+ return new Promise((resolve, reject) => {
52
+ const url = (0, node_url_1.pathToFileURL)(path);
53
+ url.searchParams.set("vfs", "unix-none");
54
+ const mode = (opts.readonly ? sqlite3.OPEN_READONLY : sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE) |
55
+ sqlite3.OPEN_URI;
56
+ const raw = new sqlite3.Database(url.href, mode, (err) => {
57
+ if (err)
58
+ reject(err);
59
+ else
60
+ resolve(wrap(raw));
61
+ });
62
+ });
63
+ }
64
+ function wrap(raw) {
65
+ return {
66
+ run: (sql, params = []) => new Promise((resolve, reject) => raw.run(sql, params, (err) => (err ? reject(err) : resolve()))),
67
+ get: (sql, params = []) => new Promise((resolve, reject) => raw.get(sql, params, (err, row) => (err ? reject(err) : resolve(row)))),
68
+ all: (sql, params = []) => new Promise((resolve, reject) => raw.all(sql, params, (err, rows) => (err ? reject(err) : resolve(rows)))),
69
+ exec: (sql) => new Promise((resolve, reject) => raw.exec(sql, (err) => (err ? reject(err) : resolve()))),
70
+ close: () => new Promise((resolve, reject) => raw.close((err) => (err ? reject(err) : resolve()))),
71
+ };
72
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Parse duration strings like "30s", "5m", "2h", "1d", "500ms".
3
+ *
4
+ * Returns the number of milliseconds. Throws on unparseable input.
5
+ */
6
+ export declare function parseDurationMs(input: string | number): number;
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ /**
3
+ * Parse duration strings like "30s", "5m", "2h", "1d", "500ms".
4
+ *
5
+ * Returns the number of milliseconds. Throws on unparseable input.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.parseDurationMs = parseDurationMs;
9
+ const UNIT_MS = {
10
+ ms: 1,
11
+ s: 1_000,
12
+ m: 60_000,
13
+ h: 3_600_000,
14
+ d: 86_400_000,
15
+ };
16
+ const RE = /^\s*(\d+(?:\.\d+)?)(ms|s|m|h|d)\s*$/i;
17
+ function parseDurationMs(input) {
18
+ if (typeof input === "number") {
19
+ if (!Number.isFinite(input))
20
+ throw new TypeError("duration: non-finite number");
21
+ return Math.trunc(input);
22
+ }
23
+ const m = RE.exec(input);
24
+ if (!m) {
25
+ throw new Error(`invalid duration ${JSON.stringify(input)}: expected e.g. "500ms", "30s", "5m", "2h", "1d"`);
26
+ }
27
+ const magnitude = Number(m[1]);
28
+ const unit = m[2].toLowerCase();
29
+ return Math.round(magnitude * UNIT_MS[unit]);
30
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Custom error classes thrown by mundane.
3
+ *
4
+ * - MundaneLockedError: another live process holds the file lock.
5
+ * - MundaneSerializationError: a step's return value doesn't survive a
6
+ * JSON round-trip (Date, undefined, BigInt, Map, Set, function, circular).
7
+ * - MundaneSchemaError: file exists but meta.schema_version != "1".
8
+ * - MundaneStepFailedError: a step body threw; wraps the underlying error.
9
+ */
10
+ export declare class MundaneLockedError extends Error {
11
+ readonly code = "EMUNDANELOCKED";
12
+ constructor(message: string);
13
+ }
14
+ export declare class MundaneSerializationError extends Error {
15
+ readonly code = "EMUNDANESERIALIZATION";
16
+ readonly path?: string;
17
+ constructor(message: string, path?: string);
18
+ }
19
+ export declare class MundaneSchemaError extends Error {
20
+ readonly code = "EMUNDANESCHEMA";
21
+ constructor(message: string);
22
+ }
23
+ export declare class MundaneDuplicateStepError extends Error {
24
+ readonly code = "EMUNDANEDUPLICATE";
25
+ readonly stepName: string;
26
+ constructor(stepName: string);
27
+ }
28
+ export declare class MundaneStepFailedError extends Error {
29
+ readonly code = "EMUNDANESTEPFAILED";
30
+ readonly stepName: string;
31
+ readonly original: unknown;
32
+ constructor(stepName: string, original: unknown);
33
+ }
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ /**
3
+ * Custom error classes thrown by mundane.
4
+ *
5
+ * - MundaneLockedError: another live process holds the file lock.
6
+ * - MundaneSerializationError: a step's return value doesn't survive a
7
+ * JSON round-trip (Date, undefined, BigInt, Map, Set, function, circular).
8
+ * - MundaneSchemaError: file exists but meta.schema_version != "1".
9
+ * - MundaneStepFailedError: a step body threw; wraps the underlying error.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.MundaneStepFailedError = exports.MundaneDuplicateStepError = exports.MundaneSchemaError = exports.MundaneSerializationError = exports.MundaneLockedError = void 0;
13
+ class MundaneLockedError extends Error {
14
+ code = "EMUNDANELOCKED";
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "MundaneLockedError";
18
+ }
19
+ }
20
+ exports.MundaneLockedError = MundaneLockedError;
21
+ class MundaneSerializationError extends Error {
22
+ code = "EMUNDANESERIALIZATION";
23
+ path;
24
+ constructor(message, path) {
25
+ super(message);
26
+ this.name = "MundaneSerializationError";
27
+ this.path = path;
28
+ }
29
+ }
30
+ exports.MundaneSerializationError = MundaneSerializationError;
31
+ class MundaneSchemaError extends Error {
32
+ code = "EMUNDANESCHEMA";
33
+ constructor(message) {
34
+ super(message);
35
+ this.name = "MundaneSchemaError";
36
+ }
37
+ }
38
+ exports.MundaneSchemaError = MundaneSchemaError;
39
+ class MundaneDuplicateStepError extends Error {
40
+ code = "EMUNDANEDUPLICATE";
41
+ stepName;
42
+ constructor(stepName) {
43
+ super(`duplicate step name: ${stepName}`);
44
+ this.name = "MundaneDuplicateStepError";
45
+ this.stepName = stepName;
46
+ }
47
+ }
48
+ exports.MundaneDuplicateStepError = MundaneDuplicateStepError;
49
+ class MundaneStepFailedError extends Error {
50
+ code = "EMUNDANESTEPFAILED";
51
+ stepName;
52
+ original;
53
+ constructor(stepName, original) {
54
+ const msg = original instanceof Error
55
+ ? `step ${JSON.stringify(stepName)} failed: ${original.message}`
56
+ : `step ${JSON.stringify(stepName)} failed: ${String(original)}`;
57
+ super(msg);
58
+ this.name = "MundaneStepFailedError";
59
+ this.stepName = stepName;
60
+ this.original = original;
61
+ if (original instanceof Error && original.stack) {
62
+ this.stack = `${this.stack}\nCaused by: ${original.stack}`;
63
+ }
64
+ }
65
+ }
66
+ exports.MundaneStepFailedError = MundaneStepFailedError;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * mundane-sdk — tiny durable-execution library.
3
+ *
4
+ * Public API:
5
+ * run(path, async (ctx) => { ... })
6
+ * ctx.step(name, async () => json-able)
7
+ * ctx.sleep(name, "5m" | 5_000)
8
+ *
9
+ * See ../../../SPEC.md (project root) for the full contract.
10
+ */
11
+ import { MundaneDuplicateStepError, MundaneLockedError, MundaneSchemaError, MundaneSerializationError, MundaneStepFailedError } from "./errors";
12
+ export { MundaneDuplicateStepError, MundaneLockedError, MundaneSchemaError, MundaneSerializationError, MundaneStepFailedError, };
13
+ export type Json = null | boolean | number | string | Json[] | {
14
+ [k: string]: Json;
15
+ };
16
+ export interface Context {
17
+ step<T = unknown>(name: string, fn: () => Promise<T> | T): Promise<T>;
18
+ sleep(name: string, duration: string | number): Promise<void>;
19
+ }
20
+ export declare function run<T>(path: string, fn: (ctx: Context) => Promise<T> | T): Promise<T>;
@@ -0,0 +1,236 @@
1
+ "use strict";
2
+ /**
3
+ * mundane-sdk — tiny durable-execution library.
4
+ *
5
+ * Public API:
6
+ * run(path, async (ctx) => { ... })
7
+ * ctx.step(name, async () => json-able)
8
+ * ctx.sleep(name, "5m" | 5_000)
9
+ *
10
+ * See ../../../SPEC.md (project root) for the full contract.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.MundaneStepFailedError = exports.MundaneSerializationError = exports.MundaneSchemaError = exports.MundaneLockedError = exports.MundaneDuplicateStepError = void 0;
14
+ exports.run = run;
15
+ const db_1 = require("./db");
16
+ const duration_1 = require("./duration");
17
+ const errors_1 = require("./errors");
18
+ Object.defineProperty(exports, "MundaneDuplicateStepError", { enumerable: true, get: function () { return errors_1.MundaneDuplicateStepError; } });
19
+ Object.defineProperty(exports, "MundaneLockedError", { enumerable: true, get: function () { return errors_1.MundaneLockedError; } });
20
+ Object.defineProperty(exports, "MundaneSchemaError", { enumerable: true, get: function () { return errors_1.MundaneSchemaError; } });
21
+ Object.defineProperty(exports, "MundaneSerializationError", { enumerable: true, get: function () { return errors_1.MundaneSerializationError; } });
22
+ Object.defineProperty(exports, "MundaneStepFailedError", { enumerable: true, get: function () { return errors_1.MundaneStepFailedError; } });
23
+ const lock_1 = require("./lock");
24
+ const names_1 = require("./names");
25
+ const schema_1 = require("./schema");
26
+ function checkJsonRoundtrip(value) {
27
+ let text;
28
+ try {
29
+ text = JSON.stringify(value);
30
+ }
31
+ catch (e) {
32
+ throw new errors_1.MundaneSerializationError(`value is not JSON-serializable: ${e.message}`);
33
+ }
34
+ if (text === undefined) {
35
+ // JSON.stringify returns undefined for top-level undefined/function/symbol
36
+ throw new errors_1.MundaneSerializationError("value is not JSON-serializable (undefined / function / symbol at top level)");
37
+ }
38
+ const decoded = JSON.parse(text);
39
+ const mismatch = deepDiff(value, decoded, "");
40
+ if (mismatch !== null) {
41
+ throw new errors_1.MundaneSerializationError(`value does not round-trip through JSON at ${JSON.stringify(mismatch)}`, mismatch);
42
+ }
43
+ return text;
44
+ }
45
+ function deepDiff(a, b, path) {
46
+ if (a === b)
47
+ return null;
48
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") {
49
+ // Special-case: NaN/Infinity get encoded as null by JSON, mismatch.
50
+ return path || "(root)";
51
+ }
52
+ if (Array.isArray(a) !== Array.isArray(b))
53
+ return path || "(root)";
54
+ if (Array.isArray(a) && Array.isArray(b)) {
55
+ if (a.length !== b.length)
56
+ return path || "(root)";
57
+ for (let i = 0; i < a.length; i++) {
58
+ const d = deepDiff(a[i], b[i], `${path}[${i}]`);
59
+ if (d)
60
+ return d;
61
+ }
62
+ return null;
63
+ }
64
+ // Plain objects: keys must match exactly (catches `undefined` values
65
+ // disappearing, Date/Map/Set turning into "{}", and class instances).
66
+ const aProto = Object.getPrototypeOf(a);
67
+ if (aProto !== Object.prototype && aProto !== null) {
68
+ return path || "(root)";
69
+ }
70
+ const aKeys = Object.keys(a).sort();
71
+ const bKeys = Object.keys(b).sort();
72
+ if (aKeys.length !== bKeys.length)
73
+ return path || "(root)";
74
+ for (let i = 0; i < aKeys.length; i++) {
75
+ if (aKeys[i] !== bKeys[i])
76
+ return path || "(root)";
77
+ const d = deepDiff(a[aKeys[i]], b[aKeys[i]], `${path}.${aKeys[i]}`);
78
+ if (d)
79
+ return d;
80
+ }
81
+ return null;
82
+ }
83
+ function decodeResult(row) {
84
+ if (row.result === null)
85
+ return null;
86
+ // node-sqlite3 returns BLOB as Buffer, TEXT as string.
87
+ switch (row.encoding) {
88
+ case "json": {
89
+ const text = typeof row.result === "string" ? row.result : row.result.toString("utf8");
90
+ return JSON.parse(text);
91
+ }
92
+ case "bytes":
93
+ return typeof row.result === "string" ? Buffer.from(row.result, "utf8") : row.result;
94
+ default:
95
+ throw new Error(`unknown encoding: ${row.encoding}`);
96
+ }
97
+ }
98
+ class TaskState {
99
+ db;
100
+ cache = new Map();
101
+ seen = new Set();
102
+ constructor(db) {
103
+ this.db = db;
104
+ }
105
+ static async create(db) {
106
+ const task = new TaskState(db);
107
+ await task.loadCache();
108
+ return task;
109
+ }
110
+ checkSeen(name) {
111
+ if (this.seen.has(name)) {
112
+ throw new errors_1.MundaneDuplicateStepError(name);
113
+ }
114
+ this.seen.add(name);
115
+ }
116
+ async loadCache() {
117
+ const rows = await this.db.all("SELECT id, name, kind, encoding, result, status, error FROM mundane_steps ORDER BY id");
118
+ for (const row of rows)
119
+ this.cache.set(row.name, row);
120
+ }
121
+ async ensurePendingRow(name, kind, encoding) {
122
+ const existing = this.cache.get(name);
123
+ if (existing) {
124
+ // Re-running a leftover pending/failed row (never 'done' on this path):
125
+ // reset it to pending so the on-disk state reflects the retry (SPEC §2).
126
+ await this.db.run("UPDATE mundane_steps SET kind=?, encoding=?, status='pending', result=NULL, error=NULL, finished_at=NULL " +
127
+ "WHERE name=?", [kind, encoding, name]);
128
+ existing.kind = kind;
129
+ existing.encoding = encoding;
130
+ existing.status = "pending";
131
+ existing.result = null;
132
+ existing.error = null;
133
+ return existing;
134
+ }
135
+ const now = new Date().toISOString();
136
+ await this.db.run("INSERT INTO mundane_steps (name, kind, encoding, result, status, started_at) " +
137
+ "VALUES (?, ?, ?, NULL, 'pending', ?)", [name, kind, encoding, now]);
138
+ const row = (await this.db.get("SELECT id, name, kind, encoding, result, status, error FROM mundane_steps WHERE name = ?", [name]));
139
+ this.cache.set(name, row);
140
+ return row;
141
+ }
142
+ async commitDone(name, encoding, result) {
143
+ const finished = new Date().toISOString();
144
+ await this.db.run("UPDATE mundane_steps SET status='done', encoding=?, result=?, finished_at=?, error=NULL " +
145
+ "WHERE name=?", [encoding, result, finished, name]);
146
+ const row = this.cache.get(name);
147
+ row.status = "done";
148
+ row.encoding = encoding;
149
+ row.result = result;
150
+ }
151
+ async commitFailed(name, errMsg) {
152
+ const finished = new Date().toISOString();
153
+ await this.db.run("UPDATE mundane_steps SET status='failed', error=?, finished_at=? WHERE name=?", [errMsg, finished, name]);
154
+ const row = this.cache.get(name);
155
+ row.status = "failed";
156
+ row.error = errMsg;
157
+ }
158
+ }
159
+ class ContextImpl {
160
+ task;
161
+ constructor(task) {
162
+ this.task = task;
163
+ }
164
+ async step(name, fn) {
165
+ (0, names_1.validateName)(name);
166
+ this.task.checkSeen(name);
167
+ const cached = this.task.cache.get(name);
168
+ if (cached && cached.status === "done") {
169
+ return decodeResult(cached);
170
+ }
171
+ await this.task.ensurePendingRow(name, "step", "json");
172
+ let value;
173
+ try {
174
+ value = await fn();
175
+ }
176
+ catch (e) {
177
+ const msg = e instanceof Error ? e.message : String(e);
178
+ await this.task.commitFailed(name, msg);
179
+ throw new errors_1.MundaneStepFailedError(name, e);
180
+ }
181
+ const text = checkJsonRoundtrip(value);
182
+ await this.task.commitDone(name, "json", text);
183
+ // Return the round-tripped value so first run and resume agree exactly.
184
+ return JSON.parse(text);
185
+ }
186
+ async sleep(name, duration) {
187
+ (0, names_1.validateName)(name);
188
+ this.task.checkSeen(name);
189
+ const cached = this.task.cache.get(name);
190
+ let wakeAt;
191
+ if (cached && cached.status === "done") {
192
+ // Resume: the duration arg is ignored (SPEC §6), so don't parse it — a
193
+ // now-invalid duration string must not fail an otherwise-no-op resume.
194
+ wakeAt = Number(decodeResult(cached));
195
+ }
196
+ else {
197
+ wakeAt = Date.now() + (0, duration_1.parseDurationMs)(duration);
198
+ await this.task.ensurePendingRow(name, "sleep", "json");
199
+ await this.task.commitDone(name, "json", String(wakeAt));
200
+ }
201
+ const remaining = wakeAt - Date.now();
202
+ if (remaining > 0) {
203
+ await new Promise((resolve) => setTimeout(resolve, remaining));
204
+ }
205
+ }
206
+ }
207
+ async function run(path, fn) {
208
+ let lock;
209
+ try {
210
+ lock = await (0, lock_1.acquireLock)(path);
211
+ }
212
+ catch (e) {
213
+ if (e instanceof errors_1.MundaneLockedError)
214
+ throw e;
215
+ throw e;
216
+ }
217
+ let db = null;
218
+ try {
219
+ db = await (0, db_1.openDb)(path);
220
+ await (0, schema_1.bootstrap)(db);
221
+ const task = await TaskState.create(db);
222
+ const ctx = new ContextImpl(task);
223
+ return await fn(ctx);
224
+ }
225
+ finally {
226
+ if (db) {
227
+ try {
228
+ await db.close();
229
+ }
230
+ catch {
231
+ // ignore
232
+ }
233
+ }
234
+ await lock.release();
235
+ }
236
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Cross-process exclusive lock interoperable with flock(2).
3
+ *
4
+ * We open the SQLite file and take flock(LOCK_EX | LOCK_NB) on its fd via the
5
+ * `fs-ext` native binding — no subprocess. This is the same flock(2) that the
6
+ * bash (`flock`), Go (`unix.Flock`), and Python (`fcntl.flock`) runtimes use,
7
+ * so a lock held by any one is visible to all. The kernel drops it
8
+ * automatically if the process dies, so there is nothing to clean up on crash.
9
+ *
10
+ * We hold a raw integer fd (not a `FileHandle`) on purpose: a `FileHandle` has
11
+ * a GC finalizer that would close the fd — and release the lock — if it were
12
+ * collected mid-run. A raw fd stays open until we close it (or the process
13
+ * exits).
14
+ */
15
+ export interface AcquiredLock {
16
+ release(): Promise<void>;
17
+ }
18
+ export declare function acquireLock(path: string): Promise<AcquiredLock>;
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ /**
3
+ * Cross-process exclusive lock interoperable with flock(2).
4
+ *
5
+ * We open the SQLite file and take flock(LOCK_EX | LOCK_NB) on its fd via the
6
+ * `fs-ext` native binding — no subprocess. This is the same flock(2) that the
7
+ * bash (`flock`), Go (`unix.Flock`), and Python (`fcntl.flock`) runtimes use,
8
+ * so a lock held by any one is visible to all. The kernel drops it
9
+ * automatically if the process dies, so there is nothing to clean up on crash.
10
+ *
11
+ * We hold a raw integer fd (not a `FileHandle`) on purpose: a `FileHandle` has
12
+ * a GC finalizer that would close the fd — and release the lock — if it were
13
+ * collected mid-run. A raw fd stays open until we close it (or the process
14
+ * exits).
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.acquireLock = acquireLock;
18
+ const node_fs_1 = require("node:fs");
19
+ const fs_ext_1 = require("fs-ext");
20
+ const errors_1 = require("./errors");
21
+ function openFd(path) {
22
+ return new Promise((resolve, reject) => {
23
+ (0, node_fs_1.open)(path, node_fs_1.constants.O_RDWR | node_fs_1.constants.O_CREAT, 0o644, (err, fd) => {
24
+ if (err)
25
+ reject(err);
26
+ else
27
+ resolve(fd);
28
+ });
29
+ });
30
+ }
31
+ function closeFd(fd) {
32
+ return new Promise((resolve) => {
33
+ // Closing drops the flock; ignore close errors (fd may already be gone).
34
+ (0, node_fs_1.close)(fd, () => resolve());
35
+ });
36
+ }
37
+ function flockExclusiveNonblock(fd) {
38
+ return new Promise((resolve, reject) => {
39
+ (0, fs_ext_1.flock)(fd, "exnb", (err) => {
40
+ if (err)
41
+ reject(err);
42
+ else
43
+ resolve();
44
+ });
45
+ });
46
+ }
47
+ async function acquireLock(path) {
48
+ const fd = await openFd(path);
49
+ try {
50
+ await flockExclusiveNonblock(fd);
51
+ }
52
+ catch (e) {
53
+ await closeFd(fd);
54
+ const code = e.code;
55
+ if (code === "EWOULDBLOCK" || code === "EAGAIN") {
56
+ throw new errors_1.MundaneLockedError(`${path}: locked by another process`);
57
+ }
58
+ throw e;
59
+ }
60
+ let released = false;
61
+ return {
62
+ async release() {
63
+ if (released)
64
+ return;
65
+ released = true;
66
+ await closeFd(fd);
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Step-name validation. Names must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/.
3
+ * Duplicate names within one task body raise MundaneDuplicateStepError;
4
+ * see ../src/index.ts.
5
+ */
6
+ export declare function validateName(name: string): void;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ /**
3
+ * Step-name validation. Names must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/.
4
+ * Duplicate names within one task body raise MundaneDuplicateStepError;
5
+ * see ../src/index.ts.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.validateName = validateName;
9
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
10
+ function validateName(name) {
11
+ if (typeof name !== "string" || !NAME_RE.test(name)) {
12
+ throw new Error(`invalid step name ${JSON.stringify(name)}: must match ${NAME_RE.source}`);
13
+ }
14
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Schema definition shared across runtimes.
3
+ *
4
+ * The schema is pinned to v1. Opening a file whose meta.schema_version is
5
+ * not "1" is a hard error.
6
+ */
7
+ import type { Db } from "./db";
8
+ export declare const SCHEMA_VERSION = "1";
9
+ export declare const CREATE_META = "\nCREATE TABLE IF NOT EXISTS mundane_meta (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n)\n";
10
+ export declare const CREATE_STEPS = "\nCREATE TABLE IF NOT EXISTS mundane_steps (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT NOT NULL,\n kind TEXT NOT NULL,\n encoding TEXT NOT NULL,\n result BLOB,\n status TEXT NOT NULL,\n error TEXT,\n started_at TEXT NOT NULL,\n finished_at TEXT,\n UNIQUE(name)\n)\n";
11
+ export declare const CREATE_INDEX = "\nCREATE INDEX IF NOT EXISTS mundane_steps_status ON mundane_steps(status)\n";
12
+ export declare function bootstrap(db: Db): Promise<void>;
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ /**
3
+ * Schema definition shared across runtimes.
4
+ *
5
+ * The schema is pinned to v1. Opening a file whose meta.schema_version is
6
+ * not "1" is a hard error.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.CREATE_INDEX = exports.CREATE_STEPS = exports.CREATE_META = exports.SCHEMA_VERSION = void 0;
10
+ exports.bootstrap = bootstrap;
11
+ const node_crypto_1 = require("node:crypto");
12
+ const errors_1 = require("./errors");
13
+ exports.SCHEMA_VERSION = "1";
14
+ exports.CREATE_META = `
15
+ CREATE TABLE IF NOT EXISTS mundane_meta (
16
+ key TEXT PRIMARY KEY,
17
+ value TEXT NOT NULL
18
+ )
19
+ `;
20
+ exports.CREATE_STEPS = `
21
+ CREATE TABLE IF NOT EXISTS mundane_steps (
22
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ name TEXT NOT NULL,
24
+ kind TEXT NOT NULL,
25
+ encoding TEXT NOT NULL,
26
+ result BLOB,
27
+ status TEXT NOT NULL,
28
+ error TEXT,
29
+ started_at TEXT NOT NULL,
30
+ finished_at TEXT,
31
+ UNIQUE(name)
32
+ )
33
+ `;
34
+ exports.CREATE_INDEX = `
35
+ CREATE INDEX IF NOT EXISTS mundane_steps_status ON mundane_steps(status)
36
+ `;
37
+ async function bootstrap(db) {
38
+ await db.exec("PRAGMA journal_mode = DELETE");
39
+ // Pre-check: if mundane_meta exists with wrong schema_version, bail before
40
+ // running CREATE INDEX (which assumes columns we only promise at v1).
41
+ const existing = await db.get("SELECT name FROM sqlite_master WHERE type='table' AND name='mundane_meta'");
42
+ if (existing) {
43
+ const row = await db.get("SELECT value FROM mundane_meta WHERE key='schema_version'");
44
+ if (row && row.value !== exports.SCHEMA_VERSION) {
45
+ throw new errors_1.MundaneSchemaError(`schema_version is ${JSON.stringify(row.value)}, expected "${exports.SCHEMA_VERSION}"`);
46
+ }
47
+ }
48
+ await db.exec("BEGIN IMMEDIATE");
49
+ try {
50
+ await db.exec(exports.CREATE_META);
51
+ await db.exec(exports.CREATE_STEPS);
52
+ await db.exec(exports.CREATE_INDEX);
53
+ await db.run("INSERT OR IGNORE INTO mundane_meta (key, value) VALUES ('schema_version', ?)", [
54
+ exports.SCHEMA_VERSION,
55
+ ]);
56
+ await db.run("INSERT OR IGNORE INTO mundane_meta (key, value) VALUES ('task_id', ?)", [
57
+ (0, node_crypto_1.randomUUID)(),
58
+ ]);
59
+ await db.run("INSERT OR IGNORE INTO mundane_meta (key, value) VALUES ('created_at', ?)", [
60
+ new Date().toISOString(),
61
+ ]);
62
+ await db.exec("COMMIT");
63
+ }
64
+ catch (e) {
65
+ try {
66
+ await db.exec("ROLLBACK");
67
+ }
68
+ catch { }
69
+ throw e;
70
+ }
71
+ // Final check.
72
+ const row = await db.get("SELECT value FROM mundane_meta WHERE key='schema_version'");
73
+ if (!row || row.value !== exports.SCHEMA_VERSION) {
74
+ throw new errors_1.MundaneSchemaError(`schema_version is ${JSON.stringify(row?.value)}, expected "${exports.SCHEMA_VERSION}"`);
75
+ }
76
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,295 @@
1
+ "use strict";
2
+ /* Basic tests for mundane-sdk */
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const strict_1 = __importDefault(require("node:assert/strict"));
8
+ const node_fs_1 = require("node:fs");
9
+ const node_os_1 = require("node:os");
10
+ const node_path_1 = require("node:path");
11
+ const node_test_1 = require("node:test");
12
+ const db_1 = require("../src/db");
13
+ const index_1 = require("../src/index");
14
+ // Inspection lives in the CLI now; tests read on-disk state directly.
15
+ async function readSteps(path) {
16
+ const db = await (0, db_1.openDb)(path, { readonly: true });
17
+ try {
18
+ return await db.all("SELECT name, encoding, status FROM mundane_steps ORDER BY id");
19
+ }
20
+ finally {
21
+ await db.close();
22
+ }
23
+ }
24
+ async function readResult(path, name) {
25
+ const db = await (0, db_1.openDb)(path, { readonly: true });
26
+ try {
27
+ const row = await db.get("SELECT result, encoding FROM mundane_steps WHERE name = ?", [name]);
28
+ if (!row)
29
+ throw new Error(`no step ${name}`);
30
+ const text = typeof row.result === "string" ? row.result : row.result.toString("utf8");
31
+ return row.encoding === "json" ? JSON.parse(text) : text;
32
+ }
33
+ finally {
34
+ await db.close();
35
+ }
36
+ }
37
+ function newDb() {
38
+ const dir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "mundane-test-"));
39
+ const path = (0, node_path_1.join)(dir, "task.db");
40
+ return {
41
+ path,
42
+ cleanup: () => {
43
+ try {
44
+ (0, node_fs_1.rmSync)(dir, { recursive: true, force: true });
45
+ }
46
+ catch { }
47
+ },
48
+ };
49
+ }
50
+ (0, node_test_1.test)("three steps execute once; second run uses cache", async () => {
51
+ const { path, cleanup } = newDb();
52
+ try {
53
+ const calls = [];
54
+ const wf = async (ctx) => {
55
+ const a = await ctx.step("a", async () => {
56
+ calls.push("a");
57
+ return 1;
58
+ });
59
+ const b = await ctx.step("b", async () => {
60
+ calls.push("b");
61
+ return { v: a + 1 };
62
+ });
63
+ return b;
64
+ };
65
+ const r1 = await (0, index_1.run)(path, wf);
66
+ strict_1.default.deepEqual(r1, { v: 2 });
67
+ strict_1.default.deepEqual(calls, ["a", "b"]);
68
+ const r2 = await (0, index_1.run)(path, wf);
69
+ strict_1.default.deepEqual(r2, { v: 2 });
70
+ strict_1.default.deepEqual(calls, ["a", "b"]); // not re-called
71
+ }
72
+ finally {
73
+ cleanup();
74
+ }
75
+ });
76
+ (0, node_test_1.test)("step is cached after a body throws", async () => {
77
+ const { path, cleanup } = newDb();
78
+ try {
79
+ await strict_1.default.rejects((0, index_1.run)(path, async (ctx) => {
80
+ await ctx.step("a", async () => 42);
81
+ throw new Error("simulated crash");
82
+ }));
83
+ const calls = [];
84
+ const r = await (0, index_1.run)(path, async (ctx) => {
85
+ const v = await ctx.step("a", async () => {
86
+ calls.push("a");
87
+ return 0;
88
+ });
89
+ const w = await ctx.step("b", async () => {
90
+ calls.push("b");
91
+ return v + 1;
92
+ });
93
+ return w;
94
+ });
95
+ strict_1.default.equal(r, 43);
96
+ strict_1.default.deepEqual(calls, ["b"]);
97
+ }
98
+ finally {
99
+ cleanup();
100
+ }
101
+ });
102
+ (0, node_test_1.test)("invalid step name is rejected", async () => {
103
+ const { path, cleanup } = newDb();
104
+ try {
105
+ await strict_1.default.rejects((0, index_1.run)(path, async (ctx) => {
106
+ await ctx.step("bad name", async () => 1);
107
+ }));
108
+ }
109
+ finally {
110
+ cleanup();
111
+ }
112
+ });
113
+ (0, node_test_1.test)("duplicate step name raises MundaneDuplicateStepError", async () => {
114
+ const { path, cleanup } = newDb();
115
+ try {
116
+ await strict_1.default.rejects((0, index_1.run)(path, async (ctx) => {
117
+ await ctx.step("x", async () => 1);
118
+ await ctx.step("x", async () => 2);
119
+ }), (e) => e instanceof index_1.MundaneDuplicateStepError && e.stepName === "x");
120
+ // First step still committed before the dup raised.
121
+ const names = (await readSteps(path)).map((s) => s.name);
122
+ strict_1.default.deepEqual(names, ["x"]);
123
+ }
124
+ finally {
125
+ cleanup();
126
+ }
127
+ });
128
+ (0, node_test_1.test)("sleep persists wake_at and resumes", async () => {
129
+ const { path, cleanup } = newDb();
130
+ try {
131
+ const t0 = Date.now();
132
+ await (0, index_1.run)(path, async (ctx) => {
133
+ await ctx.sleep("nap", "30ms");
134
+ });
135
+ const e1 = Date.now() - t0;
136
+ strict_1.default.ok(e1 < 500, `first sleep should be quick: ${e1}ms`);
137
+ strict_1.default.ok(e1 >= 25, `first sleep should be ~30ms: ${e1}ms`);
138
+ const t1 = Date.now();
139
+ // second run sees the wake_at in the past, returns immediately
140
+ await (0, index_1.run)(path, async (ctx) => {
141
+ await ctx.sleep("nap", "10s");
142
+ });
143
+ const e2 = Date.now() - t1;
144
+ strict_1.default.ok(e2 < 200, `second sleep should be near-instant: ${e2}ms`);
145
+ }
146
+ finally {
147
+ cleanup();
148
+ }
149
+ });
150
+ (0, node_test_1.test)("sleep resume ignores an invalid duration", async () => {
151
+ const { path, cleanup } = newDb();
152
+ try {
153
+ await (0, index_1.run)(path, async (ctx) => {
154
+ await ctx.sleep("n", "1ms");
155
+ });
156
+ // Resume ignores the duration arg, so an invalid string must not throw.
157
+ await (0, index_1.run)(path, async (ctx) => {
158
+ await ctx.sleep("n", "not-a-duration");
159
+ });
160
+ }
161
+ finally {
162
+ cleanup();
163
+ }
164
+ });
165
+ (0, node_test_1.test)("sequential runs on the same file do not spuriously lock", async () => {
166
+ const { path, cleanup } = newDb();
167
+ try {
168
+ // Back-to-back runs: if release() resolved before the helper actually
169
+ // dropped the flock, a later run would throw MundaneLockedError.
170
+ for (let i = 0; i < 5; i++) {
171
+ await (0, index_1.run)(path, async (ctx) => ctx.step(`s${i}`, async () => i));
172
+ }
173
+ const done = (await readSteps(path)).filter((s) => s.status === "done");
174
+ strict_1.default.equal(done.length, 5);
175
+ }
176
+ finally {
177
+ cleanup();
178
+ }
179
+ });
180
+ (0, node_test_1.test)("non-JSON value raises MundaneSerializationError", async () => {
181
+ const { path, cleanup } = newDb();
182
+ try {
183
+ await strict_1.default.rejects((0, index_1.run)(path, async (ctx) => {
184
+ await ctx.step("a", async () => new Date());
185
+ }), (e) => e instanceof index_1.MundaneSerializationError);
186
+ }
187
+ finally {
188
+ cleanup();
189
+ }
190
+ });
191
+ (0, node_test_1.test)("locked task throws MundaneLockedError", async () => {
192
+ const { path, cleanup } = newDb();
193
+ try {
194
+ let unblock;
195
+ const blocker = new Promise((r) => {
196
+ unblock = r;
197
+ });
198
+ const first = (0, index_1.run)(path, async (ctx) => {
199
+ await ctx.step("init", async () => 0);
200
+ await blocker;
201
+ return 0;
202
+ });
203
+ // Give the first run time to acquire the lock.
204
+ await new Promise((r) => setTimeout(r, 200));
205
+ await strict_1.default.rejects((0, index_1.run)(path, async () => { }), (e) => e instanceof index_1.MundaneLockedError);
206
+ unblock();
207
+ await first;
208
+ }
209
+ finally {
210
+ cleanup();
211
+ }
212
+ });
213
+ (0, node_test_1.test)("failed step re-runs on next invocation", async () => {
214
+ const { path, cleanup } = newDb();
215
+ try {
216
+ await strict_1.default.rejects((0, index_1.run)(path, async (ctx) => {
217
+ await ctx.step("s", async () => {
218
+ throw new Error("boom");
219
+ });
220
+ }));
221
+ // A failed step is not cached; it must re-run.
222
+ const calls = [];
223
+ const r = await (0, index_1.run)(path, async (ctx) => ctx.step("s", async () => {
224
+ calls.push("s");
225
+ return 7;
226
+ }));
227
+ strict_1.default.equal(r, 7);
228
+ strict_1.default.deepEqual(calls, ["s"]);
229
+ }
230
+ finally {
231
+ cleanup();
232
+ }
233
+ });
234
+ (0, node_test_1.test)("failed row is reset to pending during re-run", async () => {
235
+ const { path, cleanup } = newDb();
236
+ try {
237
+ await strict_1.default.rejects((0, index_1.run)(path, async (ctx) => {
238
+ await ctx.step("s", async () => {
239
+ throw new Error("boom");
240
+ });
241
+ }));
242
+ let midStatus = "";
243
+ let midError = "stale";
244
+ await (0, index_1.run)(path, async (ctx) => ctx.step("s", async () => {
245
+ const db = await (0, db_1.openDb)(path, { readonly: true });
246
+ const row = (await db.get("SELECT status, error FROM mundane_steps WHERE name='s'"));
247
+ await db.close();
248
+ midStatus = row.status;
249
+ midError = row.error;
250
+ return 7;
251
+ }));
252
+ strict_1.default.equal(midStatus, "pending");
253
+ strict_1.default.equal(midError, null);
254
+ }
255
+ finally {
256
+ cleanup();
257
+ }
258
+ });
259
+ (0, node_test_1.test)("pending step re-runs on resume", async () => {
260
+ const { path, cleanup } = newDb();
261
+ try {
262
+ // Bootstrap, then leave a pending row behind (simulating a crash mid-step).
263
+ await (0, index_1.run)(path, async () => { });
264
+ const db = await (0, db_1.openDb)(path);
265
+ await db.run("INSERT INTO mundane_steps (name, kind, encoding, result, status, started_at) " +
266
+ "VALUES ('s', 'step', 'json', NULL, 'pending', ?)", [new Date().toISOString()]);
267
+ await db.close();
268
+ const calls = [];
269
+ const r = await (0, index_1.run)(path, async (ctx) => ctx.step("s", async () => {
270
+ calls.push("s");
271
+ return 5;
272
+ }));
273
+ strict_1.default.equal(r, 5);
274
+ strict_1.default.deepEqual(calls, ["s"]);
275
+ }
276
+ finally {
277
+ cleanup();
278
+ }
279
+ });
280
+ (0, node_test_1.test)("steps are committed and decode round-trips", async () => {
281
+ const { path, cleanup } = newDb();
282
+ try {
283
+ await (0, index_1.run)(path, async (ctx) => {
284
+ await ctx.step("a", async () => ({ x: 1 }));
285
+ await ctx.step("b", async () => "hello");
286
+ });
287
+ const done = (await readSteps(path)).filter((s) => s.status === "done");
288
+ strict_1.default.equal(done.length, 2);
289
+ strict_1.default.deepEqual(await readResult(path, "a"), { x: 1 });
290
+ strict_1.default.equal(await readResult(path, "b"), "hello");
291
+ }
292
+ finally {
293
+ cleanup();
294
+ }
295
+ });
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "mundane-sdk",
3
+ "version": "0.0.1",
4
+ "description": "Tiny durable-execution: one workflow run is one SQLite file.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/paulbellamy/mundane.git"
9
+ },
10
+ "main": "dist/src/index.js",
11
+ "types": "dist/src/index.d.ts",
12
+ "files": [
13
+ "dist/"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc -p .",
17
+ "test": "node --test dist/test/"
18
+ },
19
+ "dependencies": {
20
+ "fs-ext": "^2.1.1",
21
+ "sqlite3": "^6.0.1"
22
+ },
23
+ "devDependencies": {
24
+ "@biomejs/biome": "^2.4.15",
25
+ "@types/fs-ext": "^2.0.3",
26
+ "@types/node": "^20.0.0",
27
+ "typescript": "^5.4.0"
28
+ },
29
+ "engines": {
30
+ "node": ">=18"
31
+ }
32
+ }