@crewhaus/state-store 0.1.3 → 0.1.5

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.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Catalog R7 `state-store` — tiny zustand-style in-process state container.
3
+ *
4
+ * `createStore<T>(initial)` returns a `Store<T>` exposing `get`, `set`,
5
+ * `subscribe`, and `select`. The root listener fires whenever `set` produces
6
+ * a state where `Object.is(next, prev) === false`. `select(selector)`
7
+ * returns a derived view whose listeners fire only when
8
+ * `Object.is(selector(next), selector(prev)) === false` — referential
9
+ * equality on the selector output, mirroring zustand's
10
+ * `subscribeWithSelector` middleware.
11
+ *
12
+ * Used per-`runChatLoop` invocation as a coordination surface for hooks,
13
+ * skills, and tools (Section 11+). The runtime instantiates one per run
14
+ * and threads it through `RunContext` consumers.
15
+ *
16
+ * Reference: `claude-code/state/store.ts` (40 lines).
17
+ */
18
+ export type Store<T> = {
19
+ get(): T;
20
+ set(partial: Partial<T> | ((s: T) => Partial<T>)): void;
21
+ subscribe(listener: (next: T, prev: T) => void): () => void;
22
+ select<U>(selector: (s: T) => U): SelectorView<U>;
23
+ };
24
+ export type SelectorView<U> = {
25
+ get(): U;
26
+ subscribe(listener: (next: U, prev: U) => void): () => void;
27
+ };
28
+ /**
29
+ * Build a fresh state container around `initial`. The container's `set`
30
+ * shallow-merges the supplied partial (or the partial returned by the
31
+ * functional form) into the current state and notifies subscribers iff the
32
+ * merged reference differs from the previous reference (`Object.is`).
33
+ *
34
+ * Listener exceptions are swallowed and reported via `console.error` so a
35
+ * misbehaving subscriber does not poison its siblings — keeps the
36
+ * notification semantics predictable for downstream tools/hooks.
37
+ */
38
+ export declare function createStore<T extends object>(initial: T): Store<T>;
package/dist/index.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Catalog R7 `state-store` — tiny zustand-style in-process state container.
3
+ *
4
+ * `createStore<T>(initial)` returns a `Store<T>` exposing `get`, `set`,
5
+ * `subscribe`, and `select`. The root listener fires whenever `set` produces
6
+ * a state where `Object.is(next, prev) === false`. `select(selector)`
7
+ * returns a derived view whose listeners fire only when
8
+ * `Object.is(selector(next), selector(prev)) === false` — referential
9
+ * equality on the selector output, mirroring zustand's
10
+ * `subscribeWithSelector` middleware.
11
+ *
12
+ * Used per-`runChatLoop` invocation as a coordination surface for hooks,
13
+ * skills, and tools (Section 11+). The runtime instantiates one per run
14
+ * and threads it through `RunContext` consumers.
15
+ *
16
+ * Reference: `claude-code/state/store.ts` (40 lines).
17
+ */
18
+ /**
19
+ * Build a fresh state container around `initial`. The container's `set`
20
+ * shallow-merges the supplied partial (or the partial returned by the
21
+ * functional form) into the current state and notifies subscribers iff the
22
+ * merged reference differs from the previous reference (`Object.is`).
23
+ *
24
+ * Listener exceptions are swallowed and reported via `console.error` so a
25
+ * misbehaving subscriber does not poison its siblings — keeps the
26
+ * notification semantics predictable for downstream tools/hooks.
27
+ */
28
+ export function createStore(initial) {
29
+ let state = initial;
30
+ const listeners = new Set();
31
+ function get() {
32
+ return state;
33
+ }
34
+ function set(partial) {
35
+ const change = typeof partial === "function" ? partial(state) : partial;
36
+ const next = { ...state, ...change };
37
+ if (Object.is(next, state))
38
+ return;
39
+ const prev = state;
40
+ state = next;
41
+ for (const l of listeners) {
42
+ try {
43
+ l(next, prev);
44
+ }
45
+ catch (err) {
46
+ console.error("state-store: subscriber threw", err);
47
+ }
48
+ }
49
+ }
50
+ function subscribe(listener) {
51
+ listeners.add(listener);
52
+ return () => {
53
+ listeners.delete(listener);
54
+ };
55
+ }
56
+ function select(selector) {
57
+ return {
58
+ get() {
59
+ return selector(state);
60
+ },
61
+ subscribe(listener) {
62
+ const wrapped = (next, prev) => {
63
+ const a = selector(next);
64
+ const b = selector(prev);
65
+ if (Object.is(a, b))
66
+ return;
67
+ listener(a, b);
68
+ };
69
+ return subscribe(wrapped);
70
+ },
71
+ };
72
+ }
73
+ return { get, set, subscribe, select };
74
+ }
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@crewhaus/state-store",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Zustand-style in-process state container with referential-equality selectors",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
@@ -29,5 +32,5 @@
29
32
  "publishConfig": {
30
33
  "access": "public"
31
34
  },
