@castlemilk/omega 0.6.32 → 0.6.33

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.
@@ -13,6 +13,7 @@ When a task asks you to add atomic/signal selectors to Kea, treat it as a framew
13
13
  - Do NOT create or modify test files. The verifier supplies its own tests.
14
14
  - Do NOT leave the engine in a standalone file without wiring it into `selectors.ts`, `build.ts`, and `kea.ts`.
15
15
  - Do NOT modify `rollup.config.js`, `tsconfig.json`, or build tooling unless explicitly required.
16
+ - Do NOT break React/Redux referential equality. The tracking proxy must record reads but return the original underlying values to consumers.
16
17
 
17
18
  ## Step-by-step wiring
18
19
 
@@ -24,11 +25,10 @@ Edit `src/kea/context.ts`. Add `atomicSelectors: false` to the default options m
24
25
 
25
26
  ### Step 2 — Atomic engine helper
26
27
 
27
- Create `src/kea/atomic.ts` with the starter below. This engine tracks leaf-level reads, selector dependencies, evaluations, and dirtyCause.
28
+ Create `src/kea/atomic.ts` with the starter below.
28
29
 
29
30
  ```ts
30
31
  import { getContext } from './context'
31
- import { Selector } from '../types'
32
32
 
33
33
  export interface AtomicSelectorHealth {
34
34
  dependencies: string[]
@@ -53,6 +53,7 @@ interface SelectorMeta {
53
53
  }
54
54
 
55
55
  const engines = new WeakMap<any, Map<string, SelectorMeta>>()
56
+ const evaluationStack = new Set<string>()
56
57
 
57
58
  export function getAtomicEngine(logic: any): Map<string, SelectorMeta> {
58
59
  if (!engines.has(logic)) {
@@ -80,15 +81,20 @@ export function registerAtomicSelector(logic: any, name: string): void {
80
81
  }
81
82
  }
82
83
 
83
- export function recordAtomicDependency(logic: any, selectorName: string, leafPath: string): void {
84
+ export function recordAtomicDependency(logic: any, selectorName: string, path: string): void {
84
85
  const engine = getAtomicEngine(logic)
85
86
  const meta = engine.get(selectorName)
86
87
  if (!meta) return
87
- meta.dependencies.add(leafPath)
88
- for (const [otherName, otherMeta] of engine) {
89
- if (otherName !== selectorName && otherMeta.dependencies.has(selectorName)) {
90
- otherMeta.dependents.add(selectorName)
91
- }
88
+ meta.dependencies.add(path)
89
+ }
90
+
91
+ export function recordAtomicSelectorDependency(logic: any, selectorName: string, inputSelectorName: string): void {
92
+ const engine = getAtomicEngine(logic)
93
+ const meta = engine.get(selectorName)
94
+ const inputMeta = engine.get(inputSelectorName)
95
+ if (meta && inputMeta) {
96
+ meta.dependencies.add(inputSelectorName)
97
+ inputMeta.dependents.add(selectorName)
92
98
  }
93
99
  }
94
100
 
@@ -99,6 +105,11 @@ export function startAtomicEvaluation(logic: any, selectorName: string): void {
99
105
  meta.dirty = false
100
106
  meta.dirtyCause = null
101
107
  }
108
+ const stackKey = `${logic.pathString || 'unknown'}::${selectorName}`
109
+ if (evaluationStack.has(stackKey)) {
110
+ throw new Error('[KEA] Circular dependency detected')
111
+ }
112
+ evaluationStack.add(stackKey)
102
113
  }
103
114
 
104
115
  export function endAtomicEvaluation(logic: any, selectorName: string, result: any): void {
@@ -108,6 +119,8 @@ export function endAtomicEvaluation(logic: any, selectorName: string, result: an
108
119
  meta.lastResult = result
109
120
  meta.evaluations += 1
110
121
  }
122
+ const stackKey = `${logic.pathString || 'unknown'}::${selectorName}`
123
+ evaluationStack.delete(stackKey)
111
124
  }
112
125
 
113
126
  export function markAtomicDirty(logic: any, leafPath: string): void {
@@ -136,17 +149,101 @@ export function buildAtomicHealth(logic: any): AtomicLogicHealth {
136
149
  return { selectors, topologicalOrder: order }
137
150
  }
138
151
 
139
- export function createStateProxy(state: any, logic: any, selectorName: string, reducerNames: string[]): any {
140
- if (!state || typeof state !== 'object') return state
152
+ function buildLeafPath(reducerNames: string[], path: string[]): string {
153
+ // The first segment that is a reducer name starts the path.
154
+ let start = 0
155
+ for (let i = 0; i < path.length; i++) {
156
+ if (reducerNames.includes(path[i])) {
157
+ start = i
158
+ break
159
+ }
160
+ }
161
+ return path.slice(start).join('.')
162
+ }
163
+
164
+ export function createStateProxy(
165
+ state: any,
166
+ logic: any,
167
+ selectorName: string,
168
+ reducerNames: string[],
169
+ path: string[] = [],
170
+ ): any {
171
+ if (state === null || typeof state !== 'object') {
172
+ return state
173
+ }
174
+
175
+ const record = (key: string, value: any) => {
176
+ const leafPath = buildLeafPath(reducerNames, path.concat(key))
177
+ recordAtomicDependency(logic, selectorName, leafPath)
178
+ }
179
+
180
+ if (state instanceof Map) {
181
+ return new Proxy(state, {
182
+ get(target, prop) {
183
+ const key = String(prop)
184
+ if (key === 'get') {
185
+ return function (mapKey: any) {
186
+ record(`map:${mapKey}`, target.get(mapKey))
187
+ return target.get(mapKey)
188
+ }
189
+ }
190
+ if (key === 'has') {
191
+ return function (mapKey: any) {
192
+ record(`map:${mapKey}`, target.has(mapKey))
193
+ return target.has(mapKey)
194
+ }
195
+ }
196
+ const value = (target as any)[prop]
197
+ return typeof value === 'function' ? value.bind(target) : value
198
+ },
199
+ })
200
+ }
201
+
202
+ if (state instanceof Set) {
203
+ return new Proxy(state, {
204
+ get(target, prop) {
205
+ const key = String(prop)
206
+ if (key === 'has' || key === 'includes') {
207
+ return function (setValue: any) {
208
+ record(`set:${setValue}`, target.has(setValue))
209
+ return target.has(setValue)
210
+ }
211
+ }
212
+ const value = (target as any)[prop]
213
+ return typeof value === 'function' ? value.bind(target) : value
214
+ },
215
+ })
216
+ }
217
+
218
+ if (Array.isArray(state)) {
219
+ return new Proxy(state, {
220
+ get(target, prop) {
221
+ const key = String(prop)
222
+ if (/^\d+$/.test(key) || key === 'length') {
223
+ record(key, (target as any)[key])
224
+ }
225
+ if (key === 'includes' || key === 'indexOf' || key === 'find' || key === 'some') {
226
+ const fn = (target as any)[key]
227
+ return function (...fnArgs: any[]) {
228
+ // For includes/indexOf the first arg is the searched value.
229
+ if ((key === 'includes' || key === 'indexOf') && fnArgs.length > 0) {
230
+ record(fnArgs[0], true)
231
+ }
232
+ return fn.apply(target, fnArgs)
233
+ }
234
+ }
235
+ const value = (target as any)[prop]
236
+ return typeof value === 'function' ? value.bind(target) : value
237
+ },
238
+ })
239
+ }
240
+
141
241
  return new Proxy(state, {
142
242
  get(target, prop) {
143
243
  const key = String(prop)
144
244
  const value = (target as any)[key]
145
- if (reducerNames.includes(key) || (Array.isArray(target) && /^\d+$/.test(key)) || key === 'length') {
146
- // Track root reducer reads; leaf tracking happens recursively below
147
- }
148
- // Recursively wrap objects so nested reads can be tracked if the selector reads them directly.
149
- return createStateProxy(value, logic, selectorName, reducerNames)
245
+ record(key, value)
246
+ return value
150
247
  },
151
248
  })
152
249
  }
@@ -156,43 +253,38 @@ export function createStateProxy(state: any, logic: any, selectorName: string, r
156
253
 
157
254
  Edit `src/core/selectors.ts`:
158
255
 
159
- 1. Import the atomic helpers:
160
- ```ts
161
- import { getContext } from '../kea/context'
162
- import {
163
- getAtomicEngine,
164
- registerAtomicSelector,
165
- startAtomicEvaluation,
166
- endAtomicEvaluation,
167
- buildAtomicHealth,
168
- } from '../kea/atomic'
169
- ```
170
-
171
- 2. Inside the `selectors` builder, after resolving `selectorInputs`, check atomic mode:
172
- ```ts
173
- const atomicEnabled = getContext().options.atomicSelectors
174
- ```
175
-
256
+ 1. Import the atomic helpers.
257
+ 2. Inside the `selectors` builder, after resolving `selectorInputs`, check `const atomicEnabled = getContext().options.atomicSelectors`.
176
258
  3. When building each selector, if `atomicEnabled`, wrap it:
177
259
  ```ts
