@davesheffer/hunch 1.26.1 → 1.27.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/dist/cli/index.js +10 -2
- package/dist/cli/serve.js +25 -1
- package/dist/client/state.js +1 -0
- package/dist/core/stateContract.js +25 -1
- package/dist/extractors/git.js +5 -1
- package/dist/mcp/server.js +64 -12
- package/dist/serve/app.js +8 -2
- package/dist/serve/config.js +10 -0
- package/dist/store/changeLedger.js +61 -4
- package/dist/store/merge.js +34 -0
- package/dist/store/stateBinding.js +70 -12
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -20,7 +20,7 @@ import { join, relative, dirname, basename, resolve, isAbsolute } from "node:pat
|
|
|
20
20
|
import { tmpdir } from "node:os";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { Command } from "commander";
|
|
23
|
-
import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget } from "../core/paths.js";
|
|
23
|
+
import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget, isDir } from "../core/paths.js";
|
|
24
24
|
import { writeFileAtomic } from "../core/io.js";
|
|
25
25
|
import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
|
|
26
26
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
@@ -5160,8 +5160,16 @@ function printAutoReviewPlan(plan) {
|
|
|
5160
5160
|
program
|
|
5161
5161
|
.command("mcp")
|
|
5162
5162
|
.description("Start the MCP server over stdio (Claude Code connects here).")
|
|
5163
|
-
.
|
|
5163
|
+
.option("--root <dir>", "serve exactly this store or served partition and ignore the client's workspace roots and cwd hints")
|
|
5164
|
+
.action(async (opts) => {
|
|
5164
5165
|
const { startServer } = await import("../mcp/server.js");
|
|
5166
|
+
if (opts.root) {
|
|
5167
|
+
const pinned = resolve(opts.root);
|
|
5168
|
+
if (!isDir(join(pinned, ".hunch")))
|
|
5169
|
+
throw new Error(`--root ${pinned} has no .hunch/ store`);
|
|
5170
|
+
await startServer(pinned, { pinned: true });
|
|
5171
|
+
return;
|
|
5172
|
+
}
|
|
5165
5173
|
await startServer(process.cwd());
|
|
5166
5174
|
});
|
|
5167
5175
|
// ---- migrate (schema versioning) ------------------------------------------
|
package/dist/cli/serve.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { createServeApp } from "../serve/app.js";
|
|
3
|
-
import { initServeConfig, readServeConfig } from "../serve/config.js";
|
|
3
|
+
import { initServeConfig, partitionFor, readServeConfig } from "../serve/config.js";
|
|
4
|
+
import { compactLedger } from "../store/changeLedger.js";
|
|
5
|
+
import { join } from "node:path";
|
|
4
6
|
import { ScopeSchema, scopePath } from "../core/stateContract.js";
|
|
5
7
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
6
8
|
function parseScopeArg(value) {
|
|
@@ -31,6 +33,28 @@ export function registerServeCommands(program) {
|
|
|
31
33
|
process.on("SIGINT", stop);
|
|
32
34
|
process.on("SIGTERM", stop);
|
|
33
35
|
});
|
|
36
|
+
serve.command("compact")
|
|
37
|
+
.description("Compact a served partition's change ledger: keep the newest N events, move the floor up; subscribers below the floor resynchronize")
|
|
38
|
+
.requiredOption("--partition <kind:id>", "the partition whose ledger to compact")
|
|
39
|
+
.option("--keep <n>", "events to keep", "1000")
|
|
40
|
+
.option("--json", "machine-readable output")
|
|
41
|
+
.action((opts) => {
|
|
42
|
+
const parent = serve.opts();
|
|
43
|
+
const config = readServeConfig(resolve(parent.config ?? DEFAULT_CONFIG));
|
|
44
|
+
const scope = parseScopeArg(opts.partition);
|
|
45
|
+
const partition = partitionFor(config, scope);
|
|
46
|
+
if (!partition)
|
|
47
|
+
throw new Error(`this config does not serve ${scopePath(scope)}`);
|
|
48
|
+
const keep = Number(opts.keep);
|
|
49
|
+
if (!Number.isInteger(keep) || keep < 0)
|
|
50
|
+
throw new Error("--keep must be a non-negative integer");
|
|
51
|
+
const result = compactLedger(join(partition.root, ".hunch"), scope, { keep });
|
|
52
|
+
if (opts.json) {
|
|
53
|
+
console.log(JSON.stringify({ partition: scopePath(scope), ...result }));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
console.log(result.dropped ? `${scopePath(scope)}: dropped ${result.dropped} event(s); floor ${result.floor_seq}, head ${result.head_seq}` : `${scopePath(scope)}: nothing to compact (${result.head_seq - result.floor_seq} events retained)`);
|
|
57
|
+
});
|
|
34
58
|
serve.command("init")
|
|
35
59
|
.description("Declare a partition directory and mint a principal token (printed once; only its hash is stored)")
|
|
36
60
|
.requiredOption("--partition <kind:id>", "the scope this directory IS, e.g. user:david or organization:ylm")
|
package/dist/client/state.js
CHANGED
|
@@ -42,6 +42,7 @@ export function createStateClient(opts) {
|
|
|
42
42
|
read: (request) => call("POST", "/nuryel/v1/read", request),
|
|
43
43
|
write: (request) => call("POST", "/nuryel/v1/write", request),
|
|
44
44
|
subscribe: (request) => call("POST", "/nuryel/v1/subscribe", request),
|
|
45
|
+
records: (request) => call("POST", "/nuryel/v1/records", request),
|
|
45
46
|
health: () => call("GET", "/nuryel/v1/health"),
|
|
46
47
|
};
|
|
47
48
|
}
|
|
@@ -34,10 +34,11 @@ export const STATE_CONTRACT_VERSION = "nuryel.state/1";
|
|
|
34
34
|
export const STATE_READ_VERSION = "nuryel.state.read/1";
|
|
35
35
|
export const STATE_WRITE_VERSION = "nuryel.state.write/1";
|
|
36
36
|
export const STATE_SUBSCRIBE_VERSION = "nuryel.state.subscribe/1";
|
|
37
|
+
export const STATE_RECORDS_VERSION = "nuryel.state.records/1";
|
|
37
38
|
/** Capabilities a server advertises; a client that needs one the server lacks gets a typed
|
|
38
39
|
* `unsupported`, never a compatible-looking degraded answer. */
|
|
39
40
|
export const STATE_CAPABILITIES = [
|
|
40
|
-
STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION,
|
|
41
|
+
STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION, STATE_RECORDS_VERSION,
|
|
41
42
|
RECEIPT_SCHEMA_VERSION, COMMITMENT_SCHEMA_VERSION, DERIVED_SCHEMA_VERSION, ENTITY_SCHEMA_VERSION, RELATIONSHIP_SCHEMA_VERSION,
|
|
42
43
|
];
|
|
43
44
|
const SHA256 = /^sha256:[a-f0-9]{64}$/;
|
|
@@ -88,6 +89,9 @@ export const ReadResponseSchema = z.object({
|
|
|
88
89
|
state_of_record: StateOfRecordSchema.nullable(),
|
|
89
90
|
/** Scopes the principal asked about but is not granted — named, never silently dropped. */
|
|
90
91
|
denied_scopes: z.array(ScopeSchema).max(64).default([]),
|
|
92
|
+
/** The records behind every ref in `state_of_record`, by id, so a consumer can answer from
|
|
93
|
+
* the drawer without a second lookup. Additive; absent when there is no subject. */
|
|
94
|
+
records: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
|
|
91
95
|
}).strict();
|
|
92
96
|
export const WriteRequestSchema = z.object({
|
|
93
97
|
schema: z.literal(STATE_WRITE_VERSION),
|
|
@@ -107,6 +111,9 @@ export const WriteResultSchema = z.object({
|
|
|
107
111
|
durability: z.enum(DURABILITY),
|
|
108
112
|
outcome: z.enum(["created", "updated", "replayed", "superseded"]),
|
|
109
113
|
conflict: z.object({ incumbent_id: z.string().max(2048), reason: z.string().max(512) }).strict().nullable().default(null),
|
|
114
|
+
/** The record as stored (after normalization and identity derivation), so a writer can verify
|
|
115
|
+
* what landed without a second lookup. Additive. */
|
|
116
|
+
record: z.record(z.string(), z.unknown()).optional(),
|
|
110
117
|
}).strict();
|
|
111
118
|
export const SubscribeRequestSchema = z.object({
|
|
112
119
|
schema: z.literal(STATE_SUBSCRIBE_VERSION),
|
|
@@ -135,6 +142,23 @@ export const ChangeEventSchema = z.object({
|
|
|
135
142
|
z.object({ kind: z.literal("write"), principal: z.string().regex(TOKEN) }).strict(),
|
|
136
143
|
]).optional(),
|
|
137
144
|
}).strict();
|
|
145
|
+
/** records — fetch records by id, grants first. A subscribe event names a record; this is how
|
|
146
|
+
* a consumer gets its body without a subject read. Ids outside the grants are named in
|
|
147
|
+
* `denied`, unknown ids in `missing`; neither is silently dropped. */
|
|
148
|
+
export const RecordsRequestSchema = z.object({
|
|
149
|
+
schema: z.literal(STATE_RECORDS_VERSION),
|
|
150
|
+
principal: PrincipalSchema,
|
|
151
|
+
scope: ScopeSchema,
|
|
152
|
+
ids: z.array(z.string().min(1).max(2048)).min(1).max(256),
|
|
153
|
+
}).strict();
|
|
154
|
+
export const RecordsResponseSchema = z.object({
|
|
155
|
+
schema: z.literal(STATE_RECORDS_VERSION),
|
|
156
|
+
scope: ScopeSchema,
|
|
157
|
+
records: z.record(z.string(), z.record(z.string(), z.unknown())),
|
|
158
|
+
facets: z.record(z.string(), z.enum(STATE_FACETS)),
|
|
159
|
+
missing: z.array(z.string().max(2048)).default([]),
|
|
160
|
+
denied: z.array(z.string().max(2048)).default([]),
|
|
161
|
+
}).strict();
|
|
138
162
|
export const CapabilityNegotiationSchema = z.object({
|
|
139
163
|
protocol: z.literal(STATE_CONTRACT_VERSION),
|
|
140
164
|
capabilities: z.array(z.string().max(128)).max(64),
|
package/dist/extractors/git.js
CHANGED
|
@@ -818,7 +818,11 @@ function stagedMemoryPaths(hunchDir, env, allowMemoryDeletions = false) {
|
|
|
818
818
|
function isDerivedStoreArtifact(relativeName) {
|
|
819
819
|
return /^[^/]+\.sqlite[^/]*$/i.test(relativeName)
|
|
820
820
|
|| relativeName.split("/").some((segment) => segment.includes(".tmp"))
|
|
821
|
-
|| relativeName === "events.log"
|
|
821
|
+
|| relativeName === "events.log"
|
|
822
|
+
// `hunch serve` flushes INSIDE its cross-process write lock, so the lock file is always
|
|
823
|
+
// staged alongside the record; treating it as a violation made every served write skip
|
|
824
|
+
// the commit quietly and report durability "local" forever (1.26.0/1.26.1).
|
|
825
|
+
|| relativeName === "write.lock";
|
|
822
826
|
}
|
|
823
827
|
/** Enumerate ordinary JSON files already contained under an overlay. Push-capable
|
|
824
828
|
* stores force-add this exact allowlist so remote .gitignore, info/exclude, or an
|
package/dist/mcp/server.js
CHANGED
|
@@ -13,8 +13,8 @@ import { z } from "zod";
|
|
|
13
13
|
import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
|
|
14
14
|
import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
|
|
15
15
|
import { HunchStore } from "../store/hunchStore.js";
|
|
16
|
-
import { StateRefusal, SubscribeResponseSchema, capabilities, readState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
17
|
-
import { ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION } from "../core/stateContract.js";
|
|
16
|
+
import { StateRefusal, SubscribeResponseSchema, capabilities, readState, recordsState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
17
|
+
import { ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION, STATE_RECORDS_VERSION } from "../core/stateContract.js";
|
|
18
18
|
import { selectEmbedder } from "../store/embedder.js";
|
|
19
19
|
import { decisionId, findingId } from "../core/ids.js";
|
|
20
20
|
import { buildCorrectionConstraint } from "../core/correction.js";
|
|
@@ -22,6 +22,7 @@ import { knownRepoDeps } from "../synthesis/tripwires.js";
|
|
|
22
22
|
import { refreshExistingGrounding } from "../integrations/providers.js";
|
|
23
23
|
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, workingFiles, workingDiff, pullHunchStatus, sameRemoteUrl, currentBranch } from "../extractors/git.js";
|
|
24
24
|
import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
|
|
25
|
+
import { withWriteLock } from "../serve/writelock.js";
|
|
25
26
|
import { advertisedTeamRemoteContract, ensureTeamOverlay, overlayMatchesTeamRemote, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
|
|
26
27
|
import { formatStructure } from "../core/format.js";
|
|
27
28
|
import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
|
|
@@ -517,7 +518,8 @@ function prepareRoot(root, explicitOverlay, requireIndex) {
|
|
|
517
518
|
throw error;
|
|
518
519
|
}
|
|
519
520
|
}
|
|
520
|
-
export function buildServerWithRootControl(initialRoot) {
|
|
521
|
+
export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
522
|
+
const pinned = options.pinned === true;
|
|
521
523
|
const explicitOverlay = !!process.env.HUNCH_PRIVATE_DIR?.trim();
|
|
522
524
|
const initial = prepareRoot(initialRoot, explicitOverlay, false);
|
|
523
525
|
let root = initial.root;
|
|
@@ -670,7 +672,9 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
670
672
|
// sole in-flight request — re-homing under a concurrent request would tear its
|
|
671
673
|
// root/store out from under it, so that case is refused rather than risked.
|
|
672
674
|
const cwdHint = extractCwdHint(args[0]);
|
|
673
|
-
|
|
675
|
+
// A pinned root is the whole point of `hunch mcp --root`: a served partition must not
|
|
676
|
+
// follow the caller's working directory into some other checkout.
|
|
677
|
+
if (cwdHint !== undefined && !pinned) {
|
|
674
678
|
const target = canonicalRootPath(findRoot(cwdHint));
|
|
675
679
|
if (target !== canonicalRootPath(root)) {
|
|
676
680
|
if (activeRequests) {
|
|
@@ -1710,7 +1714,31 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1710
1714
|
? `subject ${sor.subject}: current ${sor.current.length} · in force ${sor.in_force.length} · done ${sor.done.length} · depends on ${sor.depends_on.length} · invalidated by ${sor.invalidated_by.length}`
|
|
1711
1715
|
: "no subject — delivery envelope only";
|
|
1712
1716
|
const deniedNote = response.denied_scopes.length ? `\ndenied scopes: ${response.denied_scopes.map((s) => `${s.kind}/${s.id}`).join(", ")}` : "";
|
|
1713
|
-
|
|
1717
|
+
// Render the state of record itself, not only its refs: a consumer answers from this text.
|
|
1718
|
+
const line = (label, ref) => {
|
|
1719
|
+
const r = (response.records ?? {})[ref.id] ?? {};
|
|
1720
|
+
const g = (k) => { const v = r[k]; return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v); };
|
|
1721
|
+
if (ref.facet === "derived")
|
|
1722
|
+
return `- ${label} derived ${ref.id} · computed ${g("computed_at")} · ${r.dependencies?.length ?? 0} dependencies\n ${g("content").slice(0, 1200)}`;
|
|
1723
|
+
if (ref.facet === "commitments")
|
|
1724
|
+
return `- ${label} commitment ${ref.id} · ${g("status")} · due ${g("due")} · owner ${g("owner")}: ${g("title")}`;
|
|
1725
|
+
if (ref.facet === "receipts") {
|
|
1726
|
+
const t = (r.target ?? {});
|
|
1727
|
+
return `- ${label} receipt ${ref.id} · ${g("action_kind")} on ${String(t.system ?? "")} ${String(t.object_type ?? "")}:${String(t.object_key ?? "")} · ${g("state")} at ${g("occurred_at")} by ${g("actor")}`;
|
|
1728
|
+
}
|
|
1729
|
+
if (ref.facet === "decisions")
|
|
1730
|
+
return `- ${label} decision ${ref.id} · ${g("status")}: ${g("title")}`;
|
|
1731
|
+
if (ref.facet === "constraints")
|
|
1732
|
+
return `- ${label} constraint ${ref.id} · ${g("severity")}: ${g("statement")}`;
|
|
1733
|
+
if (ref.facet === "entities")
|
|
1734
|
+
return `- ${label} entity ${ref.id} · ${g("kind")} ${g("name")} · ${g("lifecycle")}`;
|
|
1735
|
+
return `- ${label} ${ref.facet} ${ref.id}`;
|
|
1736
|
+
};
|
|
1737
|
+
const stateText = sor
|
|
1738
|
+
? [...sor.current.map((r) => line("current", r)), ...sor.in_force.map((r) => line("in force", r)), ...sor.done.map((r) => line("done", r)),
|
|
1739
|
+
...(sor.invalidated_by.length ? [`- invalidated by: ${sor.invalidated_by.join(", ")}`] : [])].join("\n") || "(nothing on record for this subject)"
|
|
1740
|
+
: "";
|
|
1741
|
+
return stateResult(`${response.receipt_id} · ${summary}${deniedNote}${stateText ? `\n\nState of record:\n${stateText}` : ""}\n\n${envelope.text}`, response);
|
|
1714
1742
|
}
|
|
1715
1743
|
catch (e) {
|
|
1716
1744
|
return stateRefusal(e);
|
|
@@ -1723,9 +1751,11 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1723
1751
|
outputSchema: WriteResultSchema.shape,
|
|
1724
1752
|
}, async ({ cwd: _cwd, ...input }) => {
|
|
1725
1753
|
try {
|
|
1726
|
-
|
|
1754
|
+
// Same cross-process lock `hunch serve` takes: a second agent writing over stdio must
|
|
1755
|
+
// not race the HTTP server between the ledger read and the record write.
|
|
1756
|
+
const result = await withWriteLock(hunchPaths(root).hunch, () => writeState(store, { schema: STATE_WRITE_VERSION, ...input }, {
|
|
1727
1757
|
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
|
|
1728
|
-
});
|
|
1758
|
+
}));
|
|
1729
1759
|
return stateResult(`${result.outcome} ${result.record_id} (${result.durability}) ${result.record_hash}`, result);
|
|
1730
1760
|
}
|
|
1731
1761
|
catch (e) {
|
|
@@ -1747,6 +1777,22 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1747
1777
|
return stateRefusal(e);
|
|
1748
1778
|
}
|
|
1749
1779
|
});
|
|
1780
|
+
server.registerTool("nuryel_records", {
|
|
1781
|
+
title: "nuryel.state/1 records — fetch records by id, grants first",
|
|
1782
|
+
description: "Fetch state records by id (from a subscribe event, a read ref, or a write result). Every id is accounted for: found (with its facet), denied (its scope is outside your grants — named, never described) or missing.",
|
|
1783
|
+
inputSchema: RecordsRequestSchema.omit({ schema: true }).shape,
|
|
1784
|
+
outputSchema: RecordsResponseSchema.shape,
|
|
1785
|
+
}, async (input) => {
|
|
1786
|
+
try {
|
|
1787
|
+
const response = recordsState(store, { schema: STATE_RECORDS_VERSION, ...input });
|
|
1788
|
+
const lines = Object.entries(response.records).map(([id, r]) => `- ${response.facets[id]} ${id}: ${JSON.stringify(r).slice(0, 600)}`);
|
|
1789
|
+
const tail = [...(response.missing.length ? [`missing: ${response.missing.join(", ")}`] : []), ...(response.denied.length ? [`denied: ${response.denied.join(", ")}`] : [])];
|
|
1790
|
+
return stateResult(`${Object.keys(response.records).length} record(s)\n${lines.join("\n")}${tail.length ? `\n${tail.join("\n")}` : ""}`, response);
|
|
1791
|
+
}
|
|
1792
|
+
catch (e) {
|
|
1793
|
+
return stateRefusal(e);
|
|
1794
|
+
}
|
|
1795
|
+
});
|
|
1750
1796
|
// -- hunch_findings (read: the open-observations ledger) --------------------
|
|
1751
1797
|
server.registerTool("hunch_findings", {
|
|
1752
1798
|
title: "Open findings for a scope",
|
|
@@ -2290,7 +2336,9 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
2290
2336
|
return {
|
|
2291
2337
|
server,
|
|
2292
2338
|
getRoot: () => root,
|
|
2293
|
-
setRoot
|
|
2339
|
+
setRoot: (next) => { if (!pinned)
|
|
2340
|
+
setRoot(next); },
|
|
2341
|
+
pinned,
|
|
2294
2342
|
cancelPendingRoot: () => { pendingRoot = null; },
|
|
2295
2343
|
};
|
|
2296
2344
|
}
|
|
@@ -2309,6 +2357,8 @@ function provLine(record) {
|
|
|
2309
2357
|
/** Query client roots after initialization and follow later list changes.
|
|
2310
2358
|
* Generation ordering prevents a slow stale roots/list response from winning. */
|
|
2311
2359
|
export function wireClientRoots(control, fallback) {
|
|
2360
|
+
if (control.pinned)
|
|
2361
|
+
return; // `hunch mcp --root`: the client's workspace is not this server's store
|
|
2312
2362
|
let generation = 0;
|
|
2313
2363
|
const syncRoots = async () => {
|
|
2314
2364
|
const mine = ++generation;
|
|
@@ -2346,12 +2396,14 @@ export function wireClientRoots(control, fallback) {
|
|
|
2346
2396
|
control.server.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => { await syncRoots(); });
|
|
2347
2397
|
}
|
|
2348
2398
|
/** Start the stdio server (called by `hunch mcp`). */
|
|
2349
|
-
export async function startServer(cwd = process.cwd()) {
|
|
2350
|
-
const fallback = findRoot(cwd);
|
|
2351
|
-
const control = buildServerWithRootControl(fallback);
|
|
2399
|
+
export async function startServer(cwd = process.cwd(), options = {}) {
|
|
2400
|
+
const fallback = options.pinned ? cwd : findRoot(cwd);
|
|
2401
|
+
const control = buildServerWithRootControl(fallback, options);
|
|
2352
2402
|
wireClientRoots(control, fallback);
|
|
2353
2403
|
const transport = new StdioServerTransport();
|
|
2354
2404
|
await control.server.connect(transport);
|
|
2355
|
-
console.error(
|
|
2405
|
+
console.error(control.pinned
|
|
2406
|
+
? `[hunch-mcp] serving Hunch over stdio (pinned root ${control.getRoot()}; client roots ignored)`
|
|
2407
|
+
: `[hunch-mcp] serving Hunch over stdio (spawn root ${control.getRoot()}; resolving client roots…)`);
|
|
2356
2408
|
}
|
|
2357
2409
|
//# sourceMappingURL=server.js.map
|
package/dist/serve/app.js
CHANGED
|
@@ -11,14 +11,15 @@
|
|
|
11
11
|
* POST /nuryel/v1/read → readState
|
|
12
12
|
* POST /nuryel/v1/write → writeState (under the partition's write lock)
|
|
13
13
|
* POST /nuryel/v1/subscribe → subscribeState
|
|
14
|
+
* POST /nuryel/v1/records → recordsState (by id, grants first)
|
|
14
15
|
* Request bodies are the contract's request schemas minus `schema` and `principal`.
|
|
15
16
|
*/
|
|
16
17
|
import { createServer } from "node:http";
|
|
17
18
|
import { HunchStore } from "../store/hunchStore.js";
|
|
18
19
|
import { hunchPaths } from "../core/paths.js";
|
|
19
20
|
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";
|
|
21
|
+
import { StateRefusal, capabilities, readState, recordsState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
22
|
+
import { STATE_READ_VERSION, STATE_RECORDS_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ScopeSchema, scopePath } from "../core/stateContract.js";
|
|
22
23
|
import { partitionFor, resolvePrincipal } from "./config.js";
|
|
23
24
|
import { WriteLockTimeout, withWriteLock } from "./writelock.js";
|
|
24
25
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
@@ -163,6 +164,11 @@ export function createServeApp(config, opts = {}) {
|
|
|
163
164
|
const { store } = storeFor(scope);
|
|
164
165
|
return send(res, 200, subscribeState(store, { schema: STATE_SUBSCRIBE_VERSION, principal, ...body }));
|
|
165
166
|
}
|
|
167
|
+
if (url.pathname === "/nuryel/v1/records") {
|
|
168
|
+
const scope = requireScope(principal, body);
|
|
169
|
+
const { store } = storeFor(scope);
|
|
170
|
+
return send(res, 200, recordsState(store, { schema: STATE_RECORDS_VERSION, principal, ...body }));
|
|
171
|
+
}
|
|
166
172
|
throw problem(404, "not-found", `${url.pathname} is not a nuryel.state/1 route`);
|
|
167
173
|
}
|
|
168
174
|
catch (error) {
|
package/dist/serve/config.js
CHANGED
|
@@ -34,6 +34,11 @@ export const ServeConfigSchema = z.object({
|
|
|
34
34
|
partitions: z.array(PartitionConfigSchema).min(1).max(256),
|
|
35
35
|
principals: z.array(PrincipalConfigSchema).max(1024).default([]),
|
|
36
36
|
}).strict();
|
|
37
|
+
export const PARTITION_GITIGNORE = [
|
|
38
|
+
"# hunch serve partition — derived runtime artifacts (regenerable from .hunch/*.json)",
|
|
39
|
+
".hunch/*.sqlite", ".hunch/*.sqlite-shm", ".hunch/*.sqlite-wal", ".hunch/*.sqlite-journal",
|
|
40
|
+
".hunch/**/*.tmp*", ".hunch/write.lock", ".hunch/.hunch-commit.lock", ".hunch/local.json", ".hunch/events.log", "",
|
|
41
|
+
].join("\n");
|
|
37
42
|
export function hashToken(token) {
|
|
38
43
|
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
39
44
|
}
|
|
@@ -104,6 +109,11 @@ export function initServeConfig(opts) {
|
|
|
104
109
|
const manifest = resolve(hunchDir, "manifest.json");
|
|
105
110
|
if (!existsSync(manifest))
|
|
106
111
|
writeFileAtomic(manifest, JSON.stringify({ schema_version: 3 }, null, 2) + "\n");
|
|
112
|
+
// A served partition is meant to be its own git repository: keep the derived index, temp
|
|
113
|
+
// files and locks out of it so every auto-commit is records + ledger only.
|
|
114
|
+
const ignore = resolve(root, ".gitignore");
|
|
115
|
+
if (!existsSync(ignore))
|
|
116
|
+
writeFileAtomic(ignore, PARTITION_GITIGNORE);
|
|
107
117
|
const partitions = existing ? existing.partitions.filter((p) => scopePath(p.scope) !== scopePath(opts.scope)) : [];
|
|
108
118
|
const partition = { scope: opts.scope, root };
|
|
109
119
|
partitions.push(partition);
|
|
@@ -28,6 +28,9 @@ export const LedgerSchema = z.object({
|
|
|
28
28
|
schema: z.literal(LEDGER_SCHEMA_VERSION),
|
|
29
29
|
scope: ScopeSchema,
|
|
30
30
|
head_seq: z.number().int().nonnegative(),
|
|
31
|
+
/** Events below this seq were compacted away. `events` starts at floor_seq + 1. A subscriber
|
|
32
|
+
* whose cursor is below the floor must resynchronize (the contract's gap rule, made explicit). */
|
|
33
|
+
floor_seq: z.number().int().nonnegative().default(0),
|
|
31
34
|
events: z.array(ChangeEventSchema),
|
|
32
35
|
idempotency: z.record(z.string(), IdempotencyEntrySchema).default({}),
|
|
33
36
|
}).strict();
|
|
@@ -40,7 +43,7 @@ export function ledgerFile(hunchDir, scope) {
|
|
|
40
43
|
return join(hunchDir, CHANGES_DIR, `${scope.kind}-${safe}-${tag}.json`);
|
|
41
44
|
}
|
|
42
45
|
export function emptyLedger(scope) {
|
|
43
|
-
return { schema: LEDGER_SCHEMA_VERSION, scope, head_seq: 0, events: [], idempotency: {} };
|
|
46
|
+
return { schema: LEDGER_SCHEMA_VERSION, scope, head_seq: 0, floor_seq: 0, events: [], idempotency: {} };
|
|
44
47
|
}
|
|
45
48
|
/** Read the ledger for a scope; a missing file is an empty ledger, a corrupt one is an
|
|
46
49
|
* error (never silently treated as empty — that would restart the sequence). */
|
|
@@ -52,14 +55,14 @@ export function readLedger(hunchDir, scope) {
|
|
|
52
55
|
const ledger = LedgerSchema.parse(raw);
|
|
53
56
|
if (scopePath(ledger.scope) !== scopePath(scope))
|
|
54
57
|
throw new Error(`ledger ${file} belongs to scope ${scopePath(ledger.scope)}, not ${scopePath(scope)}`);
|
|
55
|
-
let expected = 1;
|
|
58
|
+
let expected = ledger.floor_seq + 1;
|
|
56
59
|
for (const event of ledger.events) {
|
|
57
60
|
if (event.seq !== expected)
|
|
58
61
|
throw new Error(`ledger ${file} is not contiguous at seq ${event.seq} (expected ${expected})`);
|
|
59
62
|
expected += 1;
|
|
60
63
|
}
|
|
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`);
|
|
64
|
+
if (ledger.head_seq !== ledger.floor_seq + ledger.events.length)
|
|
65
|
+
throw new Error(`ledger ${file} head_seq ${ledger.head_seq} disagrees with floor ${ledger.floor_seq} + ${ledger.events.length} events`);
|
|
63
66
|
return ledger;
|
|
64
67
|
}
|
|
65
68
|
export function writeLedger(hunchDir, ledger) {
|
|
@@ -93,4 +96,58 @@ export function latestSeqFor(ledger, recordId) {
|
|
|
93
96
|
}
|
|
94
97
|
return 0;
|
|
95
98
|
}
|
|
99
|
+
/** Keep the newest `keep` events; everything older is dropped and the floor moves up. The
|
|
100
|
+
* idempotency table is kept whole (it is what makes replays exact); the records themselves are
|
|
101
|
+
* untouched. Returns how many events were dropped. */
|
|
102
|
+
export function compactLedger(hunchDir, scope, opts = {}) {
|
|
103
|
+
const keep = Math.max(0, Math.floor(opts.keep ?? 1000));
|
|
104
|
+
const ledger = readLedger(hunchDir, scope);
|
|
105
|
+
const dropped = Math.max(0, ledger.events.length - keep);
|
|
106
|
+
if (dropped === 0)
|
|
107
|
+
return { dropped: 0, floor_seq: ledger.floor_seq, head_seq: ledger.head_seq };
|
|
108
|
+
ledger.events = ledger.events.slice(dropped);
|
|
109
|
+
ledger.floor_seq = ledger.head_seq - ledger.events.length;
|
|
110
|
+
writeLedger(hunchDir, ledger);
|
|
111
|
+
return { dropped, floor_seq: ledger.floor_seq, head_seq: ledger.head_seq };
|
|
112
|
+
}
|
|
113
|
+
const eventIdentity = (e) => [e.change, e.facet, e.record_id, e.record_hash, e.at, e.cause ? JSON.stringify(e.cause) : ""].join("|");
|
|
114
|
+
/** Three-way merge of one scope's ledger, for the git merge driver: two clones that both
|
|
115
|
+
* appended to the same partition. The union of events is kept (identity = what changed, to
|
|
116
|
+
* which hash, when, by whom), ordered by time then ours-before-theirs, and RE-SEQUENCED from
|
|
117
|
+
* the higher floor; every subscriber's cursor is therefore invalid after a merge and the gap
|
|
118
|
+
* rule makes it resynchronize. Idempotency entries are unioned; a key both sides used for
|
|
119
|
+
* different records is a conflict the caller must surface (ours is kept). */
|
|
120
|
+
export function mergeLedgers(base, ours, theirs) {
|
|
121
|
+
if (scopePath(ours.scope) !== scopePath(theirs.scope))
|
|
122
|
+
throw new Error("ledgers for different scopes cannot be merged");
|
|
123
|
+
const seen = new Map();
|
|
124
|
+
const order = [];
|
|
125
|
+
const add = (e) => { const k = eventIdentity(e); if (!seen.has(k)) {
|
|
126
|
+
seen.set(k, e);
|
|
127
|
+
order.push(e);
|
|
128
|
+
} };
|
|
129
|
+
for (const e of base?.events ?? [])
|
|
130
|
+
add(e);
|
|
131
|
+
for (const e of ours.events)
|
|
132
|
+
add(e);
|
|
133
|
+
for (const e of theirs.events)
|
|
134
|
+
add(e);
|
|
135
|
+
const ranked = order.map((e, i) => ({ e, i, side: (ours.events.includes(e) ? 0 : 1) }));
|
|
136
|
+
ranked.sort((a, b) => a.e.at.localeCompare(b.e.at) || a.side - b.side || a.i - b.i);
|
|
137
|
+
const floor = Math.max(base?.floor_seq ?? 0, ours.floor_seq, theirs.floor_seq);
|
|
138
|
+
const events = ranked.map(({ e }, i) => ({ ...e, seq: floor + i + 1 }));
|
|
139
|
+
const conflicts = [];
|
|
140
|
+
const idempotency = { ...(base?.idempotency ?? {}), ...theirs.idempotency, ...ours.idempotency };
|
|
141
|
+
for (const [key, entry] of Object.entries(theirs.idempotency)) {
|
|
142
|
+
const mine = ours.idempotency[key];
|
|
143
|
+
if (mine && mine.record_id !== entry.record_id)
|
|
144
|
+
conflicts.push(`idempotency key ${key}: ours ${mine.record_id}, theirs ${entry.record_id} (kept ours)`);
|
|
145
|
+
}
|
|
146
|
+
for (const key of Object.keys(idempotency)) {
|
|
147
|
+
const entry = idempotency[key];
|
|
148
|
+
const at = events.find((e) => e.record_id === entry.record_id && e.record_hash === entry.record_hash);
|
|
149
|
+
idempotency[key] = { ...entry, seq: at ? at.seq : Math.min(entry.seq, floor + events.length) };
|
|
150
|
+
}
|
|
151
|
+
return { ledger: { schema: LEDGER_SCHEMA_VERSION, scope: ours.scope, floor_seq: floor, head_seq: floor + events.length, events, idempotency }, conflicts };
|
|
152
|
+
}
|
|
96
153
|
//# sourceMappingURL=changeLedger.js.map
|
package/dist/store/merge.js
CHANGED
|
@@ -17,9 +17,15 @@
|
|
|
17
17
|
* content tiebreak (so both developers' merges converge on the same result). Records
|
|
18
18
|
* are pure data here — no filesystem access; the CLI reads/writes the files.
|
|
19
19
|
*/
|
|
20
|
+
import { LEDGER_SCHEMA_VERSION, LedgerSchema, mergeLedgers } from "./changeLedger.js";
|
|
20
21
|
/** Merge three versions of one `.hunch` JSON file (an index array OR a single
|
|
21
22
|
* record object). Returns the merged text, or conflict=true to fall back. */
|
|
22
23
|
export function mergeHunchJson(baseText, oursText, theirsText) {
|
|
24
|
+
// A per-scope change ledger is not a record array: two clones that both appended get the
|
|
25
|
+
// union of their events, re-sequenced, and unioned idempotency tables (see mergeLedgers).
|
|
26
|
+
const ledger = mergeLedgerText(baseText, oursText, theirsText);
|
|
27
|
+
if (ledger)
|
|
28
|
+
return ledger;
|
|
23
29
|
const ours = parseSide(oursText);
|
|
24
30
|
const theirs = parseSide(theirsText);
|
|
25
31
|
const base = parseSide(baseText);
|
|
@@ -42,6 +48,34 @@ export function mergeHunchJson(baseText, oursText, theirsText) {
|
|
|
42
48
|
return { text: oursText, conflict: true };
|
|
43
49
|
return { text: serialize(merged[0]), conflict: false };
|
|
44
50
|
}
|
|
51
|
+
function mergeLedgerText(baseText, oursText, theirsText) {
|
|
52
|
+
const parse = (text) => {
|
|
53
|
+
if (!text.trim())
|
|
54
|
+
return null;
|
|
55
|
+
try {
|
|
56
|
+
const raw = JSON.parse(text);
|
|
57
|
+
return raw && raw.schema === LEDGER_SCHEMA_VERSION ? LedgerSchema.parse(raw) : null;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const ours = parse(oursText);
|
|
64
|
+
const theirs = parse(theirsText);
|
|
65
|
+
if (!ours && !theirs)
|
|
66
|
+
return null;
|
|
67
|
+
if (!ours || !theirs)
|
|
68
|
+
return null; // one side is not a ledger (or deleted it): let git surface it
|
|
69
|
+
try {
|
|
70
|
+
const { ledger, conflicts } = mergeLedgers(parse(baseText), ours, theirs);
|
|
71
|
+
if (conflicts.length)
|
|
72
|
+
return { text: oursText, conflict: true };
|
|
73
|
+
return { text: JSON.stringify(ledger, null, 2) + "\n", conflict: false };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return { text: oursText, conflict: true };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
45
79
|
/** Three-way merge of record arrays keyed by `id`. Additions on either side are
|
|
46
80
|
* kept; a record changed on one side only takes that side; a delete is honored
|
|
47
81
|
* only if the other side left the record unchanged (a modification beats a delete);
|
|
@@ -26,7 +26,7 @@ import { decisionId } from "../core/ids.js";
|
|
|
26
26
|
import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
|
|
27
27
|
import { captureConflicts, isLive } from "../core/topics.js";
|
|
28
28
|
import { buildDeliveryEnvelope } from "../core/delivery.js";
|
|
29
|
-
import { STATE_CAPABILITIES, STATE_CONTRACT_VERSION, STATE_FACETS, STATE_READ_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, ChangeEventSchema, ScopeSchema, scopePath, stateHash, actionReceiptId, commitmentId, derivedId, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, } from "../core/stateContract.js";
|
|
29
|
+
import { STATE_CAPABILITIES, STATE_CONTRACT_VERSION, STATE_FACETS, STATE_READ_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, ChangeEventSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_RECORDS_VERSION, ScopeSchema, scopePath, stateHash, actionReceiptId, commitmentId, derivedId, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, } from "../core/stateContract.js";
|
|
30
30
|
/** A typed refusal. `code` is stable for bindings; `conflict` names the incumbent when one exists. */
|
|
31
31
|
export class StateRefusal extends Error {
|
|
32
32
|
code;
|
|
@@ -68,6 +68,11 @@ export const SubscribeResponseSchema = z.object({
|
|
|
68
68
|
/** True when facet / subject filters were applied: `events` is then a subsequence and
|
|
69
69
|
* assertChangeSequence does not apply; `head_seq` remains the cursor. */
|
|
70
70
|
filtered: z.boolean(),
|
|
71
|
+
/** Events below this seq were compacted away. */
|
|
72
|
+
floor_seq: z.number().int().nonnegative().default(0),
|
|
73
|
+
/** True when `after_seq` was below the floor: the caller's cursor is stale, the events returned
|
|
74
|
+
* start at the floor, and the caller must rebuild what it holds from a read. */
|
|
75
|
+
resync: z.boolean().default(false),
|
|
71
76
|
}).strict();
|
|
72
77
|
export function capabilities(store) {
|
|
73
78
|
const own = partitionOf(store);
|
|
@@ -118,6 +123,7 @@ export function readState(store, input) {
|
|
|
118
123
|
profile: request.profile ?? "builder",
|
|
119
124
|
});
|
|
120
125
|
let stateOfRecord = null;
|
|
126
|
+
const records = {};
|
|
121
127
|
const denied = new Map();
|
|
122
128
|
if (request.subject !== undefined) {
|
|
123
129
|
const subject = request.subject;
|
|
@@ -135,6 +141,10 @@ export function readState(store, input) {
|
|
|
135
141
|
}
|
|
136
142
|
return scope;
|
|
137
143
|
};
|
|
144
|
+
const keep = (facet, record, scope) => {
|
|
145
|
+
records[record.id] = record;
|
|
146
|
+
return refOf(facet, record, scope);
|
|
147
|
+
};
|
|
138
148
|
if (facets.has("decisions"))
|
|
139
149
|
for (const d of store.recs("decisions")) {
|
|
140
150
|
if (d.topic !== subject && d.id !== subject)
|
|
@@ -143,7 +153,7 @@ export function readState(store, input) {
|
|
|
143
153
|
if (!scope)
|
|
144
154
|
continue;
|
|
145
155
|
if (isLive(d))
|
|
146
|
-
current.push(
|
|
156
|
+
current.push(keep("decisions", d, scope));
|
|
147
157
|
}
|
|
148
158
|
if (facets.has("constraints"))
|
|
149
159
|
for (const c of store.recs("constraints")) {
|
|
@@ -153,7 +163,7 @@ export function readState(store, input) {
|
|
|
153
163
|
if (!scope)
|
|
154
164
|
continue;
|
|
155
165
|
if (c.status === "active" && c.valid_to == null)
|
|
156
|
-
inForce.push(
|
|
166
|
+
inForce.push(keep("constraints", c, scope));
|
|
157
167
|
}
|
|
158
168
|
if (facets.has("receipts"))
|
|
159
169
|
for (const r of store.recs("receipts")) {
|
|
@@ -164,7 +174,7 @@ export function readState(store, input) {
|
|
|
164
174
|
if (!scope)
|
|
165
175
|
continue;
|
|
166
176
|
if (r.state === "succeeded" || r.state === "verified")
|
|
167
|
-
done.push(
|
|
177
|
+
done.push(keep("receipts", r, scope));
|
|
168
178
|
if (r.invalidates.includes(subject))
|
|
169
179
|
invalidatedBy.add(r.id);
|
|
170
180
|
}
|
|
@@ -176,7 +186,7 @@ export function readState(store, input) {
|
|
|
176
186
|
if (!scope)
|
|
177
187
|
continue;
|
|
178
188
|
if ((c.status === "open" || c.status === "waiting") && c.valid_to == null)
|
|
179
|
-
inForce.push(
|
|
189
|
+
inForce.push(keep("commitments", c, scope));
|
|
180
190
|
}
|
|
181
191
|
if (facets.has("derived"))
|
|
182
192
|
for (const d of store.recs("derived")) {
|
|
@@ -186,7 +196,7 @@ export function readState(store, input) {
|
|
|
186
196
|
if (!scope)
|
|
187
197
|
continue;
|
|
188
198
|
if (d.state === "current" && d.valid_to == null) {
|
|
189
|
-
current.push(
|
|
199
|
+
current.push(keep("derived", d, scope));
|
|
190
200
|
dependsOn.push(...d.dependencies);
|
|
191
201
|
}
|
|
192
202
|
}
|
|
@@ -198,7 +208,7 @@ export function readState(store, input) {
|
|
|
198
208
|
if (!scope)
|
|
199
209
|
continue;
|
|
200
210
|
if (e.lifecycle === "active")
|
|
201
|
-
current.push(
|
|
211
|
+
current.push(keep("entities", e, scope));
|
|
202
212
|
}
|
|
203
213
|
if (facets.has("relationships"))
|
|
204
214
|
for (const r of store.recs("relationships")) {
|
|
@@ -207,7 +217,7 @@ export function readState(store, input) {
|
|
|
207
217
|
const scope = admit("relationships", r);
|
|
208
218
|
if (!scope)
|
|
209
219
|
continue;
|
|
210
|
-
current.push(
|
|
220
|
+
current.push(keep("relationships", r, scope));
|
|
211
221
|
}
|
|
212
222
|
stateOfRecord = { subject, current, in_force: inForce, done, depends_on: dependsOn, invalidated_by: [...invalidatedBy].sort() };
|
|
213
223
|
}
|
|
@@ -217,6 +227,7 @@ export function readState(store, input) {
|
|
|
217
227
|
scope: request.scope,
|
|
218
228
|
state_of_record: stateOfRecord,
|
|
219
229
|
denied_scopes: [...denied.values()],
|
|
230
|
+
...(stateOfRecord ? { records } : {}),
|
|
220
231
|
});
|
|
221
232
|
assertReadWithinGrants(request.principal, response);
|
|
222
233
|
return { response, envelope };
|
|
@@ -237,6 +248,11 @@ function subjectOf(facet, record) {
|
|
|
237
248
|
default: return undefined;
|
|
238
249
|
}
|
|
239
250
|
}
|
|
251
|
+
/** Top-level fields whose canonical hash differs between two records, sorted. */
|
|
252
|
+
function differingFields(a, b) {
|
|
253
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
254
|
+
return [...keys].filter((k) => stateHash(a[k] ?? null) !== stateHash(b[k] ?? null)).sort();
|
|
255
|
+
}
|
|
240
256
|
/** Records the store can close a valid-time window on when superseded. */
|
|
241
257
|
function closeWindow(store, facet, incumbentId, byId, at, isPrivate) {
|
|
242
258
|
if (facet === "decisions") {
|
|
@@ -323,14 +339,19 @@ export function writeState(store, input, opts = {}) {
|
|
|
323
339
|
const hash = stateHash(record);
|
|
324
340
|
const ledger = readLedger(hunchDir, request.scope);
|
|
325
341
|
const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
|
|
326
|
-
const result = (outcome, conflict = null, rid = id, rhash = hash) => WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: rhash, durability: durability(), outcome, conflict });
|
|
342
|
+
const result = (outcome, conflict = null, rid = id, rhash = hash) => WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: rhash, durability: durability(), outcome, conflict, record: store.getRec(facet, rid) ?? record });
|
|
327
343
|
// Idempotency: the same key replays the original; the same key with a different payload
|
|
328
344
|
// is a refusal, never a second record.
|
|
329
345
|
const seen = ledger.idempotency[request.idempotency_key];
|
|
330
346
|
if (seen) {
|
|
331
347
|
if (seen.record_hash === hash && seen.record_id === id)
|
|
332
348
|
return result("replayed");
|
|
333
|
-
|
|
349
|
+
// Say WHAT differs and what to do: a stable key with a varying payload (a timestamp, new
|
|
350
|
+
// wording) is the trap every writer falls into once; the refusal must teach the way out.
|
|
351
|
+
const stored = store.getRec(facet, seen.record_id);
|
|
352
|
+
const differing = stored ? differingFields(stored, record) : [];
|
|
353
|
+
const where = differing.length ? ` — this payload differs in: ${differing.join(", ")}` : (seen.record_id !== id ? ` — this payload derives a different identity (${id})` : "");
|
|
354
|
+
throw new StateRefusal("idempotency", `idempotency key "${request.idempotency_key}" was already used for ${seen.record_id}${where}. A key names ONE request payload: re-send the original payload to replay it, or use a new key to write this payload (the record keeps its derived id and is updated in place).`, { incumbent_id: seen.record_id, reason: "idempotency key reused with a different payload" });
|
|
334
355
|
}
|
|
335
356
|
const existing = store.recsInHome(facet, home).find((r) => r.id === id);
|
|
336
357
|
if (existing && stateHash(existing) === hash) {
|
|
@@ -390,9 +411,46 @@ export function subscribeState(store, input) {
|
|
|
390
411
|
const facets = request.facets ? new Set(request.facets) : null;
|
|
391
412
|
const subjects = request.subjects ? new Set(request.subjects) : null;
|
|
392
413
|
const filtered = !!(facets || subjects);
|
|
393
|
-
const
|
|
414
|
+
const resync = request.after_seq < ledger.floor_seq;
|
|
415
|
+
const after = resync ? ledger.floor_seq : request.after_seq;
|
|
416
|
+
const events = ledger.events.filter((e) => e.seq > after
|
|
394
417
|
&& (!facets || facets.has(e.facet))
|
|
395
418
|
&& (!subjects || subjects.has(e.record_id) || (e.subject !== undefined && subjects.has(e.subject)) || e.invalidates.some((s) => subjects.has(s))));
|
|
396
|
-
return SubscribeResponseSchema.parse({ schema: STATE_SUBSCRIBE_VERSION, scope: request.scope, head_seq: ledger.head_seq, events, filtered });
|
|
419
|
+
return SubscribeResponseSchema.parse({ schema: STATE_SUBSCRIBE_VERSION, scope: request.scope, head_seq: ledger.head_seq, events, filtered, floor_seq: ledger.floor_seq, resync });
|
|
420
|
+
}
|
|
421
|
+
// ---- records ------------------------------------------------------------------------------
|
|
422
|
+
/** records — fetch by id, grants first. Every id is accounted for: found, denied (its scope is
|
|
423
|
+
* outside the grants — named, never described) or missing. */
|
|
424
|
+
export function recordsState(store, input) {
|
|
425
|
+
const request = RecordsRequestSchema.parse(input);
|
|
426
|
+
if (!granted(request.principal, request.scope))
|
|
427
|
+
throw new StateRefusal("outside-grants", `scope ${scopePath(request.scope)} is outside the principal's grants`);
|
|
428
|
+
const repo = partitionOf(store);
|
|
429
|
+
const records = {};
|
|
430
|
+
const facets = {};
|
|
431
|
+
const denied = [];
|
|
432
|
+
const missing = [];
|
|
433
|
+
for (const id of new Set(request.ids)) {
|
|
434
|
+
let found = null;
|
|
435
|
+
for (const facet of STATE_FACETS) {
|
|
436
|
+
const record = store.getRec(facet, id);
|
|
437
|
+
if (record) {
|
|
438
|
+
found = { facet, record };
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (!found) {
|
|
443
|
+
missing.push(id);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const scope = recordScope(found.record, repo);
|
|
447
|
+
if (!granted(request.principal, scope)) {
|
|
448
|
+
denied.push(id);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
records[id] = found.record;
|
|
452
|
+
facets[id] = found.facet;
|
|
453
|
+
}
|
|
454
|
+
return RecordsResponseSchema.parse({ schema: STATE_RECORDS_VERSION, scope: request.scope, records, facets, missing, denied });
|
|
397
455
|
}
|
|
398
456
|
//# sourceMappingURL=stateBinding.js.map
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://www.hunchmemory.com",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.27.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.
|
|
16
|
+
"version": "1.27.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|