32
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
35
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
33
36
  }
package/src/index.test.ts DELETED
@@ -1,240 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { createStore } from "./index";
3
-
4
- describe("createStore — basic semantics", () => {
5
- test("get() returns the initial state", () => {
6
- const s = createStore({ a: 1, b: "x" });
7
- expect(s.get()).toEqual({ a: 1, b: "x" });
8
- });
9
-
10
- test("set(partial) shallow-merges and updates get()", () => {
11
- const s = createStore({ a: 1, b: "x" });
12
- s.set({ a: 2 });
13
- expect(s.get()).toEqual({ a: 2, b: "x" });
14
- s.set({ b: "y" });
15
- expect(s.get()).toEqual({ a: 2, b: "y" });
16
- });
17
-
18
- test("set accepts a functional updater that receives current state", () => {
19
- const s = createStore({ count: 0 });
20
- s.set((curr) => ({ count: curr.count + 1 }));
21
- s.set((curr) => ({ count: curr.count + 1 }));
22
- expect(s.get().count).toBe(2);
23
- });
24
-
25
- test("subscribe fires on each set with (next, prev)", () => {
26
- const s = createStore({ a: 1 });
27
- const calls: Array<{ next: { a: number }; prev: { a: number } }> = [];
28
- s.subscribe((next, prev) => {
29
- calls.push({ next, prev });
30
- });
31
- s.set({ a: 2 });
32
- s.set({ a: 3 });
33
- expect(calls.length).toBe(2);
34
- expect(calls[0]?.prev.a).toBe(1);
35
- expect(calls[0]?.next.a).toBe(2);
36
- expect(calls[1]?.prev.a).toBe(2);
37
- expect(calls[1]?.next.a).toBe(3);
38
- });
39
-
40
- test("unsubscribe stops further notifications", () => {
41
- const s = createStore({ a: 0 });
42
- let count = 0;
43
- const unsub = s.subscribe(() => {
44
- count += 1;
45
- });
46
- s.set({ a: 1 });
47
- unsub();
48
- s.set({ a: 2 });
49
- s.set({ a: 3 });
50
- expect(count).toBe(1);
51
- });
52
-
53
- test("multiple subscribers each receive every change", () => {
54
- const s = createStore({ a: 0 });
55
- let n1 = 0;
56
- let n2 = 0;
57
- s.subscribe(() => {
58
- n1 += 1;
59
- });
60
- s.subscribe(() => {
61
- n2 += 1;
62
- });
63
- s.set({ a: 1 });
64
- s.set({ a: 2 });
65
- expect(n1).toBe(2);
66
- expect(n2).toBe(2);
67
- });
68
-
69
- test("a throwing subscriber does not block sibling subscribers", () => {
70
- const s = createStore({ a: 0 });
71
- const originalError = console.error;
72
- const errs: unknown[] = [];
73
- console.error = (...args: unknown[]) => {
74
- errs.push(args);
75
- };
76
- try {
77
- s.subscribe(() => {
78
- throw new Error("boom");
79
- });
80
- let saw = 0;
81
- s.subscribe(() => {
82
- saw += 1;
83
- });
84
- s.set({ a: 1 });
85
- expect(saw).toBe(1);
86
- expect(errs.length).toBe(1);
87
- } finally {
88
- console.error = originalError;
89
- }
90
- });
91
- });
92
-
93
- describe("createStore — selectors", () => {
94
- type State = { a: number; b: string; nested: { x: number } };
95
-
96
- test("select(s => s.a).get() returns the current projected value", () => {
97
- const s = createStore<State>({ a: 1, b: "x", nested: { x: 10 } });
98
- const view = s.select((curr) => curr.a);
99
- expect(view.get()).toBe(1);
100
- s.set({ a: 5 });
101
- expect(view.get()).toBe(5);
102
- });
103
-
104
- test("selector subscriber fires only when projection changes (Object.is)", () => {
105
- const s = createStore<State>({ a: 1, b: "x", nested: { x: 10 } });
106
- const view = s.select((curr) => curr.a);
107
- let fires = 0;
108
- view.subscribe(() => {
109
- fires += 1;
110
- });
111
- s.set({ b: "y" }); // does not change a
112
- s.set({ b: "z" }); // does not change a
113
- expect(fires).toBe(0);
114
- s.set({ a: 2 }); // changes a
115
- expect(fires).toBe(1);
116
- s.set({ a: 2 }); // re-set to same value — Object.is true → no fire
117
- expect(fires).toBe(1);
118
- s.set({ a: 3 });
119
- expect(fires).toBe(2);
120
- });
121
-
122
- test("selector receives the projected (next, prev), not the root state", () => {
123
- const s = createStore<State>({ a: 1, b: "x", nested: { x: 10 } });
124
- const view = s.select((curr) => curr.a);
125
- const calls: Array<{ next: number; prev: number }> = [];
126
- view.subscribe((next, prev) => {
127
- calls.push({ next, prev });
128
- });
129
- s.set({ a: 7 });
130
- s.set({ a: 9 });
131
- expect(calls).toEqual([
132
- { next: 7, prev: 1 },
133
- { next: 9, prev: 7 },
134
- ]);
135
- });
136
-
137
- test("selectors over object projections compare by reference (Object.is)", () => {
138
- const s = createStore<State>({ a: 1, b: "x", nested: { x: 10 } });
139
- const view = s.select((curr) => curr.nested);
140
- let fires = 0;
141
- view.subscribe(() => {
142
- fires += 1;
143
- });
144
- s.set({ a: 2 }); // nested ref unchanged
145
- expect(fires).toBe(0);
146
- s.set({ nested: { x: 11 } }); // nested ref changed (new object literal)
147
- expect(fires).toBe(1);
148
- });
149
-
150
- test("unsubscribing the selector view stops further fires", () => {
151
- const s = createStore<State>({ a: 1, b: "x", nested: { x: 10 } });
152
- const view = s.select((curr) => curr.a);
153
- let fires = 0;
154
- const unsub = view.subscribe(() => {
155
- fires += 1;
156
- });
157
- s.set({ a: 2 });
158
- unsub();
159
- s.set({ a: 3 });
160
- s.set({ a: 4 });
161
- expect(fires).toBe(1);
162
- });
163
- });
164
-
165
- // ---------------------------------------------------------------------------
166
- // T9 — property tests
167
- // ---------------------------------------------------------------------------
168
- //
169
- // Hand-rolled randomized testing (no fast-check dep) following the pattern in
170
- // `tool-orchestrator/src/index.test.ts`. Seeded so failures reproduce.
171
- // ---------------------------------------------------------------------------
172
-
173
- function mulberry32(seed: number): () => number {
174
- let t = seed | 0;
175
- return () => {
176
- t = (t + 0x6d2b79f5) | 0;
177
- let r = Math.imul(t ^ (t >>> 15), 1 | t);
178
- r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
179
- return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
180
- };
181
- }
182
-
183
- describe("createStore — T9 property", () => {
184
- test("100 random sequences: model state matches store state", () => {
185
- const rand = mulberry32(0xc0ffee);
186
- const KEYS = ["a", "b", "c", "d"] as const;
187
- type Key = (typeof KEYS)[number];
188
- type S = Record<Key, number>;
189
-
190
- for (let iter = 0; iter < 100; iter++) {
191
- const initial: S = { a: 0, b: 0, c: 0, d: 0 };
192
- const store = createStore<S>({ ...initial });
193
- const model: S = { ...initial };
194
-
195
- const opCount = 5 + Math.floor(rand() * 25);
196
- for (let op = 0; op < opCount; op++) {
197
- const key = KEYS[Math.floor(rand() * KEYS.length)] as Key;
198
- const value = Math.floor(rand() * 10);
199
- store.set({ [key]: value } as Partial<S>);
200
- model[key] = value;
201
- }
202
-
203
- expect(store.get()).toEqual(model);
204
- }
205
- });
206
-
207
- test("100 random sequences: selector fires == count of distinct projections", () => {
208
- const rand = mulberry32(0xb16b00b5);
209
- const KEYS = ["a", "b", "c"] as const;
210
- type Key = (typeof KEYS)[number];
211
- type S = Record<Key, number>;
212
-
213
- for (let iter = 0; iter < 100; iter++) {
214
- const store = createStore<S>({ a: 0, b: 0, c: 0 });
215
- const view = store.select((s) => s.a);
216
-
217
- let fires = 0;
218
- view.subscribe(() => {
219
- fires += 1;
220
- });
221
-
222
- let prevA = 0;
223
- let expectedFires = 0;
224
- const opCount = 5 + Math.floor(rand() * 30);
225
- for (let op = 0; op < opCount; op++) {
226
- const key = KEYS[Math.floor(rand() * KEYS.length)] as Key;
227
- const value = Math.floor(rand() * 5);
228
- store.set({ [key]: value } as Partial<S>);
229
- if (key === "a") {
230
- if (!Object.is(value, prevA)) {
231
- expectedFires += 1;
232
- }
233
- prevA = value;
234
- }
235
- // mutating any other key never bumps expectedFires
236
- }
237
- expect(fires).toBe(expectedFires);
238
- }
239
- });
240
- });
package/src/index.ts DELETED
@@ -1,89 +0,0 @@
1
- /**
2
- * Catalog R7 `state-store` — tiny zustand-style in-process state container.
3
- *
4
- * `createStore<T>(initial)` returns a `Store<T>` exposing `get`, `set`,
5
- * `subscribe`, and `select`. The root listener fires whenever `set` produces
6
- * a state where `Object.is(next, prev) === false`. `select(selector)`
7
- * returns a derived view whose listeners fire only when
8
- * `Object.is(selector(next), selector(prev)) === false` — referential
9
- * equality on the selector output, mirroring zustand's
10
- * `subscribeWithSelector` middleware.
11
- *
12
- * Used per-`runChatLoop` invocation as a coordination surface for hooks,
13
- * skills, and tools (Section 11+). The runtime instantiates one per run
14
- * and threads it through `RunContext` consumers.
15
- *
16
- * Reference: `claude-code/state/store.ts` (40 lines).
17
- */
18
-
19
- export type Store<T> = {
20
- get(): T;
21
- set(partial: Partial<T> | ((s: T) => Partial<T>)): void;
22
- subscribe(listener: (next: T, prev: T) => void): () => void;
23
- select<U>(selector: (s: T) => U): SelectorView<U>;
24
- };
25
-
26
- export type SelectorView<U> = {
27
- get(): U;
28
- subscribe(listener: (next: U, prev: U) => void): () => void;
29
- };
30
-
31
- /**
32
- * Build a fresh state container around `initial`. The container's `set`
33
- * shallow-merges the supplied partial (or the partial returned by the
34
- * functional form) into the current state and notifies subscribers iff the
35
- * merged reference differs from the previous reference (`Object.is`).
36
- *
37
- * Listener exceptions are swallowed and reported via `console.error` so a
38
- * misbehaving subscriber does not poison its siblings — keeps the
39
- * notification semantics predictable for downstream tools/hooks.
40
- */
41
- export function createStore<T extends object>(initial: T): Store<T> {
42
- let state: T = initial;
43
- const listeners = new Set<(next: T, prev: T) => void>();
44
-
45
- function get(): T {
46
- return state;
47
- }
48
-
49
- function set(partial: Partial<T> | ((s: T) => Partial<T>)): void {
50
- const change = typeof partial === "function" ? partial(state) : partial;
51
- const next = { ...state, ...change } as T;
52
- if (Object.is(next, state)) return;
53
- const prev = state;
54
- state = next;
55
- for (const l of listeners) {
56
- try {
57
- l(next, prev);
58
- } catch (err) {
59
- console.error("state-store: subscriber threw", err);
60
- }
61
- }
62
- }
63
-
64
- function subscribe(listener: (next: T, prev: T) => void): () => void {
65
- listeners.add(listener);
66
- return () => {
67
- listeners.delete(listener);
68
- };
69
- }
70
-
71
- function select<U>(selector: (s: T) => U): SelectorView<U> {
72
- return {
73
- get(): U {
74
- return selector(state);
75
- },
76
- subscribe(listener: (next: U, prev: U) => void): () => void {
77
- const wrapped = (next: T, prev: T): void => {
78
- const a = selector(next);
79
- const b = selector(prev);
80
- if (Object.is(a, b)) return;
81
- listener(a, b);
82
- };
83
- return subscribe(wrapped);
84
- },
85
- };
86
- }
87
-
88
- return { get, set, subscribe, select };
89
- }