@davesheffer/hunch 1.26.2 → 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/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")
@@ -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}$/;
@@ -110,6 +111,9 @@ export const WriteResultSchema = z.object({
110
111
  durability: z.enum(DURABILITY),
111
112
  outcome: z.enum(["created", "updated", "replayed", "superseded"]),
112
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(),
113
117
  }).strict();
114
118
  export const SubscribeRequestSchema = z.object({
115
119
  schema: z.literal(STATE_SUBSCRIBE_VERSION),
@@ -138,6 +142,23 @@ export const ChangeEventSchema = z.object({
138
142
  z.object({ kind: z.literal("write"), principal: z.string().regex(TOKEN) }).strict(),
139
143
  ]).optional(),
140
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();
141
162
  export const CapabilityNegotiationSchema = z.object({
142
163
  protocol: z.literal(STATE_CONTRACT_VERSION),
143
164
  capabilities: z.array(z.string().max(128)).max(64),
@@ -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";
@@ -1777,6 +1777,22 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1777
1777
  return stateRefusal(e);
1778
1778
  }
1779
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
+ });
1780
1796
  // -- hunch_findings (read: the open-observations ledger) --------------------
1781
1797
  server.registerTool("hunch_findings", {
1782
1798
  title: "Open findings for a scope",
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) {
@@ -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
@@ -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);
@@ -243,6 +248,11 @@ function subjectOf(facet, record) {
243
248
  default: return undefined;
244
249
  }
245
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
+ }
246
256
  /** Records the store can close a valid-time window on when superseded. */
247
257
  function closeWindow(store, facet, incumbentId, byId, at, isPrivate) {
248
258
  if (facet === "decisions") {
@@ -329,14 +339,19 @@ export function writeState(store, input, opts = {}) {
329
339
  const hash = stateHash(record);
330
340
  const ledger = readLedger(hunchDir, request.scope);
331
341
  const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
332
- 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 });
333
343
  // Idempotency: the same key replays the original; the same key with a different payload
334
344
  // is a refusal, never a second record.
335
345
  const seen = ledger.idempotency[request.idempotency_key];
336
346
  if (seen) {
337
347
  if (seen.record_hash === hash && seen.record_id === id)
338
348
  return result("replayed");
339
- 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" });
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" });
340
355
  }
341
356
  const existing = store.recsInHome(facet, home).find((r) => r.id === id);
342
357
  if (existing && stateHash(existing) === hash) {
@@ -396,9 +411,46 @@ export function subscribeState(store, input) {
396
411
  const facets = request.facets ? new Set(request.facets) : null;
397
412
  const subjects = request.subjects ? new Set(request.subjects) : null;
398
413
  const filtered = !!(facets || subjects);
399
- const events = ledger.events.filter((e) => e.seq > request.after_seq
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
400
417
  && (!facets || facets.has(e.facet))
401
418
  && (!subjects || subjects.has(e.record_id) || (e.subject !== undefined && subjects.has(e.subject)) || e.invalidates.some((s) => subjects.has(s))));
402
- 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 });
403
455
  }
404
456
  //# sourceMappingURL=stateBinding.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.26.2",
3
+ "version": "1.27.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.26.2",
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.26.2",
16
+ "version": "1.27.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {