@bendyline/gezel-sdk 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/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/checks.d.ts +733 -0
- package/dist/checks.js +1867 -0
- package/dist/index.d.ts +589 -0
- package/dist/index.js +231 -0
- package/dist/stores.d.ts +188 -0
- package/dist/stores.js +287 -0
- package/dist/types-cYfcp6_8.d.ts +282 -0
- package/package.json +70 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// src/rpc.ts
|
|
2
|
+
import { readFileSync, writeSync } from "fs";
|
|
3
|
+
import { Socket } from "net";
|
|
4
|
+
var DEFAULT_INIT = {
|
|
5
|
+
input: void 0,
|
|
6
|
+
runId: "",
|
|
7
|
+
projectId: "",
|
|
8
|
+
engagementMode: "off",
|
|
9
|
+
engagementFlags: { llmAllowed: false }
|
|
10
|
+
};
|
|
11
|
+
function readInitSync() {
|
|
12
|
+
if (process.env.GEZEL_SCRIPT_RUNTIME !== "1") {
|
|
13
|
+
return DEFAULT_INIT;
|
|
14
|
+
}
|
|
15
|
+
let raw = "";
|
|
16
|
+
try {
|
|
17
|
+
raw = readFileSync(0, "utf8");
|
|
18
|
+
} catch {
|
|
19
|
+
return DEFAULT_INIT;
|
|
20
|
+
}
|
|
21
|
+
if (!raw) return DEFAULT_INIT;
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(raw.trim());
|
|
24
|
+
} catch {
|
|
25
|
+
return DEFAULT_INIT;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
var RpcClient = class {
|
|
29
|
+
init;
|
|
30
|
+
nextId = 0;
|
|
31
|
+
pending = /* @__PURE__ */ new Map();
|
|
32
|
+
buffer = "";
|
|
33
|
+
started = false;
|
|
34
|
+
_socket = null;
|
|
35
|
+
constructor() {
|
|
36
|
+
this.init = readInitSync();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Lazy-create the read side. We use `net.Socket` for reads (it gives
|
|
40
|
+
* us proper 'data' / 'end' events on POSIX socketpairs and Windows
|
|
41
|
+
* named pipes) but **never** for writes — see `writeFrame` below.
|
|
42
|
+
*/
|
|
43
|
+
get socket() {
|
|
44
|
+
if (!this._socket) {
|
|
45
|
+
this._socket = new Socket({ fd: 3, readable: true, writable: true });
|
|
46
|
+
this._socket.setEncoding("utf8");
|
|
47
|
+
this._socket.unref();
|
|
48
|
+
}
|
|
49
|
+
return this._socket;
|
|
50
|
+
}
|
|
51
|
+
updatePendingRef() {
|
|
52
|
+
if (!this._socket) return;
|
|
53
|
+
if (this.pending.size > 0) this._socket.ref();
|
|
54
|
+
else this._socket.unref();
|
|
55
|
+
}
|
|
56
|
+
ensureStarted() {
|
|
57
|
+
if (this.started) return;
|
|
58
|
+
this.started = true;
|
|
59
|
+
this.socket.on("data", (chunk) => {
|
|
60
|
+
this.buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
61
|
+
let newline = this.buffer.indexOf("\n");
|
|
62
|
+
while (newline >= 0) {
|
|
63
|
+
const line = this.buffer.slice(0, newline);
|
|
64
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
65
|
+
if (line.length > 0) {
|
|
66
|
+
this.handleMessage(line);
|
|
67
|
+
}
|
|
68
|
+
newline = this.buffer.indexOf("\n");
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
this.socket.on("error", (err) => {
|
|
72
|
+
for (const pending of this.pending.values()) {
|
|
73
|
+
pending.reject(err instanceof Error ? err : new Error(String(err)));
|
|
74
|
+
}
|
|
75
|
+
this.pending.clear();
|
|
76
|
+
});
|
|
77
|
+
this.socket.on("end", () => {
|
|
78
|
+
const err = new Error("script RPC channel closed before all requests completed");
|
|
79
|
+
for (const pending of this.pending.values()) {
|
|
80
|
+
pending.reject(err);
|
|
81
|
+
}
|
|
82
|
+
this.pending.clear();
|
|
83
|
+
});
|
|
84
|
+
this.updatePendingRef();
|
|
85
|
+
}
|
|
86
|
+
handleMessage(line) {
|
|
87
|
+
let parsed;
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(line);
|
|
90
|
+
} catch {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (typeof parsed.id !== "number") return;
|
|
94
|
+
const pending = this.pending.get(parsed.id);
|
|
95
|
+
if (!pending) return;
|
|
96
|
+
this.pending.delete(parsed.id);
|
|
97
|
+
this.updatePendingRef();
|
|
98
|
+
if (parsed.error) {
|
|
99
|
+
const err = new Error(parsed.error.message);
|
|
100
|
+
err.code = parsed.error.code;
|
|
101
|
+
pending.reject(err);
|
|
102
|
+
} else {
|
|
103
|
+
pending.resolve(parsed.result);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async call(method, params) {
|
|
107
|
+
const id = ++this.nextId;
|
|
108
|
+
const req = { id, method };
|
|
109
|
+
if (params !== void 0) req.params = params;
|
|
110
|
+
const payload = `${JSON.stringify(req)}
|
|
111
|
+
`;
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
this.pending.set(id, { resolve, reject });
|
|
114
|
+
try {
|
|
115
|
+
writeFrame(payload);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
this.pending.delete(id);
|
|
118
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
this.ensureStarted();
|
|
122
|
+
this.updatePendingRef();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
notify(method, params) {
|
|
126
|
+
const msg = { method };
|
|
127
|
+
if (params !== void 0) msg.params = params;
|
|
128
|
+
writeFrame(`${JSON.stringify(msg)}
|
|
129
|
+
`);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
function writeFrame(payload) {
|
|
133
|
+
const buf = Buffer.from(payload, "utf8");
|
|
134
|
+
let offset = 0;
|
|
135
|
+
while (offset < buf.length) {
|
|
136
|
+
offset += writeSync(3, buf, offset, buf.length - offset);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/index.ts
|
|
141
|
+
function defineScript(meta) {
|
|
142
|
+
return meta;
|
|
143
|
+
}
|
|
144
|
+
var rpc = new RpcClient();
|
|
145
|
+
var outputStamped = false;
|
|
146
|
+
var gezel = {
|
|
147
|
+
get input() {
|
|
148
|
+
return rpc.init.input ?? {};
|
|
149
|
+
},
|
|
150
|
+
output(value) {
|
|
151
|
+
if (outputStamped) {
|
|
152
|
+
throw new Error("gezel.output() called more than once \u2014 a script may only stamp one output");
|
|
153
|
+
}
|
|
154
|
+
outputStamped = true;
|
|
155
|
+
rpc.notify("script.output", { value });
|
|
156
|
+
},
|
|
157
|
+
log(...args) {
|
|
158
|
+
rpc.notify("script.log", { args });
|
|
159
|
+
process.stderr.write(
|
|
160
|
+
`${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
|
|
161
|
+
`
|
|
162
|
+
);
|
|
163
|
+
},
|
|
164
|
+
fs: {
|
|
165
|
+
read: (path) => rpc.call("fs.read", { path }),
|
|
166
|
+
write: (path, content) => rpc.call("fs.write", { path, content }),
|
|
167
|
+
list: (path) => rpc.call("fs.list", { path }),
|
|
168
|
+
listAll: () => rpc.call("fs.listAll"),
|
|
169
|
+
stat: (path) => rpc.call("fs.stat", { path }),
|
|
170
|
+
rm: (path) => rpc.call("fs.rm", { path }),
|
|
171
|
+
mkdir: (path) => rpc.call("fs.mkdir", { path }),
|
|
172
|
+
rename: (from, to) => rpc.call("fs.rename", { from, to })
|
|
173
|
+
},
|
|
174
|
+
artifacts: {
|
|
175
|
+
read: (path) => rpc.call("artifact.read", { path }),
|
|
176
|
+
write: (path, content) => rpc.call("artifact.write", { path, content }),
|
|
177
|
+
list: (prefix) => rpc.call("artifact.list", { prefix }),
|
|
178
|
+
delete: (path) => rpc.call("artifact.delete", { path })
|
|
179
|
+
},
|
|
180
|
+
documents: {
|
|
181
|
+
read: (name) => rpc.call("document.read", { name }),
|
|
182
|
+
write: (name, content) => rpc.call("document.write", { name, content }),
|
|
183
|
+
list: () => rpc.call("document.list"),
|
|
184
|
+
delete: (name) => rpc.call("document.delete", { name })
|
|
185
|
+
},
|
|
186
|
+
task: {
|
|
187
|
+
get: (ref) => rpc.call("task.get", { ref }),
|
|
188
|
+
steps: (ref) => rpc.call("task.steps", { ref }),
|
|
189
|
+
currentStep: (ref) => rpc.call("task.currentStep", { ref }),
|
|
190
|
+
update: (ref, patch) => rpc.call("task.update", { ref, patch }),
|
|
191
|
+
advance: (ref, nextPhaseName) => rpc.call("task.advance", { ref, nextPhaseName }),
|
|
192
|
+
writeNotes: (ref, content, phaseId) => rpc.call("task.writeNotes", { ref, content, phaseId }),
|
|
193
|
+
appendNote: (ref, text, stepId) => rpc.call("task.appendNote", { ref, text, stepId }),
|
|
194
|
+
readNotes: (ref, phaseId) => rpc.call("task.readNotes", { ref, phaseId }),
|
|
195
|
+
deleteNote: (ref, noteId) => rpc.call("task.deleteNote", { ref, noteId }),
|
|
196
|
+
create: (req) => rpc.call("task.create", { req })
|
|
197
|
+
},
|
|
198
|
+
memory: {
|
|
199
|
+
search: (query) => rpc.call("memory.search", { query }),
|
|
200
|
+
save: (text, meta) => rpc.call("memory.save", { text, meta })
|
|
201
|
+
},
|
|
202
|
+
llm: {
|
|
203
|
+
oneShot: (prompt, opts) => rpc.call("llm.oneShot", { prompt, opts })
|
|
204
|
+
},
|
|
205
|
+
mcp: {
|
|
206
|
+
call: (tool, args) => rpc.call("mcp.call", { tool, args })
|
|
207
|
+
},
|
|
208
|
+
http: {
|
|
209
|
+
request: (url, opts) => rpc.call("http.request", {
|
|
210
|
+
url,
|
|
211
|
+
...opts?.method ? { method: opts.method } : {},
|
|
212
|
+
...opts?.body !== void 0 ? { body: opts.body } : {},
|
|
213
|
+
...opts?.headers ? { headers: opts.headers } : {}
|
|
214
|
+
}),
|
|
215
|
+
authed: (url, opts) => rpc.call("http.authed", {
|
|
216
|
+
url,
|
|
217
|
+
credential: opts.credential,
|
|
218
|
+
...opts.method ? { method: opts.method } : {},
|
|
219
|
+
...opts.body !== void 0 ? { body: opts.body } : {},
|
|
220
|
+
...opts.headers ? { headers: opts.headers } : {},
|
|
221
|
+
...opts.authScheme ? { authScheme: opts.authScheme } : {}
|
|
222
|
+
})
|
|
223
|
+
},
|
|
224
|
+
script: {
|
|
225
|
+
run: (name, input) => rpc.call("script.run", { name, input })
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
export {
|
|
229
|
+
defineScript,
|
|
230
|
+
gezel
|
|
231
|
+
};
|
package/dist/stores.d.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@bendyline/gezel-sdk/stores` — small persistent-store helpers for
|
|
3
|
+
* sandboxed scripts: an append-only event log with derived stats
|
|
4
|
+
* (`logStore`) and a record roster with staged status transitions
|
|
5
|
+
* (`rosterStore`).
|
|
6
|
+
*
|
|
7
|
+
* These are the shared "data that accrues" primitives project-type
|
|
8
|
+
* scripts wrap (a language trainer's practice log, a job hunt's
|
|
9
|
+
* application pipeline) so each type doesn't hand-roll its own JSON
|
|
10
|
+
* handling, streak math, and transition history.
|
|
11
|
+
*
|
|
12
|
+
* Like `./checks`, this entry takes its dependencies structurally
|
|
13
|
+
* (`StoreFs`, satisfied by `gezel.fs` or `gezel.artifacts`) instead of
|
|
14
|
+
* importing the main entry's stdin-initialized singleton — the bundle
|
|
15
|
+
* stays standalone for vendoring into sandbox scratch dirs.
|
|
16
|
+
*
|
|
17
|
+
* Persistence rules:
|
|
18
|
+
* - A failed read is treated as "no store yet" (the structural interface
|
|
19
|
+
* can't distinguish missing from unreadable; `gezel.fs.read` throws on
|
|
20
|
+
* both). The file is created lazily on the first write.
|
|
21
|
+
* - Corrupt JSON or an unknown `version` throws — user data is never
|
|
22
|
+
* silently reset.
|
|
23
|
+
* - Files are written pretty-printed with a trailing newline so they stay
|
|
24
|
+
* pleasant to `cat` and diff, per the files-all-the-way-down principle.
|
|
25
|
+
* - Calls are single read-modify-write; cross-run races are out of scope
|
|
26
|
+
* for a single-user local tool.
|
|
27
|
+
*
|
|
28
|
+
* Scripts using these helpers on the workspace need
|
|
29
|
+
* `requires: ['workspace.read', 'workspace.write']`.
|
|
30
|
+
*/
|
|
31
|
+
interface StoreFs {
|
|
32
|
+
/** Read a file's text content. Expected to throw when the file is absent. */
|
|
33
|
+
read(path: string): Promise<string>;
|
|
34
|
+
write(path: string, content: string): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
interface StoreOptions {
|
|
37
|
+
/** Injectable clock for deterministic tests. Defaults to the real time. */
|
|
38
|
+
now?: () => string;
|
|
39
|
+
}
|
|
40
|
+
interface LogEvent {
|
|
41
|
+
at: string;
|
|
42
|
+
kind: string;
|
|
43
|
+
note?: string;
|
|
44
|
+
data?: Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
interface LogStats {
|
|
47
|
+
total: number;
|
|
48
|
+
byKind: Record<string, number>;
|
|
49
|
+
/** Consecutive UTC days with events, ending at the latest event's day. */
|
|
50
|
+
streakDays: number;
|
|
51
|
+
/** Distinct UTC days with at least one event. */
|
|
52
|
+
daysActive: number;
|
|
53
|
+
firstAt: string | null;
|
|
54
|
+
lastAt: string | null;
|
|
55
|
+
}
|
|
56
|
+
interface LogStoreFile {
|
|
57
|
+
version: 1;
|
|
58
|
+
events: LogEvent[];
|
|
59
|
+
}
|
|
60
|
+
interface LogStore {
|
|
61
|
+
append(event: {
|
|
62
|
+
kind: string;
|
|
63
|
+
note?: string;
|
|
64
|
+
data?: Record<string, unknown>;
|
|
65
|
+
at?: string;
|
|
66
|
+
}): Promise<LogEvent>;
|
|
67
|
+
/**
|
|
68
|
+
* Events matching the filter, oldest first. `since` is an inclusive ISO
|
|
69
|
+
* lower bound on `at`; `limit` keeps the most recent N matches.
|
|
70
|
+
*/
|
|
71
|
+
list(filter?: {
|
|
72
|
+
kind?: string;
|
|
73
|
+
since?: string;
|
|
74
|
+
limit?: number;
|
|
75
|
+
}): Promise<LogEvent[]>;
|
|
76
|
+
stats(): Promise<LogStats>;
|
|
77
|
+
}
|
|
78
|
+
declare function logStore(fs: StoreFs, path: string, opts?: StoreOptions): LogStore;
|
|
79
|
+
interface RosterTransition {
|
|
80
|
+
at: string;
|
|
81
|
+
/** Null on the record's creation entry. `from === to` marks a note-only touch. */
|
|
82
|
+
from: string | null;
|
|
83
|
+
to: string;
|
|
84
|
+
note?: string;
|
|
85
|
+
}
|
|
86
|
+
interface RosterRecord {
|
|
87
|
+
id: string;
|
|
88
|
+
status: string;
|
|
89
|
+
fields: Record<string, unknown>;
|
|
90
|
+
createdAt: string;
|
|
91
|
+
updatedAt: string;
|
|
92
|
+
history: RosterTransition[];
|
|
93
|
+
}
|
|
94
|
+
interface RosterStoreFile {
|
|
95
|
+
version: 1;
|
|
96
|
+
stages: string[];
|
|
97
|
+
records: RosterRecord[];
|
|
98
|
+
}
|
|
99
|
+
interface RosterStore {
|
|
100
|
+
/** Add a record. Defaults: status = first stage; id = `<idPrefix>-<n>`. */
|
|
101
|
+
add(input?: {
|
|
102
|
+
id?: string;
|
|
103
|
+
status?: string;
|
|
104
|
+
fields?: Record<string, unknown>;
|
|
105
|
+
note?: string;
|
|
106
|
+
}): Promise<RosterRecord>;
|
|
107
|
+
get(id: string): Promise<RosterRecord | null>;
|
|
108
|
+
list(filter?: {
|
|
109
|
+
status?: string;
|
|
110
|
+
}): Promise<RosterRecord[]>;
|
|
111
|
+
/** Merge fields and/or record a note without changing status. */
|
|
112
|
+
update(id: string, patch: {
|
|
113
|
+
fields?: Record<string, unknown>;
|
|
114
|
+
note?: string;
|
|
115
|
+
}): Promise<RosterRecord>;
|
|
116
|
+
/** Move a record to another stage, appending to its history. */
|
|
117
|
+
transition(id: string, to: string, opts?: {
|
|
118
|
+
note?: string;
|
|
119
|
+
}): Promise<RosterRecord>;
|
|
120
|
+
/** Records per status; every declared stage present, zero-filled. */
|
|
121
|
+
counts(): Promise<Record<string, number>>;
|
|
122
|
+
}
|
|
123
|
+
interface RosterStoreOptions extends StoreOptions {
|
|
124
|
+
/** Stage vocabulary used to initialize a new store file. An existing file's own `stages` win. */
|
|
125
|
+
stages: [string, ...string[]];
|
|
126
|
+
/** Prefix for generated record ids. Defaults to `'rec'`. */
|
|
127
|
+
idPrefix?: string;
|
|
128
|
+
}
|
|
129
|
+
declare function rosterStore(fs: StoreFs, path: string, opts: RosterStoreOptions): RosterStore;
|
|
130
|
+
interface LedgerEntry {
|
|
131
|
+
id: string;
|
|
132
|
+
at: string;
|
|
133
|
+
/** Always integer cents — float money drifts. */
|
|
134
|
+
amountCents: number;
|
|
135
|
+
kind: 'in' | 'out';
|
|
136
|
+
category: string;
|
|
137
|
+
note?: string;
|
|
138
|
+
data?: Record<string, unknown>;
|
|
139
|
+
}
|
|
140
|
+
interface LedgerBucket {
|
|
141
|
+
inCents: number;
|
|
142
|
+
outCents: number;
|
|
143
|
+
netCents: number;
|
|
144
|
+
}
|
|
145
|
+
interface LedgerTotals extends LedgerBucket {
|
|
146
|
+
count: number;
|
|
147
|
+
byCategory: Record<string, LedgerBucket>;
|
|
148
|
+
/** Keyed by UTC month, `YYYY-MM`. */
|
|
149
|
+
byMonth: Record<string, LedgerBucket>;
|
|
150
|
+
}
|
|
151
|
+
interface LedgerStoreFile {
|
|
152
|
+
version: 1;
|
|
153
|
+
currency: string;
|
|
154
|
+
entries: LedgerEntry[];
|
|
155
|
+
}
|
|
156
|
+
interface LedgerStore {
|
|
157
|
+
add(entry: {
|
|
158
|
+
amountCents: number;
|
|
159
|
+
kind: 'in' | 'out';
|
|
160
|
+
category: string;
|
|
161
|
+
note?: string;
|
|
162
|
+
at?: string;
|
|
163
|
+
data?: Record<string, unknown>;
|
|
164
|
+
}): Promise<LedgerEntry>;
|
|
165
|
+
/** Entries matching the filter, oldest first. `month` is `YYYY-MM` (UTC). */
|
|
166
|
+
list(filter?: {
|
|
167
|
+
kind?: 'in' | 'out';
|
|
168
|
+
category?: string;
|
|
169
|
+
month?: string;
|
|
170
|
+
limit?: number;
|
|
171
|
+
}): Promise<LedgerEntry[]>;
|
|
172
|
+
/** Rollups over the (optionally filtered) entries. */
|
|
173
|
+
totals(filter?: {
|
|
174
|
+
month?: string;
|
|
175
|
+
category?: string;
|
|
176
|
+
}): Promise<LedgerTotals>;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Money in/out with categories and monthly rollups — budgets, invoices,
|
|
180
|
+
* event budgets, pledge drives. Same persistence rules as the other
|
|
181
|
+
* stores; amounts are validated positive integers (the `kind` carries
|
|
182
|
+
* the sign).
|
|
183
|
+
*/
|
|
184
|
+
declare function ledgerStore(fs: StoreFs, path: string, opts?: StoreOptions & {
|
|
185
|
+
currency?: string;
|
|
186
|
+
}): LedgerStore;
|
|
187
|
+
|
|
188
|
+
export { type LedgerBucket, type LedgerEntry, type LedgerStore, type LedgerStoreFile, type LedgerTotals, type LogEvent, type LogStats, type LogStore, type LogStoreFile, type RosterRecord, type RosterStore, type RosterStoreFile, type RosterStoreOptions, type RosterTransition, type StoreFs, type StoreOptions, ledgerStore, logStore, rosterStore };
|
package/dist/stores.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// src/stores.ts
|
|
2
|
+
function defaultNow() {
|
|
3
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
4
|
+
}
|
|
5
|
+
function parseStoreFile(path, raw) {
|
|
6
|
+
let parsed;
|
|
7
|
+
try {
|
|
8
|
+
parsed = JSON.parse(raw);
|
|
9
|
+
} catch (err) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
`Store file ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}). Fix or remove the file; it will not be overwritten automatically.`
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
const version = parsed.version;
|
|
15
|
+
if (version !== 1) {
|
|
16
|
+
throw new Error(`Store file ${path} has unsupported version ${String(version)} (expected 1).`);
|
|
17
|
+
}
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
async function loadStoreFile(fs, path) {
|
|
21
|
+
let raw;
|
|
22
|
+
try {
|
|
23
|
+
raw = await fs.read(path);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return parseStoreFile(path, raw);
|
|
28
|
+
}
|
|
29
|
+
async function saveStoreFile(fs, path, value) {
|
|
30
|
+
await fs.write(path, `${JSON.stringify(value, null, 2)}
|
|
31
|
+
`);
|
|
32
|
+
}
|
|
33
|
+
function utcDay(iso) {
|
|
34
|
+
return iso.slice(0, 10);
|
|
35
|
+
}
|
|
36
|
+
function logStore(fs, path, opts = {}) {
|
|
37
|
+
const now = opts.now ?? defaultNow;
|
|
38
|
+
async function load() {
|
|
39
|
+
return await loadStoreFile(fs, path) ?? { version: 1, events: [] };
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
async append(event) {
|
|
43
|
+
const file = await load();
|
|
44
|
+
const entry = {
|
|
45
|
+
at: event.at ?? now(),
|
|
46
|
+
kind: event.kind,
|
|
47
|
+
...event.note !== void 0 ? { note: event.note } : {},
|
|
48
|
+
...event.data !== void 0 ? { data: event.data } : {}
|
|
49
|
+
};
|
|
50
|
+
file.events.push(entry);
|
|
51
|
+
await saveStoreFile(fs, path, file);
|
|
52
|
+
return entry;
|
|
53
|
+
},
|
|
54
|
+
async list(filter = {}) {
|
|
55
|
+
const file = await load();
|
|
56
|
+
let events = file.events;
|
|
57
|
+
if (filter.kind !== void 0) events = events.filter((e) => e.kind === filter.kind);
|
|
58
|
+
if (filter.since !== void 0) events = events.filter((e) => e.at >= filter.since);
|
|
59
|
+
if (filter.limit !== void 0 && filter.limit >= 0 && events.length > filter.limit) {
|
|
60
|
+
events = events.slice(events.length - filter.limit);
|
|
61
|
+
}
|
|
62
|
+
return events;
|
|
63
|
+
},
|
|
64
|
+
async stats() {
|
|
65
|
+
const file = await load();
|
|
66
|
+
const byKind = {};
|
|
67
|
+
const days = /* @__PURE__ */ new Set();
|
|
68
|
+
let firstAt = null;
|
|
69
|
+
let lastAt = null;
|
|
70
|
+
for (const e of file.events) {
|
|
71
|
+
byKind[e.kind] = (byKind[e.kind] ?? 0) + 1;
|
|
72
|
+
days.add(utcDay(e.at));
|
|
73
|
+
if (firstAt === null || e.at < firstAt) firstAt = e.at;
|
|
74
|
+
if (lastAt === null || e.at > lastAt) lastAt = e.at;
|
|
75
|
+
}
|
|
76
|
+
let streakDays = 0;
|
|
77
|
+
if (lastAt !== null) {
|
|
78
|
+
let cursor = Date.parse(`${utcDay(lastAt)}T00:00:00Z`);
|
|
79
|
+
while (days.has(new Date(cursor).toISOString().slice(0, 10))) {
|
|
80
|
+
streakDays += 1;
|
|
81
|
+
cursor -= 24 * 60 * 60 * 1e3;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
total: file.events.length,
|
|
86
|
+
byKind,
|
|
87
|
+
streakDays,
|
|
88
|
+
daysActive: days.size,
|
|
89
|
+
firstAt,
|
|
90
|
+
lastAt
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function rosterStore(fs, path, opts) {
|
|
96
|
+
const now = opts.now ?? defaultNow;
|
|
97
|
+
const idPrefix = opts.idPrefix ?? "rec";
|
|
98
|
+
async function load() {
|
|
99
|
+
return await loadStoreFile(fs, path) ?? {
|
|
100
|
+
version: 1,
|
|
101
|
+
stages: [...opts.stages],
|
|
102
|
+
records: []
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function mustFind(file, id) {
|
|
106
|
+
const record = file.records.find((r) => r.id === id);
|
|
107
|
+
if (!record) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`No record '${id}' in ${path}. Known ids: ${file.records.map((r) => r.id).join(", ") || "(none)"}`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return record;
|
|
113
|
+
}
|
|
114
|
+
function assertStage(file, status) {
|
|
115
|
+
if (!file.stages.includes(status)) {
|
|
116
|
+
throw new Error(`Unknown stage '${status}' for ${path}. Stages: ${file.stages.join(", ")}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function nextId(file) {
|
|
120
|
+
let max = 0;
|
|
121
|
+
const pattern = new RegExp(`^${idPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-(\\d+)$`);
|
|
122
|
+
for (const r of file.records) {
|
|
123
|
+
const m = pattern.exec(r.id);
|
|
124
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
125
|
+
}
|
|
126
|
+
return `${idPrefix}-${max + 1}`;
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
async add(input = {}) {
|
|
130
|
+
const file = await load();
|
|
131
|
+
const status = input.status ?? file.stages[0];
|
|
132
|
+
assertStage(file, status);
|
|
133
|
+
const id = input.id ?? nextId(file);
|
|
134
|
+
if (file.records.some((r) => r.id === id)) {
|
|
135
|
+
throw new Error(`Record '${id}' already exists in ${path}.`);
|
|
136
|
+
}
|
|
137
|
+
const at = now();
|
|
138
|
+
const record = {
|
|
139
|
+
id,
|
|
140
|
+
status,
|
|
141
|
+
fields: input.fields ?? {},
|
|
142
|
+
createdAt: at,
|
|
143
|
+
updatedAt: at,
|
|
144
|
+
history: [
|
|
145
|
+
{ at, from: null, to: status, ...input.note !== void 0 ? { note: input.note } : {} }
|
|
146
|
+
]
|
|
147
|
+
};
|
|
148
|
+
file.records.push(record);
|
|
149
|
+
await saveStoreFile(fs, path, file);
|
|
150
|
+
return record;
|
|
151
|
+
},
|
|
152
|
+
async get(id) {
|
|
153
|
+
const file = await load();
|
|
154
|
+
return file.records.find((r) => r.id === id) ?? null;
|
|
155
|
+
},
|
|
156
|
+
async list(filter = {}) {
|
|
157
|
+
const file = await load();
|
|
158
|
+
return filter.status === void 0 ? file.records : file.records.filter((r) => r.status === filter.status);
|
|
159
|
+
},
|
|
160
|
+
async update(id, patch) {
|
|
161
|
+
const file = await load();
|
|
162
|
+
const record = mustFind(file, id);
|
|
163
|
+
if (patch.fields) record.fields = { ...record.fields, ...patch.fields };
|
|
164
|
+
const at = now();
|
|
165
|
+
record.updatedAt = at;
|
|
166
|
+
if (patch.note !== void 0) {
|
|
167
|
+
record.history.push({ at, from: record.status, to: record.status, note: patch.note });
|
|
168
|
+
}
|
|
169
|
+
await saveStoreFile(fs, path, file);
|
|
170
|
+
return record;
|
|
171
|
+
},
|
|
172
|
+
async transition(id, to, transitionOpts = {}) {
|
|
173
|
+
const file = await load();
|
|
174
|
+
const record = mustFind(file, id);
|
|
175
|
+
assertStage(file, to);
|
|
176
|
+
const at = now();
|
|
177
|
+
record.history.push({
|
|
178
|
+
at,
|
|
179
|
+
from: record.status,
|
|
180
|
+
to,
|
|
181
|
+
...transitionOpts.note !== void 0 ? { note: transitionOpts.note } : {}
|
|
182
|
+
});
|
|
183
|
+
record.status = to;
|
|
184
|
+
record.updatedAt = at;
|
|
185
|
+
await saveStoreFile(fs, path, file);
|
|
186
|
+
return record;
|
|
187
|
+
},
|
|
188
|
+
async counts() {
|
|
189
|
+
const file = await load();
|
|
190
|
+
const counts = {};
|
|
191
|
+
for (const stage of file.stages) counts[stage] = 0;
|
|
192
|
+
for (const r of file.records) counts[r.status] = (counts[r.status] ?? 0) + 1;
|
|
193
|
+
return counts;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function emptyBucket() {
|
|
198
|
+
return { inCents: 0, outCents: 0, netCents: 0 };
|
|
199
|
+
}
|
|
200
|
+
function addToBucket(bucket, entry) {
|
|
201
|
+
if (entry.kind === "in") bucket.inCents += entry.amountCents;
|
|
202
|
+
else bucket.outCents += entry.amountCents;
|
|
203
|
+
bucket.netCents = bucket.inCents - bucket.outCents;
|
|
204
|
+
}
|
|
205
|
+
function ledgerStore(fs, path, opts = {}) {
|
|
206
|
+
const now = opts.now ?? defaultNow;
|
|
207
|
+
async function load() {
|
|
208
|
+
return await loadStoreFile(fs, path) ?? {
|
|
209
|
+
version: 1,
|
|
210
|
+
currency: opts.currency ?? "USD",
|
|
211
|
+
entries: []
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function nextId(file) {
|
|
215
|
+
let max = 0;
|
|
216
|
+
for (const e of file.entries) {
|
|
217
|
+
const m = /^ledger-(\d+)$/.exec(e.id);
|
|
218
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
219
|
+
}
|
|
220
|
+
return `ledger-${max + 1}`;
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
async add(entry) {
|
|
224
|
+
if (!Number.isInteger(entry.amountCents) || entry.amountCents <= 0) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`amountCents must be a positive integer (got ${String(entry.amountCents)}); the 'kind' carries the sign.`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (entry.kind !== "in" && entry.kind !== "out") {
|
|
230
|
+
throw new Error(`kind must be 'in' or 'out' (got '${String(entry.kind)}').`);
|
|
231
|
+
}
|
|
232
|
+
if (!entry.category || typeof entry.category !== "string") {
|
|
233
|
+
throw new Error("category is required.");
|
|
234
|
+
}
|
|
235
|
+
const file = await load();
|
|
236
|
+
const record = {
|
|
237
|
+
id: nextId(file),
|
|
238
|
+
at: entry.at ?? now(),
|
|
239
|
+
amountCents: entry.amountCents,
|
|
240
|
+
kind: entry.kind,
|
|
241
|
+
category: entry.category,
|
|
242
|
+
...entry.note !== void 0 ? { note: entry.note } : {},
|
|
243
|
+
...entry.data !== void 0 ? { data: entry.data } : {}
|
|
244
|
+
};
|
|
245
|
+
file.entries.push(record);
|
|
246
|
+
await saveStoreFile(fs, path, file);
|
|
247
|
+
return record;
|
|
248
|
+
},
|
|
249
|
+
async list(filter = {}) {
|
|
250
|
+
const file = await load();
|
|
251
|
+
let entries = file.entries;
|
|
252
|
+
if (filter.kind) entries = entries.filter((e) => e.kind === filter.kind);
|
|
253
|
+
if (filter.category) entries = entries.filter((e) => e.category === filter.category);
|
|
254
|
+
if (filter.month) entries = entries.filter((e) => e.at.slice(0, 7) === filter.month);
|
|
255
|
+
if (filter.limit !== void 0 && filter.limit >= 0 && entries.length > filter.limit) {
|
|
256
|
+
entries = entries.slice(entries.length - filter.limit);
|
|
257
|
+
}
|
|
258
|
+
return entries;
|
|
259
|
+
},
|
|
260
|
+
async totals(filter = {}) {
|
|
261
|
+
const file = await load();
|
|
262
|
+
let entries = file.entries;
|
|
263
|
+
if (filter.month) entries = entries.filter((e) => e.at.slice(0, 7) === filter.month);
|
|
264
|
+
if (filter.category) entries = entries.filter((e) => e.category === filter.category);
|
|
265
|
+
const totals = {
|
|
266
|
+
...emptyBucket(),
|
|
267
|
+
count: entries.length,
|
|
268
|
+
byCategory: {},
|
|
269
|
+
byMonth: {}
|
|
270
|
+
};
|
|
271
|
+
for (const e of entries) {
|
|
272
|
+
addToBucket(totals, e);
|
|
273
|
+
totals.byCategory[e.category] ??= emptyBucket();
|
|
274
|
+
addToBucket(totals.byCategory[e.category], e);
|
|
275
|
+
const month = e.at.slice(0, 7);
|
|
276
|
+
totals.byMonth[month] ??= emptyBucket();
|
|
277
|
+
addToBucket(totals.byMonth[month], e);
|
|
278
|
+
}
|
|
279
|
+
return totals;
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
export {
|
|
284
|
+
ledgerStore,
|
|
285
|
+
logStore,
|
|
286
|
+
rosterStore
|
|
287
|
+
};
|