178
260
  if (atomicEnabled) {
179
261
  registerAtomicSelector(logic, key)
180
262
  const originalCompute = func
181
- const wrappedSelector = (state: any, props: any) => {
263
+ const builtSelector = (state: any, props: any) => {
182
264
  startAtomicEvaluation(logic, key)
183
265
  const reducerNames = Object.keys(logic.reducers || {})
184
266
  const proxyState = createStateProxy(state, logic, key, reducerNames)
185
- const result = originalCompute(...args.map((a) => a(proxyState, props)))
267
+ const inputResults = args.map((a) => {
268
+ const inputName = (a as any).__keaSelectorName || (a as any).selectorName
269
+ if (inputName) {
270
+ recordAtomicSelectorDependency(logic, key, inputName)
271
+ }
272
+ return a(proxyState, props)
273
+ })
274
+ const result = originalCompute(...inputResults)
186
275
  endAtomicEvaluation(logic, key, result)
187
276
  return result
188
277
  }
189
- builtSelectors[key] = wrappedSelector as Selector
278
+ builtSelectors[key] = builtSelector as Selector
190
279
  } else {
191
280
  builtSelectors[key] = createSelector(args, func, { memoizeOptions })
192
281
  }
193
282
  ```
194
-
195
- 4. At the end of the builder, attach `selectorHealth` to `logic`:
283
+ 4. Tag each built selector with its name so chained selectors can detect dependencies:
284
+ ```ts
285
+ ;(builtSelectors[key] as any).__keaSelectorName = key
286
+ ```
287
+ 5. At the end of the builder, attach `selectorHealth` to `logic`:
196
288
  ```ts
197
289
  if (atomicEnabled) {
198
290
  logic.selectorHealth = () => buildAtomicHealth(logic)
@@ -209,11 +301,9 @@ if (getContext().options.atomicSelectors) {
209
301
  }
210
302
  ```
211
303
 
212
- This is required because verifier tests call `logic.mount(); logic.selectorHealth()` directly on the object returned by `kea({...})`.
213
-
214
304
  ### Step 5 — Wire reducer updates
215
305
 
216
- Edit `src/core/reducers.ts` (or the file that builds Kea reducers):
306
+ Edit `src/core/reducers.ts`:
217
307
  - After a reducer returns a new state, compare old and new state at the leaf level.
218
308
  - For each changed leaf path, call `markAtomicDirty(logic, leafPath)`.
219
309
  - Multiple leaf changes in one action must coalesce into one selector re-evaluation.
@@ -223,10 +313,11 @@ Edit `src/core/reducers.ts` (or the file that builds Kea reducers):
223
313
  Edit `src/react/hooks.ts`:
224
314
  - Ensure React components re-render only when a leaf path they subscribe to changes.
225
315
  - Keep existing behavior when `atomicSelectors` is false.
316
+ - Do not introduce infinite loops; selectors must remain referentially stable.
226
317
 
227
318
  ### Step 7 — Exports
228
319
 
229
- Edit `src/index.ts` and export public helpers/types (e.g. `AtomicSelectorHealth`, `AtomicLogicHealth`).
320
+ Edit `src/index.ts` and export public helpers/types.
230
321
 
231
322
  ## Verification
232
323
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@castlemilk/omega",
3
- "version": "0.6.32",
3
+ "version": "0.6.33",
4
4
  "description": "Omega Harness CLI - installable via npx",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",