@davesheffer/hunch 1.24.0 → 1.26.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/README.md +125 -133
- package/dist/cli/index.js +2 -0
- package/dist/cli/serve.js +64 -0
- package/dist/client/state.js +48 -0
- package/dist/core/provenance.js +43 -0
- package/dist/core/stateContract.js +250 -0
- package/dist/core/stateRecords.js +150 -0
- package/dist/core/types.js +16 -29
- package/dist/integrations/gitignore.js +8 -0
- package/dist/mcp/server.js +75 -0
- package/dist/serve/app.js +186 -0
- package/dist/serve/config.js +121 -0
- package/dist/serve/writelock.js +116 -0
- package/dist/store/changeLedger.js +96 -0
- package/dist/store/jsonStore.js +5 -0
- package/dist/store/stateBinding.js +398 -0
- package/package.json +2 -1
- package/server.json +2 -2
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hunch serve` — the HTTP binding of nuryel.state/1, and the served product's partition host.
|
|
3
|
+
*
|
|
4
|
+
* Folded in from Hunch Memory: bind 127.0.0.1 (the caller's job; never expose a port), a
|
|
5
|
+
* bearer token that resolves the PRINCIPAL (the body never names one — grants come from the
|
|
6
|
+
* config, never from the caller), problem+json errors, a body limit, and a cross-process
|
|
7
|
+
* write lock per partition so a stdio MCP process on the same store cannot race a write.
|
|
8
|
+
*
|
|
9
|
+
* Every rule lives in src/store/stateBinding.ts; this file only maps HTTP to it:
|
|
10
|
+
* GET /nuryel/v1/capabilities → capabilities of the partition named by ?scope=kind:id (default: first granted)
|
|
11
|
+
* POST /nuryel/v1/read → readState
|
|
12
|
+
* POST /nuryel/v1/write → writeState (under the partition's write lock)
|
|
13
|
+
* POST /nuryel/v1/subscribe → subscribeState
|
|
14
|
+
* Request bodies are the contract's request schemas minus `schema` and `principal`.
|
|
15
|
+
*/
|
|
16
|
+
import { createServer } from "node:http";
|
|
17
|
+
import { HunchStore } from "../store/hunchStore.js";
|
|
18
|
+
import { hunchPaths } from "../core/paths.js";
|
|
19
|
+
import { flushCapture } from "../integrations/sync.js";
|
|
20
|
+
import { StateRefusal, capabilities, readState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
21
|
+
import { STATE_READ_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ScopeSchema, scopePath } from "../core/stateContract.js";
|
|
22
|
+
import { partitionFor, resolvePrincipal } from "./config.js";
|
|
23
|
+
import { WriteLockTimeout, withWriteLock } from "./writelock.js";
|
|
24
|
+
import { HUNCH_VERSION } from "../core/version.js";
|
|
25
|
+
export const BODY_LIMIT_BYTES = 1024 * 1024;
|
|
26
|
+
export const PROBLEM_TYPE = "https://www.hunchmemory.com/problems/nuryel.state/1/";
|
|
27
|
+
export class HttpProblem extends Error {
|
|
28
|
+
status;
|
|
29
|
+
code;
|
|
30
|
+
extra;
|
|
31
|
+
constructor(status, code, message, extra = {}) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.status = status;
|
|
34
|
+
this.code = code;
|
|
35
|
+
this.extra = extra;
|
|
36
|
+
this.name = "HttpProblem";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const problem = (status, code, message, extra = {}) => new HttpProblem(status, code, message, extra);
|
|
40
|
+
/** Refusal code → HTTP status. Outside-grants is 403 (the partition list is not secret to a
|
|
41
|
+
* principal that holds a token), conflicts are 409, identity/derivation errors 422. */
|
|
42
|
+
const REFUSAL_STATUS = {
|
|
43
|
+
"outside-grants": 403, unsupported: 400, malformed: 400, identity: 422, conflict: 409, idempotency: 409, "no-partition-home": 404,
|
|
44
|
+
};
|
|
45
|
+
function bearerToken(req) {
|
|
46
|
+
const header = req.headers.authorization;
|
|
47
|
+
if (!header)
|
|
48
|
+
return undefined;
|
|
49
|
+
const trimmed = header.trim();
|
|
50
|
+
if (!/^bearer /i.test(trimmed))
|
|
51
|
+
return undefined;
|
|
52
|
+
const token = trimmed.slice("bearer ".length).trim();
|
|
53
|
+
return token || undefined;
|
|
54
|
+
}
|
|
55
|
+
async function readBody(req) {
|
|
56
|
+
const declared = req.headers["content-length"];
|
|
57
|
+
if (declared !== undefined) {
|
|
58
|
+
if (Array.isArray(declared) || !/^[0-9]+$/.test(declared))
|
|
59
|
+
throw problem(400, "invalid-body", "content-length is not valid");
|
|
60
|
+
if (Number(declared) > BODY_LIMIT_BYTES)
|
|
61
|
+
throw problem(413, "body-too-large", `body exceeds ${BODY_LIMIT_BYTES} bytes`);
|
|
62
|
+
}
|
|
63
|
+
const chunks = [];
|
|
64
|
+
let size = 0;
|
|
65
|
+
for await (const chunk of req) {
|
|
66
|
+
size += chunk.length;
|
|
67
|
+
if (size > BODY_LIMIT_BYTES)
|
|
68
|
+
throw problem(413, "body-too-large", `body exceeds ${BODY_LIMIT_BYTES} bytes`);
|
|
69
|
+
chunks.push(chunk);
|
|
70
|
+
}
|
|
71
|
+
if (size === 0)
|
|
72
|
+
return {};
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
75
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
76
|
+
throw new Error("not an object");
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw problem(400, "invalid-body", "body is not a JSON object");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function parseScopeParam(value) {
|
|
84
|
+
if (!value)
|
|
85
|
+
return undefined;
|
|
86
|
+
const m = /^([a-z]+):(.+)$/.exec(value);
|
|
87
|
+
const parsed = m ? ScopeSchema.safeParse({ kind: m[1], id: m[2] }) : null;
|
|
88
|
+
if (!parsed?.success)
|
|
89
|
+
throw problem(400, "invalid-scope", "scope must be kind:id");
|
|
90
|
+
return parsed.data;
|
|
91
|
+
}
|
|
92
|
+
export function createServeApp(config, opts = {}) {
|
|
93
|
+
const version = opts.version ?? HUNCH_VERSION;
|
|
94
|
+
const stores = new Map();
|
|
95
|
+
const storeFor = (scope) => {
|
|
96
|
+
const partition = partitionFor(config, scope);
|
|
97
|
+
if (!partition)
|
|
98
|
+
throw problem(404, "no-partition", `this server does not serve ${scopePath(scope)}`);
|
|
99
|
+
let store = stores.get(partition.root);
|
|
100
|
+
if (!store) {
|
|
101
|
+
store = opts.openStore ? opts.openStore(partition.root) : new HunchStore(hunchPaths(partition.root));
|
|
102
|
+
store.json.ensureDirs();
|
|
103
|
+
stores.set(partition.root, store);
|
|
104
|
+
}
|
|
105
|
+
return { store, root: partition.root };
|
|
106
|
+
};
|
|
107
|
+
/** Grants are checked here AND inside the binding — the binding's check is the contract's, this
|
|
108
|
+
* one refuses before a store is even opened. */
|
|
109
|
+
const requireScope = (principal, body) => {
|
|
110
|
+
const parsed = ScopeSchema.safeParse(body.scope);
|
|
111
|
+
if (!parsed.success)
|
|
112
|
+
throw problem(400, "invalid-scope", "scope is required: { kind, id }");
|
|
113
|
+
if (!principal.grants.some((g) => scopePath(g) === scopePath(parsed.data)))
|
|
114
|
+
throw problem(403, "outside-grants", `scope ${scopePath(parsed.data)} is outside the principal's grants`);
|
|
115
|
+
return parsed.data;
|
|
116
|
+
};
|
|
117
|
+
const send = (res, status, payload, type = "application/json") => {
|
|
118
|
+
const text = JSON.stringify(payload);
|
|
119
|
+
res.writeHead(status, { "content-type": `${type}; charset=utf-8`, "content-length": Buffer.byteLength(text), "cache-control": "no-store", "x-hunch-version": version });
|
|
120
|
+
res.end(text);
|
|
121
|
+
};
|
|
122
|
+
const sendProblem = (res, p) => {
|
|
123
|
+
send(res, p.status, { type: `${PROBLEM_TYPE}${p.code}`, title: p.code, status: p.status, detail: p.message, ...p.extra }, "application/problem+json");
|
|
124
|
+
};
|
|
125
|
+
const server = createServer(async (req, res) => {
|
|
126
|
+
try {
|
|
127
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
128
|
+
if (url.pathname === "/nuryel/v1/health" && req.method === "GET") {
|
|
129
|
+
return send(res, 200, { ok: true, version, protocol: "nuryel.state/1", partitions: config.partitions.map((p) => scopePath(p.scope)) });
|
|
130
|
+
}
|
|
131
|
+
const principal = resolvePrincipal(config, bearerToken(req));
|
|
132
|
+
if (!principal)
|
|
133
|
+
throw problem(401, "unauthorized", "a valid bearer token is required");
|
|
134
|
+
if (url.pathname === "/nuryel/v1/capabilities" && req.method === "GET") {
|
|
135
|
+
const scope = parseScopeParam(url.searchParams.get("scope")) ?? principal.grants[0];
|
|
136
|
+
if (!principal.grants.some((g) => scopePath(g) === scopePath(scope)))
|
|
137
|
+
throw problem(403, "outside-grants", `scope ${scopePath(scope)} is outside the principal's grants`);
|
|
138
|
+
const { store } = storeFor(scope);
|
|
139
|
+
return send(res, 200, { ...capabilities(store), principal: { id: principal.id, kind: principal.kind, grants: principal.grants } });
|
|
140
|
+
}
|
|
141
|
+
if (req.method !== "POST")
|
|
142
|
+
throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}`);
|
|
143
|
+
const body = await readBody(req);
|
|
144
|
+
// The body never names the principal: the token did.
|
|
145
|
+
delete body.principal;
|
|
146
|
+
delete body.schema;
|
|
147
|
+
if (url.pathname === "/nuryel/v1/read") {
|
|
148
|
+
const scope = requireScope(principal, body);
|
|
149
|
+
const { store } = storeFor(scope);
|
|
150
|
+
const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, principal, ...body });
|
|
151
|
+
return send(res, 200, { ...response, envelope });
|
|
152
|
+
}
|
|
153
|
+
if (url.pathname === "/nuryel/v1/write") {
|
|
154
|
+
const scope = requireScope(principal, body);
|
|
155
|
+
const { store, root } = storeFor(scope);
|
|
156
|
+
const result = await withWriteLock(hunchPaths(root).hunch, () => writeState(store, { schema: STATE_WRITE_VERSION, principal, ...body }, {
|
|
157
|
+
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message),
|
|
158
|
+
}));
|
|
159
|
+
return send(res, result.outcome === "created" ? 201 : 200, result);
|
|
160
|
+
}
|
|
161
|
+
if (url.pathname === "/nuryel/v1/subscribe") {
|
|
162
|
+
const scope = requireScope(principal, body);
|
|
163
|
+
const { store } = storeFor(scope);
|
|
164
|
+
return send(res, 200, subscribeState(store, { schema: STATE_SUBSCRIBE_VERSION, principal, ...body }));
|
|
165
|
+
}
|
|
166
|
+
throw problem(404, "not-found", `${url.pathname} is not a nuryel.state/1 route`);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error instanceof HttpProblem)
|
|
170
|
+
return sendProblem(res, error);
|
|
171
|
+
if (error instanceof StateRefusal)
|
|
172
|
+
return sendProblem(res, problem(REFUSAL_STATUS[error.code], error.code, error.message, error.conflict ? { conflict: error.conflict } : {}));
|
|
173
|
+
if (error instanceof WriteLockTimeout)
|
|
174
|
+
return sendProblem(res, problem(503, "write-lock-timeout", error.message, { "retry-after": 1 }));
|
|
175
|
+
if (error && typeof error === "object" && error.name === "ZodError") {
|
|
176
|
+
const issues = (error.issues ?? []).map((i) => `${i.path.join(".") || "request"}: ${i.message}`);
|
|
177
|
+
return sendProblem(res, problem(400, "malformed", `request is malformed: ${issues.join("; ")}`, { issues }));
|
|
178
|
+
}
|
|
179
|
+
return sendProblem(res, problem(500, "internal", error.message));
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
const closeStores = () => { for (const store of stores.values())
|
|
183
|
+
store.close(); stores.clear(); };
|
|
184
|
+
return Object.assign(server, { closeStores });
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=app.js.map
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hunch serve` configuration — the served partitions and the principals allowed in.
|
|
3
|
+
*
|
|
4
|
+
* A partition is a directory holding a `.hunch/` store whose `.hunch/partition.json`
|
|
5
|
+
* names the scope it IS (organization / team / user / repository). A principal is a
|
|
6
|
+
* bearer token (stored as a sha256 hash — plaintext is printed once by `serve init`)
|
|
7
|
+
* bound to a principal id, kind and grants. The request body never carries a principal:
|
|
8
|
+
* the token resolves it, and grants are decided from this file, never from the caller.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, resolve } from "node:path";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
15
|
+
import { ScopeSchema, scopePath } from "../core/stateContract.js";
|
|
16
|
+
export const SERVE_CONFIG_VERSION = "nuryel.serve-config/1";
|
|
17
|
+
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$/;
|
|
18
|
+
export const PartitionConfigSchema = z.object({
|
|
19
|
+
scope: ScopeSchema,
|
|
20
|
+
/** Directory whose `.hunch/` holds the partition. Relative paths resolve from the config file. */
|
|
21
|
+
root: z.string().min(1),
|
|
22
|
+
}).strict();
|
|
23
|
+
export const PrincipalConfigSchema = z.object({
|
|
24
|
+
id: z.string().regex(TOKEN),
|
|
25
|
+
kind: z.enum(["human", "agent", "service"]),
|
|
26
|
+
display: z.string().max(256).optional(),
|
|
27
|
+
/** sha256 hex of the bearer token. */
|
|
28
|
+
token_sha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
29
|
+
grants: z.array(ScopeSchema).min(1).max(64),
|
|
30
|
+
}).strict();
|
|
31
|
+
export const ServeConfigSchema = z.object({
|
|
32
|
+
schema: z.literal(SERVE_CONFIG_VERSION),
|
|
33
|
+
port: z.number().int().min(1).max(65535).default(7474),
|
|
34
|
+
partitions: z.array(PartitionConfigSchema).min(1).max(256),
|
|
35
|
+
principals: z.array(PrincipalConfigSchema).max(1024).default([]),
|
|
36
|
+
}).strict();
|
|
37
|
+
export function hashToken(token) {
|
|
38
|
+
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
39
|
+
}
|
|
40
|
+
export function mintToken() {
|
|
41
|
+
return `nyt_${randomBytes(24).toString("base64url")}`;
|
|
42
|
+
}
|
|
43
|
+
export function readServeConfig(file) {
|
|
44
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
45
|
+
const config = ServeConfigSchema.parse(raw);
|
|
46
|
+
const base = dirname(resolve(file));
|
|
47
|
+
const partitions = config.partitions.map((p) => ({ ...p, root: resolve(base, p.root) }));
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
for (const p of partitions) {
|
|
50
|
+
const key = scopePath(p.scope);
|
|
51
|
+
if (seen.has(key))
|
|
52
|
+
throw new Error(`serve config lists partition ${key} twice`);
|
|
53
|
+
seen.add(key);
|
|
54
|
+
}
|
|
55
|
+
const ids = new Set();
|
|
56
|
+
for (const p of config.principals) {
|
|
57
|
+
if (ids.has(p.id))
|
|
58
|
+
throw new Error(`serve config lists principal ${p.id} twice`);
|
|
59
|
+
ids.add(p.id);
|
|
60
|
+
for (const g of p.grants)
|
|
61
|
+
if (!seen.has(scopePath(g)))
|
|
62
|
+
throw new Error(`principal ${p.id} is granted ${scopePath(g)}, which this server does not serve`);
|
|
63
|
+
}
|
|
64
|
+
return { ...config, partitions, file: resolve(file) };
|
|
65
|
+
}
|
|
66
|
+
export function writeServeConfig(file, config) {
|
|
67
|
+
mkdirSync(dirname(resolve(file)), { recursive: true });
|
|
68
|
+
writeFileAtomic(resolve(file), JSON.stringify(ServeConfigSchema.parse(config), null, 2) + "\n");
|
|
69
|
+
}
|
|
70
|
+
/** Constant-time token → principal. Undefined for a missing or unknown token. */
|
|
71
|
+
export function resolvePrincipal(config, token) {
|
|
72
|
+
if (!token)
|
|
73
|
+
return undefined;
|
|
74
|
+
const hash = Buffer.from(hashToken(token), "hex");
|
|
75
|
+
for (const p of config.principals) {
|
|
76
|
+
const candidate = Buffer.from(p.token_sha256, "hex");
|
|
77
|
+
if (candidate.length === hash.length && timingSafeEqual(candidate, hash)) {
|
|
78
|
+
return { id: p.id, kind: p.kind, ...(p.display ? { display: p.display } : {}), grants: p.grants };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
export function partitionFor(config, scope) {
|
|
84
|
+
return config.partitions.find((p) => scopePath(p.scope) === scopePath(scope));
|
|
85
|
+
}
|
|
86
|
+
/** `serve init`: ensure a partition directory declares its scope, and add a principal
|
|
87
|
+
* with a freshly minted token. Idempotent for the partition; a principal id that
|
|
88
|
+
* already exists gets a NEW token (rotation), the old one stops working. */
|
|
89
|
+
export function initServeConfig(opts) {
|
|
90
|
+
const file = resolve(opts.file);
|
|
91
|
+
const existing = existsSync(file) ? readServeConfig(file) : null;
|
|
92
|
+
const root = resolve(opts.root);
|
|
93
|
+
const hunchDir = resolve(root, ".hunch");
|
|
94
|
+
mkdirSync(hunchDir, { recursive: true });
|
|
95
|
+
const partitionFile = resolve(hunchDir, "partition.json");
|
|
96
|
+
if (existsSync(partitionFile)) {
|
|
97
|
+
const declared = ScopeSchema.parse(JSON.parse(readFileSync(partitionFile, "utf8")));
|
|
98
|
+
if (scopePath(declared) !== scopePath(opts.scope))
|
|
99
|
+
throw new Error(`${root} already declares partition ${scopePath(declared)}, not ${scopePath(opts.scope)}`);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
writeFileAtomic(partitionFile, JSON.stringify(opts.scope, null, 2) + "\n");
|
|
103
|
+
}
|
|
104
|
+
const manifest = resolve(hunchDir, "manifest.json");
|
|
105
|
+
if (!existsSync(manifest))
|
|
106
|
+
writeFileAtomic(manifest, JSON.stringify({ schema_version: 3 }, null, 2) + "\n");
|
|
107
|
+
const partitions = existing ? existing.partitions.filter((p) => scopePath(p.scope) !== scopePath(opts.scope)) : [];
|
|
108
|
+
const partition = { scope: opts.scope, root };
|
|
109
|
+
partitions.push(partition);
|
|
110
|
+
let principals = existing?.principals ?? [];
|
|
111
|
+
let token = null;
|
|
112
|
+
if (opts.principal) {
|
|
113
|
+
token = mintToken();
|
|
114
|
+
const grants = opts.principal.grants?.length ? opts.principal.grants : [opts.scope];
|
|
115
|
+
principals = [...principals.filter((p) => p.id !== opts.principal.id), { id: opts.principal.id, kind: opts.principal.kind, token_sha256: hashToken(token), grants }];
|
|
116
|
+
}
|
|
117
|
+
const config = { schema: SERVE_CONFIG_VERSION, port: opts.port ?? existing?.port ?? 7474, partitions, principals };
|
|
118
|
+
writeServeConfig(file, config);
|
|
119
|
+
return { config, token, partition };
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process write lock, one per partition root — folded in from Hunch Memory
|
|
3
|
+
* (src/service/writelock.ts there), where it held the one-live-decision-per-topic
|
|
4
|
+
* guarantee across the HTTP process and a stdio MCP process sharing a store.
|
|
5
|
+
*
|
|
6
|
+
* The state binding reads the ledger and the incumbent records, then writes; two
|
|
7
|
+
* processes can both complete the read before either writes. An in-process mutex
|
|
8
|
+
* does not span processes, so the lock is a file created with `open(path, "wx")` —
|
|
9
|
+
* atomic on POSIX and Windows. A holder that dies leaves the file behind; a lock is
|
|
10
|
+
* stealable once its owner is provably gone (pid dead on the same host) or older
|
|
11
|
+
* than any plausible write.
|
|
12
|
+
*/
|
|
13
|
+
import { hostname } from "node:os";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
import { closeSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
/** Longest a write may hold the lock before another process may steal it: a put plus a
|
|
18
|
+
* git commit and push — seconds, not minutes. */
|
|
19
|
+
export const STALE_AFTER_MS = 60_000;
|
|
20
|
+
/** Longest a writer waits before giving up and telling the caller. */
|
|
21
|
+
export const ACQUIRE_TIMEOUT_MS = 10_000;
|
|
22
|
+
const POLL_MS = 25;
|
|
23
|
+
export class WriteLockTimeout extends Error {
|
|
24
|
+
path;
|
|
25
|
+
heldBy;
|
|
26
|
+
waitedMs;
|
|
27
|
+
constructor(path, heldBy, waitedMs) {
|
|
28
|
+
super(heldBy
|
|
29
|
+
? `write lock on ${path} held by pid ${heldBy.pid} on ${heldBy.host} since ${heldBy.at}; waited ${waitedMs}ms`
|
|
30
|
+
: `write lock on ${path} not acquired within ${waitedMs}ms`);
|
|
31
|
+
this.path = path;
|
|
32
|
+
this.heldBy = heldBy;
|
|
33
|
+
this.waitedMs = waitedMs;
|
|
34
|
+
this.name = "WriteLockTimeout";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function writeLockPath(hunchDir) {
|
|
38
|
+
return join(hunchDir, "write.lock");
|
|
39
|
+
}
|
|
40
|
+
function readOwner(path) {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
43
|
+
if (typeof parsed.pid !== "number" || typeof parsed.nonce !== "string")
|
|
44
|
+
return undefined;
|
|
45
|
+
return { pid: parsed.pid, host: typeof parsed.host === "string" ? parsed.host : "", nonce: parsed.nonce, at: typeof parsed.at === "string" ? parsed.at : "" };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined; // released between our open and this read, or truncated by a crash — age decides
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function pidAlive(pid) {
|
|
52
|
+
try {
|
|
53
|
+
process.kill(pid, 0);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return error.code === "EPERM"; // exists, another user — alive for our purposes
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function stealable(path, owner, now) {
|
|
61
|
+
let ageMs;
|
|
62
|
+
try {
|
|
63
|
+
ageMs = now - statSync(path).mtimeMs;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (owner && owner.host === hostname() && !pidAlive(owner.pid))
|
|
69
|
+
return true;
|
|
70
|
+
return ageMs > STALE_AFTER_MS;
|
|
71
|
+
}
|
|
72
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
73
|
+
/** Hold the partition's lock across `fn` (sync or async); released even if it throws. */
|
|
74
|
+
export async function withWriteLock(hunchDir, fn, opts = {}) {
|
|
75
|
+
const path = writeLockPath(hunchDir);
|
|
76
|
+
const timeoutMs = opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS;
|
|
77
|
+
const nonce = randomBytes(8).toString("hex");
|
|
78
|
+
const started = Date.now();
|
|
79
|
+
let fd;
|
|
80
|
+
let lastOwner;
|
|
81
|
+
for (;;) {
|
|
82
|
+
try {
|
|
83
|
+
fd = openSync(path, "wx");
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error.code !== "EEXIST")
|
|
88
|
+
throw error;
|
|
89
|
+
lastOwner = readOwner(path);
|
|
90
|
+
if (stealable(path, lastOwner, Date.now())) {
|
|
91
|
+
rmSync(path, { force: true });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (Date.now() - started >= timeoutMs)
|
|
95
|
+
throw new WriteLockTimeout(path, lastOwner, Date.now() - started);
|
|
96
|
+
await sleep(POLL_MS);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const owner = { pid: process.pid, host: hostname(), nonce, at: new Date().toISOString() };
|
|
100
|
+
try {
|
|
101
|
+
writeSync(fd, JSON.stringify(owner));
|
|
102
|
+
}
|
|
103
|
+
catch { /* held by existence; identity is a courtesy */ }
|
|
104
|
+
closeSync(fd);
|
|
105
|
+
try {
|
|
106
|
+
return await fn();
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
// Only remove a lock that is still ours: if we overran STALE_AFTER_MS and were stolen
|
|
110
|
+
// from, the file now protects another writer.
|
|
111
|
+
const current = readOwner(path);
|
|
112
|
+
if (!current || current.nonce === nonce)
|
|
113
|
+
rmSync(path, { force: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=writelock.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-scope change ledger behind `subscribe` — nuryel.ledger/1.
|
|
3
|
+
*
|
|
4
|
+
* One JSON file per scope partition under `<hunch dir>/changes/`, git-native like every
|
|
5
|
+
* other record, appended atomically (con_902759b3dc). It holds the strictly ordered
|
|
6
|
+
* ChangeEvent stream for that scope (seq 1, 2, 3 … with no gaps) plus the idempotency
|
|
7
|
+
* table the write verb replays from. Seq is per scope, assigned by the writer in the
|
|
8
|
+
* home the scope lives in; a scope has exactly ONE ledger, so there is never a second
|
|
9
|
+
* sequence to reconcile. Merging two clones' ledgers for the same scope is not decided
|
|
10
|
+
* here (see docs/nuryel-state-contract.md, "Not decided here").
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
17
|
+
import { ChangeEventSchema, ScopeSchema, scopePath } from "../core/stateContract.js";
|
|
18
|
+
export const LEDGER_SCHEMA_VERSION = "nuryel.ledger/1";
|
|
19
|
+
export const CHANGES_DIR = "changes";
|
|
20
|
+
const IdempotencyEntrySchema = z.object({
|
|
21
|
+
record_id: z.string().min(1),
|
|
22
|
+
record_hash: z.string(),
|
|
23
|
+
facet: z.string(),
|
|
24
|
+
seq: z.number().int().nonnegative(),
|
|
25
|
+
at: z.string(),
|
|
26
|
+
}).strict();
|
|
27
|
+
export const LedgerSchema = z.object({
|
|
28
|
+
schema: z.literal(LEDGER_SCHEMA_VERSION),
|
|
29
|
+
scope: ScopeSchema,
|
|
30
|
+
head_seq: z.number().int().nonnegative(),
|
|
31
|
+
events: z.array(ChangeEventSchema),
|
|
32
|
+
idempotency: z.record(z.string(), IdempotencyEntrySchema).default({}),
|
|
33
|
+
}).strict();
|
|
34
|
+
/** Scope ids may carry `:` `@` `+` (safe in the contract, not in every file system), so
|
|
35
|
+
* the file name is the sanitized id plus a short hash of the exact id — readable AND
|
|
36
|
+
* collision-free. The scope inside the file is authoritative, the name is a locator. */
|
|
37
|
+
export function ledgerFile(hunchDir, scope) {
|
|
38
|
+
const safe = scope.id.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80);
|
|
39
|
+
const tag = createHash("sha256").update(scopePath(scope)).digest("hex").slice(0, 8);
|
|
40
|
+
return join(hunchDir, CHANGES_DIR, `${scope.kind}-${safe}-${tag}.json`);
|
|
41
|
+
}
|
|
42
|
+
export function emptyLedger(scope) {
|
|
43
|
+
return { schema: LEDGER_SCHEMA_VERSION, scope, head_seq: 0, events: [], idempotency: {} };
|
|
44
|
+
}
|
|
45
|
+
/** Read the ledger for a scope; a missing file is an empty ledger, a corrupt one is an
|
|
46
|
+
* error (never silently treated as empty — that would restart the sequence). */
|
|
47
|
+
export function readLedger(hunchDir, scope) {
|
|
48
|
+
const file = ledgerFile(hunchDir, scope);
|
|
49
|
+
if (!existsSync(file))
|
|
50
|
+
return emptyLedger(scope);
|
|
51
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
52
|
+
const ledger = LedgerSchema.parse(raw);
|
|
53
|
+
if (scopePath(ledger.scope) !== scopePath(scope))
|
|
54
|
+
throw new Error(`ledger ${file} belongs to scope ${scopePath(ledger.scope)}, not ${scopePath(scope)}`);
|
|
55
|
+
let expected = 1;
|
|
56
|
+
for (const event of ledger.events) {
|
|
57
|
+
if (event.seq !== expected)
|
|
58
|
+
throw new Error(`ledger ${file} is not contiguous at seq ${event.seq} (expected ${expected})`);
|
|
59
|
+
expected += 1;
|
|
60
|
+
}
|
|
61
|
+
if (ledger.head_seq !== ledger.events.length)
|
|
62
|
+
throw new Error(`ledger ${file} head_seq ${ledger.head_seq} disagrees with ${ledger.events.length} events`);
|
|
63
|
+
return ledger;
|
|
64
|
+
}
|
|
65
|
+
export function writeLedger(hunchDir, ledger) {
|
|
66
|
+
const file = ledgerFile(hunchDir, ledger.scope);
|
|
67
|
+
mkdirSync(join(hunchDir, CHANGES_DIR), { recursive: true });
|
|
68
|
+
writeFileAtomic(file, JSON.stringify(LedgerSchema.parse(ledger), null, 2) + "\n");
|
|
69
|
+
}
|
|
70
|
+
/** Append events (in order) and remember an idempotency key in ONE atomic write, so a
|
|
71
|
+
* crash between "record written" and "event appended" can be detected by the next
|
|
72
|
+
* writer (record present, ledger silent) rather than producing a half-applied write. */
|
|
73
|
+
export function appendChanges(hunchDir, scope, changes, idempotency, at = new Date().toISOString()) {
|
|
74
|
+
const ledger = readLedger(hunchDir, scope);
|
|
75
|
+
const appended = [];
|
|
76
|
+
for (const change of changes) {
|
|
77
|
+
const event = ChangeEventSchema.parse({ schema: "nuryel.state.subscribe/1", seq: ledger.head_seq + 1, at, scope, ...change });
|
|
78
|
+
ledger.events.push(event);
|
|
79
|
+
ledger.head_seq = event.seq;
|
|
80
|
+
appended.push(event);
|
|
81
|
+
}
|
|
82
|
+
if (idempotency) {
|
|
83
|
+
ledger.idempotency[idempotency.key] = { ...idempotency.entry, seq: ledger.head_seq, at };
|
|
84
|
+
}
|
|
85
|
+
writeLedger(hunchDir, ledger);
|
|
86
|
+
return appended;
|
|
87
|
+
}
|
|
88
|
+
/** The latest seq that touched a record in this scope, or 0 when the ledger never saw it. */
|
|
89
|
+
export function latestSeqFor(ledger, recordId) {
|
|
90
|
+
for (let i = ledger.events.length - 1; i >= 0; i--) {
|
|
91
|
+
if (ledger.events[i].record_id === recordId)
|
|
92
|
+
return ledger.events[i].seq;
|
|
93
|
+
}
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=changeLedger.js.map
|
package/dist/store/jsonStore.js
CHANGED
|
@@ -19,6 +19,11 @@ const SINGLE_FILE = {
|
|
|
19
19
|
// so the canonical array avoids lossy filename encoding while keeping Git diffs
|
|
20
20
|
// deterministic through id sorting.
|
|
21
21
|
resources: "index.json",
|
|
22
|
+
// nuryel.state/1 entities carry the same kind-qualified ids (customer:<name>), and
|
|
23
|
+
// relationships share edge identity; both are index-file stored for the same reason.
|
|
24
|
+
// Layout only: migration-before-validation (con_947c578b2c) is untouched.
|
|
25
|
+
entities: "index.json",
|
|
26
|
+
relationships: "index.json",
|
|
22
27
|
};
|
|
23
28
|
const encode = (v) => JSON.stringify(v, null, 2) + "\n";
|
|
24
29
|
// Sleep primitive for the single-file RMW lock's bounded spin (issue #35);
|