@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,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nuryel.state/1 bound to the store — the ONE implementation of read / write / subscribe
|
|
3
|
+
* that every transport (MCP today; HTTP, CLI, typed client next) calls. Transport-free:
|
|
4
|
+
* takes a HunchStore and a validated request, returns a validated response, throws a
|
|
5
|
+
* StateRefusal for every typed refusal. No transport may re-implement any rule here.
|
|
6
|
+
*
|
|
7
|
+
* Homing follows the store's routing, decided by SCOPE, never by a flag:
|
|
8
|
+
* repository scope → the repository's capture home (public `.hunch/`, or the overlay in
|
|
9
|
+
* shared mode) — today's git-native store, unchanged;
|
|
10
|
+
* organization / team / user scopes → the overlay ONLY. They never ride a repository
|
|
11
|
+
* (privacy rule of the contract); without an overlay the write is refused.
|
|
12
|
+
* The change ledger for a scope lives in that same home, so a scope has one sequence.
|
|
13
|
+
*
|
|
14
|
+
* Invariants enforced here (exported from stateContract as assertions, not prose):
|
|
15
|
+
* authorization-before-retrieval (grant check is the FIRST predicate on every path),
|
|
16
|
+
* provenance-on-every-write, one-live-decision-per-topic, derived-state-carries-
|
|
17
|
+
* dependencies, external-truth-stays-external (schema refinements), never-in-request-path
|
|
18
|
+
* (there is no proxy verb — this module never fetches anything).
|
|
19
|
+
*/
|
|
20
|
+
import { basename, join } from "node:path";
|
|
21
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { appendChanges, latestSeqFor, readLedger } from "./changeLedger.js";
|
|
24
|
+
import { hunchPaths } from "../core/paths.js";
|
|
25
|
+
import { decisionId } from "../core/ids.js";
|
|
26
|
+
import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
|
|
27
|
+
import { captureConflicts, isLive } from "../core/topics.js";
|
|
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";
|
|
30
|
+
/** A typed refusal. `code` is stable for bindings; `conflict` names the incumbent when one exists. */
|
|
31
|
+
export class StateRefusal extends Error {
|
|
32
|
+
code;
|
|
33
|
+
conflict;
|
|
34
|
+
constructor(code, message, conflict = null) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.conflict = conflict;
|
|
38
|
+
this.name = "StateRefusal";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const LEGACY_FACETS = new Set(["decisions", "constraints", "bugs", "findings"]);
|
|
42
|
+
// Explicit classes, no `i` flag: the pattern must survive zod → JSON schema for MCP output validation.
|
|
43
|
+
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$/;
|
|
44
|
+
/** The partition this store IS. A served partition declares itself in `.hunch/partition.json`
|
|
45
|
+
* (`{ kind, id }`, committed with the store); a plain checkout is the repository partition
|
|
46
|
+
* named after its directory, sanitized to the contract's token grammar — stable per clone,
|
|
47
|
+
* discoverable through `capabilities`, and the scope every legacy record defaults to. */
|
|
48
|
+
export function partitionOf(store) {
|
|
49
|
+
const declared = join(hunchPaths(store.publicRoot).hunch, "partition.json");
|
|
50
|
+
if (existsSync(declared)) {
|
|
51
|
+
const parsed = ScopeSchema.safeParse(JSON.parse(readFileSync(declared, "utf8")));
|
|
52
|
+
if (!parsed.success)
|
|
53
|
+
throw new StateRefusal("unsupported", `${declared} does not declare a valid partition scope`);
|
|
54
|
+
return parsed.data;
|
|
55
|
+
}
|
|
56
|
+
const raw = basename(store.publicRoot).replace(/[^A-Za-z0-9._:@+-]/g, "-").replace(/^[^A-Za-z0-9]+/, "");
|
|
57
|
+
const id = TOKEN.test(raw) ? raw : "repository";
|
|
58
|
+
return { kind: "repository", id };
|
|
59
|
+
}
|
|
60
|
+
/** @deprecated name kept for callers written before served partitions; same value as partitionOf. */
|
|
61
|
+
export const repositoryScope = partitionOf;
|
|
62
|
+
export const SubscribeResponseSchema = z.object({
|
|
63
|
+
schema: z.literal(STATE_SUBSCRIBE_VERSION),
|
|
64
|
+
scope: ScopeSchema,
|
|
65
|
+
/** The scope's newest seq — the caller's cursor for the next call, whatever filters applied. */
|
|
66
|
+
head_seq: z.number().int().nonnegative(),
|
|
67
|
+
events: z.array(ChangeEventSchema),
|
|
68
|
+
/** True when facet / subject filters were applied: `events` is then a subsequence and
|
|
69
|
+
* assertChangeSequence does not apply; `head_seq` remains the cursor. */
|
|
70
|
+
filtered: z.boolean(),
|
|
71
|
+
}).strict();
|
|
72
|
+
export function capabilities(store) {
|
|
73
|
+
const own = partitionOf(store);
|
|
74
|
+
const partitions = store.hasPrivate ? ["organization", "team", "user", "repository"] : [own.kind];
|
|
75
|
+
return { protocol: STATE_CONTRACT_VERSION, capabilities: [...STATE_CAPABILITIES], repository: own, partitions };
|
|
76
|
+
}
|
|
77
|
+
// ---- homing --------------------------------------------------------------------------------
|
|
78
|
+
const granted = (principal, scope) => principal.grants.some((g) => scopePath(g) === scopePath(scope));
|
|
79
|
+
function homeFor(store, scope) {
|
|
80
|
+
const own = partitionOf(store);
|
|
81
|
+
if (scopePath(scope) === scopePath(own)) {
|
|
82
|
+
// The store IS this partition: its capture home (public `.hunch/`, or the overlay in shared mode).
|
|
83
|
+
const home = store.captureHome(false);
|
|
84
|
+
return { home, hunchDir: home === "private" ? store.privateDir : hunchPaths(store.publicRoot).hunch, isPrivate: false };
|
|
85
|
+
}
|
|
86
|
+
if (scope.kind === "repository")
|
|
87
|
+
throw new StateRefusal("unsupported", `this store serves ${scopePath(own)}, not ${scopePath(scope)}`);
|
|
88
|
+
if (!store.hasPrivate || !store.privateDir) {
|
|
89
|
+
throw new StateRefusal("no-partition-home", `${scope.kind} partitions never ride a repository; configure an overlay (hunch private / hunch shared) to hold ${scopePath(scope)}`);
|
|
90
|
+
}
|
|
91
|
+
return { home: "private", hunchDir: store.privateDir, isPrivate: true };
|
|
92
|
+
}
|
|
93
|
+
const recordScope = (record, repo) => {
|
|
94
|
+
const s = record.scope;
|
|
95
|
+
const parsed = ScopeSchema.safeParse(s);
|
|
96
|
+
return parsed.success ? parsed.data : repo;
|
|
97
|
+
};
|
|
98
|
+
// ---- read ----------------------------------------------------------------------------------
|
|
99
|
+
function refOf(facet, record, scope) {
|
|
100
|
+
return { facet, id: record.id, record_hash: stateHash(record), scope };
|
|
101
|
+
}
|
|
102
|
+
/** read — the system-of-record answer for a subject, under the delivery envelope's receipt.
|
|
103
|
+
* Grants are the first predicate on every candidate; a matching record in a scope the
|
|
104
|
+
* principal lacks is NAMED in denied_scopes and never described. */
|
|
105
|
+
export function readState(store, input) {
|
|
106
|
+
const request = ReadRequestSchema.parse(input);
|
|
107
|
+
if (!granted(request.principal, request.scope))
|
|
108
|
+
throw new StateRefusal("outside-grants", `scope ${scopePath(request.scope)} is outside the principal's grants`);
|
|
109
|
+
const repo = partitionOf(store);
|
|
110
|
+
const facets = new Set(request.facets ?? STATE_FACETS);
|
|
111
|
+
const target = request.task ?? request.subject ?? scopePath(request.scope);
|
|
112
|
+
const ctx = store.assembleContext(target, request.budget_tokens ?? 1500);
|
|
113
|
+
const envelope = buildDeliveryEnvelope(ctx, {
|
|
114
|
+
root: store.publicRoot,
|
|
115
|
+
symbols: store.recs("symbols"),
|
|
116
|
+
components: store.recs("components"),
|
|
117
|
+
decisionCorpus: store.recs("decisions"),
|
|
118
|
+
profile: request.profile ?? "builder",
|
|
119
|
+
});
|
|
120
|
+
let stateOfRecord = null;
|
|
121
|
+
const denied = new Map();
|
|
122
|
+
if (request.subject !== undefined) {
|
|
123
|
+
const subject = request.subject;
|
|
124
|
+
const current = [];
|
|
125
|
+
const inForce = [];
|
|
126
|
+
const done = [];
|
|
127
|
+
const dependsOn = [];
|
|
128
|
+
const invalidatedBy = new Set();
|
|
129
|
+
/** authorization-before-retrieval: the grant check runs before the record is examined. */
|
|
130
|
+
const admit = (facet, record) => {
|
|
131
|
+
const scope = recordScope(record, repo);
|
|
132
|
+
if (!granted(request.principal, scope)) {
|
|
133
|
+
denied.set(scopePath(scope), scope);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return scope;
|
|
137
|
+
};
|
|
138
|
+
if (facets.has("decisions"))
|
|
139
|
+
for (const d of store.recs("decisions")) {
|
|
140
|
+
if (d.topic !== subject && d.id !== subject)
|
|
141
|
+
continue;
|
|
142
|
+
const scope = admit("decisions", d);
|
|
143
|
+
if (!scope)
|
|
144
|
+
continue;
|
|
145
|
+
if (isLive(d))
|
|
146
|
+
current.push(refOf("decisions", d, scope));
|
|
147
|
+
}
|
|
148
|
+
if (facets.has("constraints"))
|
|
149
|
+
for (const c of store.recs("constraints")) {
|
|
150
|
+
if (c.id !== subject && !c.scope.includes(subject))
|
|
151
|
+
continue;
|
|
152
|
+
const scope = admit("constraints", c);
|
|
153
|
+
if (!scope)
|
|
154
|
+
continue;
|
|
155
|
+
if (c.status === "active" && c.valid_to == null)
|
|
156
|
+
inForce.push(refOf("constraints", c, scope));
|
|
157
|
+
}
|
|
158
|
+
if (facets.has("receipts"))
|
|
159
|
+
for (const r of store.recs("receipts")) {
|
|
160
|
+
const targets = r.id === subject || r.invalidates.includes(subject) || `${r.target.object_type}:${r.target.object_key}` === subject;
|
|
161
|
+
if (!targets)
|
|
162
|
+
continue;
|
|
163
|
+
const scope = admit("receipts", r);
|
|
164
|
+
if (!scope)
|
|
165
|
+
continue;
|
|
166
|
+
if (r.state === "succeeded" || r.state === "verified")
|
|
167
|
+
done.push(refOf("receipts", r, scope));
|
|
168
|
+
if (r.invalidates.includes(subject))
|
|
169
|
+
invalidatedBy.add(r.id);
|
|
170
|
+
}
|
|
171
|
+
if (facets.has("commitments"))
|
|
172
|
+
for (const c of store.recs("commitments")) {
|
|
173
|
+
if (c.subject !== subject && c.id !== subject)
|
|
174
|
+
continue;
|
|
175
|
+
const scope = admit("commitments", c);
|
|
176
|
+
if (!scope)
|
|
177
|
+
continue;
|
|
178
|
+
if ((c.status === "open" || c.status === "waiting") && c.valid_to == null)
|
|
179
|
+
inForce.push(refOf("commitments", c, scope));
|
|
180
|
+
}
|
|
181
|
+
if (facets.has("derived"))
|
|
182
|
+
for (const d of store.recs("derived")) {
|
|
183
|
+
if (d.subject !== subject && d.id !== subject)
|
|
184
|
+
continue;
|
|
185
|
+
const scope = admit("derived", d);
|
|
186
|
+
if (!scope)
|
|
187
|
+
continue;
|
|
188
|
+
if (d.state === "current" && d.valid_to == null) {
|
|
189
|
+
current.push(refOf("derived", d, scope));
|
|
190
|
+
dependsOn.push(...d.dependencies);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (facets.has("entities"))
|
|
194
|
+
for (const e of store.recs("entities")) {
|
|
195
|
+
if (e.id !== subject)
|
|
196
|
+
continue;
|
|
197
|
+
const scope = admit("entities", e);
|
|
198
|
+
if (!scope)
|
|
199
|
+
continue;
|
|
200
|
+
if (e.lifecycle === "active")
|
|
201
|
+
current.push(refOf("entities", e, scope));
|
|
202
|
+
}
|
|
203
|
+
if (facets.has("relationships"))
|
|
204
|
+
for (const r of store.recs("relationships")) {
|
|
205
|
+
if (r.from !== subject && r.to !== subject)
|
|
206
|
+
continue;
|
|
207
|
+
const scope = admit("relationships", r);
|
|
208
|
+
if (!scope)
|
|
209
|
+
continue;
|
|
210
|
+
current.push(refOf("relationships", r, scope));
|
|
211
|
+
}
|
|
212
|
+
stateOfRecord = { subject, current, in_force: inForce, done, depends_on: dependsOn, invalidated_by: [...invalidatedBy].sort() };
|
|
213
|
+
}
|
|
214
|
+
const response = ReadResponseSchema.parse({
|
|
215
|
+
schema: STATE_READ_VERSION,
|
|
216
|
+
receipt_id: envelope.receipt_id,
|
|
217
|
+
scope: request.scope,
|
|
218
|
+
state_of_record: stateOfRecord,
|
|
219
|
+
denied_scopes: [...denied.values()],
|
|
220
|
+
});
|
|
221
|
+
assertReadWithinGrants(request.principal, response);
|
|
222
|
+
return { response, envelope };
|
|
223
|
+
}
|
|
224
|
+
/** What a record is ABOUT, for subscribers filtering by subject. Mirrors the read verb's matching. */
|
|
225
|
+
function subjectOf(facet, record) {
|
|
226
|
+
const r = record;
|
|
227
|
+
switch (facet) {
|
|
228
|
+
case "commitments":
|
|
229
|
+
case "derived": return typeof r.subject === "string" ? r.subject : undefined;
|
|
230
|
+
case "entities": return typeof r.id === "string" ? r.id : undefined;
|
|
231
|
+
case "relationships": return typeof r.from === "string" ? r.from : undefined;
|
|
232
|
+
case "receipts": {
|
|
233
|
+
const t = r.target;
|
|
234
|
+
return t?.object_type && t.object_key ? `${t.object_type}:${t.object_key}` : undefined;
|
|
235
|
+
}
|
|
236
|
+
case "decisions": return typeof r.topic === "string" ? r.topic : undefined;
|
|
237
|
+
default: return undefined;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/** Records the store can close a valid-time window on when superseded. */
|
|
241
|
+
function closeWindow(store, facet, incumbentId, byId, at, isPrivate) {
|
|
242
|
+
if (facet === "decisions") {
|
|
243
|
+
const by = store.getRec("decisions", byId);
|
|
244
|
+
if (!by)
|
|
245
|
+
return false;
|
|
246
|
+
return (isPrivate ? store.supersedePrivate(incumbentId, by) : store.supersede(incumbentId, by)) !== null;
|
|
247
|
+
}
|
|
248
|
+
const old = store.getRec(facet, incumbentId);
|
|
249
|
+
if (!old || !("valid_to" in old))
|
|
250
|
+
return false;
|
|
251
|
+
const closed = { ...old, valid_to: old.valid_to ?? at, ...(facet === "derived" ? { state: "stale" } : {}) };
|
|
252
|
+
store.putCapture(facet, closed, isPrivate);
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
/** Normalize + validate the record for its facet; enforce the identity rule (an id, when
|
|
256
|
+
* given, must be the one the record's facts derive). Returns the canonical record. */
|
|
257
|
+
function normalizeRecord(facet, scope, raw, principal) {
|
|
258
|
+
const record = { ...raw };
|
|
259
|
+
if (LEGACY_FACETS.has(facet)) {
|
|
260
|
+
// Legacy records carry no partition scope (a constraint's `scope` is its path globs);
|
|
261
|
+
// the repository scope is implied, so an echoed partition is dropped, not stored.
|
|
262
|
+
if (ScopeSchema.safeParse(record.scope).success)
|
|
263
|
+
delete record.scope;
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
record.scope = scope;
|
|
267
|
+
}
|
|
268
|
+
// Authorship tier (memory supply chain): only a human principal may sign as
|
|
269
|
+
// human_confirmed through this path; an agent's write is agent testimony.
|
|
270
|
+
const prov = record.provenance;
|
|
271
|
+
if (prov && typeof prov.source === "string" && principal.kind !== "human" && prov.source.split("+").includes("human_confirmed")) {
|
|
272
|
+
record.provenance = { ...prov, source: prov.source.split("+").map((t) => (t === "human_confirmed" ? "agent_recorded" : t)).join("+") };
|
|
273
|
+
}
|
|
274
|
+
let expectedId = null;
|
|
275
|
+
try {
|
|
276
|
+
if (facet === "receipts")
|
|
277
|
+
expectedId = actionReceiptId(record);
|
|
278
|
+
else if (facet === "commitments")
|
|
279
|
+
expectedId = commitmentId(record);
|
|
280
|
+
else if (facet === "derived")
|
|
281
|
+
expectedId = derivedId(record);
|
|
282
|
+
else if (facet === "decisions" && typeof record.id !== "string")
|
|
283
|
+
expectedId = decisionId(String(record.topic ?? record.title ?? ""));
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
throw new StateRefusal("malformed", `cannot derive ${facet} identity: ${e.message}`);
|
|
287
|
+
}
|
|
288
|
+
if (expectedId) {
|
|
289
|
+
if (typeof record.id === "string" && record.id !== expectedId)
|
|
290
|
+
throw new StateRefusal("identity", `${facet} id ${record.id} is not the identity its facts derive (${expectedId}); ids are derived, never chosen`);
|
|
291
|
+
record.id = expectedId;
|
|
292
|
+
}
|
|
293
|
+
const parsed = SCHEMAS[facet].safeParse(record);
|
|
294
|
+
if (!parsed.success)
|
|
295
|
+
throw new StateRefusal("malformed", `${facet} record is malformed: ${parsed.error.issues.map((i) => `${i.path.join(".") || "record"}: ${i.message}`).join("; ")}`);
|
|
296
|
+
if (facet === "derived") {
|
|
297
|
+
try {
|
|
298
|
+
assertDerivedState(parsed.data);
|
|
299
|
+
}
|
|
300
|
+
catch (e) {
|
|
301
|
+
throw new StateRefusal("malformed", e.message);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return parsed.data;
|
|
305
|
+
}
|
|
306
|
+
/** write — provenance + idempotency in, durability out. A replay returns the original;
|
|
307
|
+
* a conflict names the incumbent; nothing is ever silently overwritten or duplicated. */
|
|
308
|
+
export function writeState(store, input, opts = {}) {
|
|
309
|
+
const request = WriteRequestSchema.parse(input);
|
|
310
|
+
try {
|
|
311
|
+
assertWriteWellFormed(request);
|
|
312
|
+
}
|
|
313
|
+
catch (e) {
|
|
314
|
+
throw new StateRefusal(/grants/.test(e.message) ? "outside-grants" : "malformed", e.message);
|
|
315
|
+
}
|
|
316
|
+
const { home, hunchDir, isPrivate } = homeFor(store, request.scope);
|
|
317
|
+
const now = (opts.now ?? (() => new Date()))().toISOString();
|
|
318
|
+
const facet = request.facet;
|
|
319
|
+
if (!ENTITY_KINDS.includes(facet))
|
|
320
|
+
throw new StateRefusal("unsupported", `facet ${facet} is not a store kind`);
|
|
321
|
+
const record = normalizeRecord(facet, request.scope, request.record, request.principal);
|
|
322
|
+
const id = record.id;
|
|
323
|
+
const hash = stateHash(record);
|
|
324
|
+
const ledger = readLedger(hunchDir, request.scope);
|
|
325
|
+
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 });
|
|
327
|
+
// Idempotency: the same key replays the original; the same key with a different payload
|
|
328
|
+
// is a refusal, never a second record.
|
|
329
|
+
const seen = ledger.idempotency[request.idempotency_key];
|
|
330
|
+
if (seen) {
|
|
331
|
+
if (seen.record_hash === hash && seen.record_id === id)
|
|
332
|
+
return result("replayed");
|
|
333
|
+
throw new StateRefusal("idempotency", `idempotency key was already used for ${seen.record_id} with a different payload`, { incumbent_id: seen.record_id, reason: "idempotency key reused with a different payload" });
|
|
334
|
+
}
|
|
335
|
+
const existing = store.recsInHome(facet, home).find((r) => r.id === id);
|
|
336
|
+
if (existing && stateHash(existing) === hash) {
|
|
337
|
+
appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, facet } }, now);
|
|
338
|
+
return result("replayed");
|
|
339
|
+
}
|
|
340
|
+
if (existing && request.expected_version !== null) {
|
|
341
|
+
const ok = typeof request.expected_version === "number"
|
|
342
|
+
? latestSeqFor(ledger, id) === request.expected_version
|
|
343
|
+
: stateHash(existing) === request.expected_version;
|
|
344
|
+
if (!ok)
|
|
345
|
+
throw new StateRefusal("conflict", `expected_version does not match the incumbent ${id}`, { incumbent_id: id, reason: "expected_version mismatch" });
|
|
346
|
+
}
|
|
347
|
+
// one-live-decision-per-topic — refuse with the incumbent named; supersession is explicit.
|
|
348
|
+
let supersedes = request.supersedes ?? null;
|
|
349
|
+
if (facet === "decisions") {
|
|
350
|
+
const d = record;
|
|
351
|
+
if (d.topic && d.status === "accepted") {
|
|
352
|
+
const willClose = supersedes && store.recsInHome("decisions", home).some((x) => x.id === supersedes) ? supersedes : null;
|
|
353
|
+
const conflicts = captureConflicts(store.recsInHome("decisions", home), d.topic, d.id, willClose);
|
|
354
|
+
if (conflicts.length) {
|
|
355
|
+
throw new StateRefusal("conflict", `topic ${d.topic} already has a live decision ${conflicts[0].id}; pass supersedes to replace it`, { incumbent_id: conflicts[0].id, reason: "one-live-decision-per-topic" });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (supersedes && !store.recsInHome(facet, home).some((r) => r.id === supersedes)) {
|
|
360
|
+
throw new StateRefusal("conflict", `supersedes ${supersedes} is not a ${facet} record in this partition`, { incumbent_id: supersedes, reason: "supersede target absent" });
|
|
361
|
+
}
|
|
362
|
+
if (supersedes === id)
|
|
363
|
+
supersedes = null;
|
|
364
|
+
store.putCapture(facet, record, isPrivate);
|
|
365
|
+
const changes = [];
|
|
366
|
+
const cause = { kind: "write", principal: request.principal.id };
|
|
367
|
+
const invalidates = facet === "receipts" ? record.invalidates : [];
|
|
368
|
+
const subject = subjectOf(facet, record);
|
|
369
|
+
if (supersedes) {
|
|
370
|
+
const closed = closeWindow(store, facet, supersedes, id, now, isPrivate);
|
|
371
|
+
if (closed) {
|
|
372
|
+
const old = store.getRec(facet, supersedes);
|
|
373
|
+
changes.push({ facet, record_id: supersedes, record_hash: stateHash(old), change: "superseded", subject: subjectOf(facet, old), invalidates: [], cause });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
changes.push({ facet, record_id: id, record_hash: hash, change: existing ? "updated" : "created", subject, invalidates, cause });
|
|
377
|
+
appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, facet } }, now);
|
|
378
|
+
store.reindex();
|
|
379
|
+
return result(supersedes ? "superseded" : existing ? "updated" : "created");
|
|
380
|
+
}
|
|
381
|
+
// ---- subscribe -----------------------------------------------------------------------------
|
|
382
|
+
/** subscribe — the scope's ordered change stream after a cursor. Unfiltered, the events are
|
|
383
|
+
* contiguous and assertChangeSequence holds; filtered, `head_seq` is still the cursor. */
|
|
384
|
+
export function subscribeState(store, input) {
|
|
385
|
+
const request = SubscribeRequestSchema.parse(input);
|
|
386
|
+
if (!granted(request.principal, request.scope))
|
|
387
|
+
throw new StateRefusal("outside-grants", `scope ${scopePath(request.scope)} is outside the principal's grants`);
|
|
388
|
+
const { hunchDir } = homeFor(store, request.scope);
|
|
389
|
+
const ledger = readLedger(hunchDir, request.scope);
|
|
390
|
+
const facets = request.facets ? new Set(request.facets) : null;
|
|
391
|
+
const subjects = request.subjects ? new Set(request.subjects) : null;
|
|
392
|
+
const filtered = !!(facets || subjects);
|
|
393
|
+
const events = ledger.events.filter((e) => e.seq > request.after_seq
|
|
394
|
+
&& (!facets || facets.has(e.facet))
|
|
395
|
+
&& (!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 });
|
|
397
|
+
}
|
|
398
|
+
//# sourceMappingURL=stateBinding.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.26.0",
|
|
4
4
|
"mcpName": "io.github.davesheffer/hunch",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"types": "./dist/projectDna.d.ts",
|
|
27
27
|
"default": "./dist/projectDna.js"
|
|
28
28
|
},
|
|
29
|
+
"./state": "./dist/client/state.js",
|
|
29
30
|
"./dist/*": "./dist/*",
|
|
30
31
|
"./package.json": "./package.json"
|
|
31
32
|
},
|
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.26.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.26.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|