decision-gate 0.1.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/lib/state.mjs ADDED
@@ -0,0 +1,146 @@
1
+ import { closeSync, constants, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, chmodSync, statSync, appendFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { StateError } from "./errors.mjs";
7
+
8
+ export const clock = { now: Date.now, sleep: (ms, signal) => delay(Math.min(ms, 2147483647), undefined, { signal }) };
9
+
10
+ // Holders never keep a lock across network or sleep, so a lock this old belongs to a stopped
11
+ // process or a reused PID, never to a slow live one
12
+ const STALE_MS = 30000;
13
+
14
+ export function privateDir(dir) {
15
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
16
+ const stat = statSync(dir);
17
+ if (!stat.isDirectory() || (process.getuid && stat.uid !== process.getuid())) throw new StateError("local state directory must be owned by this user");
18
+ if ((stat.mode & 0o777) !== 0o700) chmodSync(dir, 0o700);
19
+ }
20
+
21
+ export function readJson(file, fallback) {
22
+ try { return JSON.parse(readFileSync(file, "utf8")); }
23
+ catch (error) {
24
+ if (error.code === "ENOENT") return fallback;
25
+ throw new StateError("cannot read local state; refusing to spend");
26
+ }
27
+ }
28
+
29
+ // Replaces a file atomically, so readers see the old or the new contents and never a torn write
30
+ export function replaceFile(file, text) {
31
+ const temp = `${file}.${randomUUID()}.tmp`;
32
+ let fd;
33
+ try {
34
+ fd = openSync(temp, "wx", 0o600);
35
+ writeFileSync(fd, text);
36
+ fsyncSync(fd);
37
+ closeSync(fd);
38
+ fd = undefined;
39
+ renameSync(temp, file);
40
+ } finally {
41
+ if (fd !== undefined) closeSync(fd);
42
+ rmSync(temp, { force: true });
43
+ }
44
+ }
45
+
46
+ export const writeJson = (file, value) => replaceFile(file, JSON.stringify(value));
47
+
48
+ export function appendRecords(file, values) {
49
+ // No-follow keeps a planted symlink from redirecting private records elsewhere
50
+ const fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
51
+ try { appendFileSync(fd, values.map((value) => `${JSON.stringify(value)}\n`).join("")); fsyncSync(fd); }
52
+ finally { closeSync(fd); }
53
+ }
54
+
55
+ export const appendRecord = (file, value) => appendRecords(file, [value]);
56
+
57
+ const held = new Set();
58
+
59
+ // Only an owner on this host can be proved dead; another host's pid means nothing here. `live`
60
+ // holds the tokens this process still owns
61
+ export function dead({ host, pid, token }, live = held) {
62
+ if (host !== os.hostname()) return false;
63
+ // A container restart can hand a killed owner's pid to this process
64
+ if (pid === process.pid) return !live.has(token);
65
+ try { process.kill(pid, 0); return false; }
66
+ catch (error) { return error.code === "ESRCH"; }
67
+ }
68
+
69
+ function ownerOf(lock) {
70
+ try {
71
+ const owner = JSON.parse(readFileSync(path.join(lock, "owner.json"), "utf8"));
72
+ return Number.isInteger(owner?.pid) && typeof owner?.host === "string" && /^[0-9a-f-]{36}$/.test(owner?.token) ? owner : null;
73
+ } catch { return null; }
74
+ }
75
+
76
+ function tryAcquire(lock, token) {
77
+ const temp = `${lock}.${token}.tmp`;
78
+ mkdirSync(temp, { mode: 0o700 });
79
+ try {
80
+ writeJson(path.join(temp, "owner.json"), { host: os.hostname(), pid: process.pid, token });
81
+ // Rename publishes the lock with its owner already inside, so a crash never leaves an ownerless lock
82
+ renameSync(temp, lock);
83
+ return true;
84
+ } catch (error) {
85
+ if (error.code === "EEXIST" || error.code === "ENOTEMPTY") return false;
86
+ throw error;
87
+ } finally { rmSync(temp, { recursive: true, force: true }); }
88
+ }
89
+
90
+ function sweep(lock) {
91
+ const prefix = `${path.basename(lock)}.`;
92
+ for (const name of readdirSync(path.dirname(lock))) {
93
+ if (!name.startsWith(prefix) || !/\.(?:tmp|stale|done)$/.test(name)) continue;
94
+ const full = path.join(path.dirname(lock), name);
95
+ try { if (Date.now() - statSync(full).mtimeMs > STALE_MS) rmSync(full, { recursive: true, force: true }); }
96
+ catch (error) { if (error.code !== "ENOENT") throw error; }
97
+ }
98
+ }
99
+
100
+ function reapIfStale(lock) {
101
+ let stat;
102
+ try { stat = statSync(lock); }
103
+ catch (error) { if (error.code === "ENOENT") return; throw error; }
104
+ const owner = ownerOf(lock);
105
+ if (!(Date.now() - stat.mtimeMs > STALE_MS || (owner && dead(owner)))) return;
106
+ // The tombstone is named after the stale owner and kept for a while, so a second reaper that
107
+ // read the same owner fails to move a newer lock onto it instead of stealing that lock
108
+ const tomb = `${lock}.${owner?.token ?? Math.floor(stat.mtimeMs)}.stale`;
109
+ try {
110
+ renameSync(lock, tomb);
111
+ writeFileSync(path.join(tomb, "reaped"), "");
112
+ } catch (error) { if (!["ENOENT", "EEXIST", "ENOTEMPTY"].includes(error.code)) throw error; }
113
+ sweep(lock);
114
+ }
115
+
116
+ function release(lock, token) {
117
+ if (ownerOf(lock)?.token !== token) return;
118
+ // Moving the lock away first means no other process ever sees it half-deleted
119
+ const done = `${lock}.${token}.done`;
120
+ try { renameSync(lock, done); }
121
+ catch (error) { if (error.code === "ENOENT") return; throw error; }
122
+ rmSync(done, { recursive: true, force: true });
123
+ }
124
+
125
+ // Callbacks are synchronous: no network or sleeps while holding a filesystem lock
126
+ export async function locked(file, job, { signal, time = clock, timeoutMs = 10000 } = {}) {
127
+ privateDir(path.dirname(file));
128
+ const lock = `${file}.lock`;
129
+ const token = randomUUID();
130
+ const start = time.now();
131
+ for (;;) {
132
+ signal?.throwIfAborted();
133
+ if (tryAcquire(lock, token)) { held.add(token); break; }
134
+ reapIfStale(lock);
135
+ if (time.now() - start >= timeoutMs) throw new StateError("local state lock timed out; no request was sent");
136
+ await time.sleep(20, signal);
137
+ }
138
+ try {
139
+ const result = job();
140
+ if (result?.then) throw new StateError("state transaction must be synchronous");
141
+ return result;
142
+ } finally {
143
+ held.delete(token);
144
+ release(lock, token);
145
+ }
146
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "decision-gate",
3
+ "version": "0.1.0",
4
+ "description": "Rate and spend limits for calling TypeSafe Jev from Node: an account-wide 429 pause with backoff, a shared rate limiter, a per-key daily spend ceiling, an answer cache that stores no text, and never-send checks",
5
+ "type": "module",
6
+ "files": [
7
+ "lib/",
8
+ "NOTICE"
9
+ ],
10
+ "engines": {
11
+ "node": ">=22"
12
+ },
13
+ "exports": {
14
+ ".": "./lib/index.mjs"
15
+ },
16
+ "scripts": {
17
+ "test": "node --test",
18
+ "lint": "for f in $(find lib test -name '*.mjs'); do node --check \"$f\" || exit 1; done"
19
+ },
20
+ "license": "Apache-2.0",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/zachlandes/decision-gate.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/zachlandes/decision-gate/issues"
27
+ },
28
+ "homepage": "https://github.com/zachlandes/decision-gate#readme",
29
+ "keywords": [
30
+ "jev",
31
+ "typesafe",
32
+ "decision-model",
33
+ "spend-limit",
34
+ "rate-limit",
35
+ "rate-limiter",
36
+ "429",
37
+ "retry",
38
+ "backoff",
39
+ "cache",
40
+ "budget"
41
+ ],
42
+ "dependencies": {
43
+ "@typesafe-ai/sdk": "0.6.0"
44
+ }
45
+ }