@very-coffee/statespace 0.1.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/LICENSE +21 -0
- package/README.md +74 -0
- package/package.json +52 -0
- package/src/codex/adapters.ts +8 -0
- package/src/codex/entity.ts +7 -0
- package/src/codex/index.ts +2 -0
- package/src/constraint/adapters.ts +66 -0
- package/src/constraint/domain.ts +61 -0
- package/src/constraint/index.ts +2 -0
- package/src/effect/adapters.ts +131 -0
- package/src/effect/domain.ts +97 -0
- package/src/effect/index.ts +2 -0
- package/src/effect/libs.ts +97 -0
- package/src/explorer/adapters.ts +155 -0
- package/src/explorer/domain.ts +57 -0
- package/src/explorer/index.ts +2 -0
- package/src/index.ts +9 -0
- package/src/morphisms/category.ts +231 -0
- package/src/morphisms/compile.ts +93 -0
- package/src/morphisms/domain.ts +111 -0
- package/src/morphisms/index.ts +21 -0
- package/src/morphisms/objects.ts +64 -0
- package/src/morphisms/result.ts +22 -0
- package/src/path/adapters.ts +126 -0
- package/src/path/domain.ts +54 -0
- package/src/path/index.ts +2 -0
- package/src/statespace/adapters.ts +54 -0
- package/src/statespace/domain.ts +61 -0
- package/src/statespace/index.ts +2 -0
- package/src/studies/bfs.ts +96 -0
- package/src/studies/dfs.ts +10 -0
- package/src/transition/adapters.ts +78 -0
- package/src/transition/domain.ts +51 -0
- package/src/transition/index.ts +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Zach Garrett
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# @very-coffee/statespace
|
|
2
|
+
|
|
3
|
+
## Typing / registries
|
|
4
|
+
|
|
5
|
+
Prefer `StateSpace<T>` at machine definition sites. Use `AnyStateSpace` / `eraseStateSpace` (and the executable parallels) only when putting spaces into a mixed collection. Calling `apply` / `enabled` on an erased space means you are responsible for passing a state that matches the original `T`.
|
|
6
|
+
|
|
7
|
+
## Effects
|
|
8
|
+
|
|
9
|
+
Path-focused effects (`set`, numeric, string ops) keep a `path`. Whole-state transforms do not:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
effect: {
|
|
13
|
+
operation: "transform",
|
|
14
|
+
transform: (state, context?) => ({ success: true, state: next }),
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**Breaking:** the previous `{ path, operation: "transform", value: (path, state, context) => … }` shape is removed. Explorer edge metadata `path` is optional and omitted for transforms.
|
|
19
|
+
|
|
20
|
+
## Morphisms (optional)
|
|
21
|
+
|
|
22
|
+
Import from `@very-coffee/statespace/morphisms` (not the package root).
|
|
23
|
+
|
|
24
|
+
Semantic **objects** are named regions (`contains` predicates). **Arrows** are Kleisli morphisms: total functions into `Result` that carry certified `ObjectValue` witnesses. Availability and validation failures are typed `MorphismError`s. Category operations are total: compatible arrows always compose; identity laws hold via Result chaining.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import {
|
|
28
|
+
classify,
|
|
29
|
+
composeArrows,
|
|
30
|
+
createMorphismSpace,
|
|
31
|
+
defineMorphisms,
|
|
32
|
+
defineObjects,
|
|
33
|
+
identityArrow,
|
|
34
|
+
instantiate,
|
|
35
|
+
ok,
|
|
36
|
+
} from "@very-coffee/statespace/morphisms";
|
|
37
|
+
import type { Schema } from "@very-coffee/statespace";
|
|
38
|
+
|
|
39
|
+
type Tip = { tip: "idle" | "ready"; value: number };
|
|
40
|
+
|
|
41
|
+
const shape = {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {
|
|
44
|
+
tip: { type: "string", enum: ["idle", "ready"] },
|
|
45
|
+
value: { type: "number" },
|
|
46
|
+
},
|
|
47
|
+
required: ["tip", "value"],
|
|
48
|
+
additionalProperties: false,
|
|
49
|
+
} as unknown as Schema<Tip>;
|
|
50
|
+
|
|
51
|
+
const objects = defineObjects<Tip>()([
|
|
52
|
+
{ key: "idle", contains: (s) => s.tip === "idle" },
|
|
53
|
+
{ key: "ready", contains: (s) => s.tip === "ready" },
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const armDef = {
|
|
57
|
+
name: "arm",
|
|
58
|
+
source: "idle" as const,
|
|
59
|
+
target: "ready" as const,
|
|
60
|
+
run: (v: { state: Tip }) => ok({ ...v.state, tip: "ready" as const }),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const arm = instantiate(armDef, undefined, objects);
|
|
64
|
+
const space = createMorphismSpace({
|
|
65
|
+
shape,
|
|
66
|
+
objects,
|
|
67
|
+
morphisms: [arm],
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- `instantiate(def, params, objects, instanceName?)` closes parameters into a concrete arrow (use `instanceName` when params distinguish transitions).
|
|
72
|
+
- `composeArrows([...])` returns a composition certificate plus a single arrow; left-to-right.
|
|
73
|
+
- `createMorphismSpace` compiles arrows to pathless `transform` transitions — no `effectPath`.
|
|
74
|
+
- Lifecycle/UI metadata (phase, labels, interpret) stays in consumer packages.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@very-coffee/statespace",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Configurable framework for generating and analyzing state graphs.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"module": "./src/index.ts",
|
|
8
|
+
"engines": {
|
|
9
|
+
"bun": ">=1.1.0"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/coffee-fueled-dev/statespace.git",
|
|
14
|
+
"directory": "packages/core"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/coffee-fueled-dev/statespace#readme",
|
|
17
|
+
"bugs": "https://github.com/coffee-fueled-dev/statespace/issues",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"state-space",
|
|
20
|
+
"state-graph",
|
|
21
|
+
"exploration",
|
|
22
|
+
"pathfinding",
|
|
23
|
+
"bun"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"import": "./src/index.ts",
|
|
33
|
+
"types": "./src/index.ts"
|
|
34
|
+
},
|
|
35
|
+
"./morphisms": {
|
|
36
|
+
"import": "./src/morphisms/index.ts",
|
|
37
|
+
"types": "./src/morphisms/index.ts"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@datastructures-js/priority-queue": "^6.3.4",
|
|
42
|
+
"ajv": "^8.17.1",
|
|
43
|
+
"immer": "^10.1.3",
|
|
44
|
+
"queue": "^7.0.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"typescript": "^5.0.0"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Codex } from "./entity";
|
|
2
|
+
|
|
3
|
+
// TODO: stronger codecs later (canonical JSON, content-hash + state store) if Map key size/CPU matters.
|
|
4
|
+
export const jsonCodex = <T>(): Codex<T> => ({
|
|
5
|
+
key: "JSON",
|
|
6
|
+
encode: async (systemState) => JSON.stringify(systemState) ?? "null",
|
|
7
|
+
decode: async (key) => JSON.parse(key),
|
|
8
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import Ajv from "ajv";
|
|
2
|
+
import { PathRepository } from "../path/adapters";
|
|
3
|
+
import type { IConstraintRepository } from "./domain";
|
|
4
|
+
|
|
5
|
+
const ajv = new Ajv({
|
|
6
|
+
strict: false,
|
|
7
|
+
validateSchema: false,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const ConstraintRepository: IConstraintRepository = {
|
|
11
|
+
apply: (constraint, state, path, phase) =>
|
|
12
|
+
ConstraintRepository.makeExecutable(constraint)(path, state, phase),
|
|
13
|
+
|
|
14
|
+
createImperative: (fn) => (path, state, phase) => {
|
|
15
|
+
const value = PathRepository.valueFromPath(path, state);
|
|
16
|
+
const result = fn(value, state);
|
|
17
|
+
|
|
18
|
+
return ConstraintRepository.formatResult({
|
|
19
|
+
isValid: result.success,
|
|
20
|
+
state,
|
|
21
|
+
phase,
|
|
22
|
+
path,
|
|
23
|
+
message: result.message,
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
formatResult: ({ isValid, state, phase, path, message }) => {
|
|
28
|
+
if (isValid) {
|
|
29
|
+
return {
|
|
30
|
+
success: true,
|
|
31
|
+
state,
|
|
32
|
+
phase,
|
|
33
|
+
path,
|
|
34
|
+
};
|
|
35
|
+
} else {
|
|
36
|
+
return {
|
|
37
|
+
success: false,
|
|
38
|
+
state,
|
|
39
|
+
phase,
|
|
40
|
+
path,
|
|
41
|
+
message: message || "Constraint validation failed",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
makeExecutable: (constraint) => (path, state, phase) => {
|
|
47
|
+
if (typeof constraint.validation === "function") {
|
|
48
|
+
return constraint.validation(path, state, phase);
|
|
49
|
+
}
|
|
50
|
+
const value = PathRepository.valueFromPath(path, state);
|
|
51
|
+
const validator = ajv.compile(constraint.validation);
|
|
52
|
+
const isValid = validator(value);
|
|
53
|
+
|
|
54
|
+
const errorMessage =
|
|
55
|
+
validator.errors?.map((err) => `${err.instancePath} ${err.message}`).join(", ") ||
|
|
56
|
+
"Validation failed";
|
|
57
|
+
|
|
58
|
+
return ConstraintRepository.formatResult({
|
|
59
|
+
isValid,
|
|
60
|
+
state,
|
|
61
|
+
phase,
|
|
62
|
+
path,
|
|
63
|
+
message: errorMessage,
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
} as const;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Path, Value } from "../path/domain";
|
|
2
|
+
import type { Schema } from "../statespace/domain";
|
|
3
|
+
|
|
4
|
+
export type ConstraintSuccess<TState extends object, TPath extends Path<TState> = Path<TState>> = {
|
|
5
|
+
success: true;
|
|
6
|
+
state: TState;
|
|
7
|
+
phase: "before_transition" | "after_transition";
|
|
8
|
+
path: TPath;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ConstraintFailure<TState extends object, TPath extends Path<TState> = Path<TState>> = {
|
|
12
|
+
success: false;
|
|
13
|
+
state: TState;
|
|
14
|
+
phase: "before_transition" | "after_transition";
|
|
15
|
+
path: TPath;
|
|
16
|
+
message?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ConstraintResult<TState extends object, TPath extends Path<TState> = Path<TState>> =
|
|
20
|
+
| ConstraintSuccess<TState, TPath>
|
|
21
|
+
| ConstraintFailure<TState, TPath>;
|
|
22
|
+
|
|
23
|
+
export type ConstraintFn<TState extends object, TPath extends Path<TState> = Path<TState>> = (
|
|
24
|
+
path: TPath,
|
|
25
|
+
state: TState,
|
|
26
|
+
phase: "before_transition" | "after_transition",
|
|
27
|
+
) => ConstraintResult<TState, TPath>;
|
|
28
|
+
|
|
29
|
+
export type Constraint<TState extends object> = {
|
|
30
|
+
[P in Path<TState>]: {
|
|
31
|
+
phase: "before_transition" | "after_transition";
|
|
32
|
+
path: P;
|
|
33
|
+
validation: Schema<Value<TState, P>> | ConstraintFn<TState, P>;
|
|
34
|
+
message?: string;
|
|
35
|
+
};
|
|
36
|
+
}[Path<TState>];
|
|
37
|
+
|
|
38
|
+
export interface IConstraintRepository {
|
|
39
|
+
readonly apply: <TState extends object>(
|
|
40
|
+
constraint: Constraint<TState>,
|
|
41
|
+
state: TState,
|
|
42
|
+
path: Path<TState>,
|
|
43
|
+
phase: "before_transition" | "after_transition",
|
|
44
|
+
) => ConstraintResult<TState>;
|
|
45
|
+
|
|
46
|
+
readonly createImperative: <TState extends object, TPath extends Path<TState> = Path<TState>>(
|
|
47
|
+
fn: (value: Value<TState, TPath>, state: TState) => { success: boolean; message?: string },
|
|
48
|
+
) => ConstraintFn<TState, TPath>;
|
|
49
|
+
|
|
50
|
+
readonly formatResult: <TState extends object, TPath extends Path<TState> = Path<TState>>(args: {
|
|
51
|
+
isValid: boolean;
|
|
52
|
+
state: TState;
|
|
53
|
+
phase: "before_transition" | "after_transition";
|
|
54
|
+
path: TPath;
|
|
55
|
+
message?: string;
|
|
56
|
+
}) => ConstraintResult<TState, TPath>;
|
|
57
|
+
|
|
58
|
+
readonly makeExecutable: <TState extends object>(
|
|
59
|
+
constraint: Constraint<TState>,
|
|
60
|
+
) => ConstraintFn<TState>;
|
|
61
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { PathRepository } from "../path/adapters";
|
|
2
|
+
import type { Path, PathReference, Value } from "../path/domain";
|
|
3
|
+
import type {
|
|
4
|
+
Effect,
|
|
5
|
+
EffectFailure,
|
|
6
|
+
EffectSuccess,
|
|
7
|
+
IEffectRepository,
|
|
8
|
+
PathEffect,
|
|
9
|
+
TransformEffect,
|
|
10
|
+
} from "./domain";
|
|
11
|
+
import { isPathEffect, isTransformEffect } from "./domain";
|
|
12
|
+
import { mergeValue, validateMutation } from "./libs";
|
|
13
|
+
|
|
14
|
+
export const EffectRepository: IEffectRepository = {
|
|
15
|
+
apply: (state, transition, validator, context) => {
|
|
16
|
+
try {
|
|
17
|
+
const result = EffectRepository.makeExecutable(transition.effect)(state, context);
|
|
18
|
+
|
|
19
|
+
if (!result.success) {
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// TODO: return validation error messages if any
|
|
24
|
+
if (!validator(result.state)) {
|
|
25
|
+
return {
|
|
26
|
+
success: false,
|
|
27
|
+
error: "Malformed state after effect",
|
|
28
|
+
} satisfies EffectFailure;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return result;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
let errorMessage = "Failed to apply effect";
|
|
34
|
+
if (error instanceof Error) {
|
|
35
|
+
errorMessage += `:\n${error.message}`;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
success: false,
|
|
39
|
+
error: errorMessage,
|
|
40
|
+
} satisfies EffectFailure;
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
resolveValue: (effect, state) => {
|
|
45
|
+
type TState = typeof state;
|
|
46
|
+
type TPath = Path<TState>;
|
|
47
|
+
type TValue = Value<TState, TPath>;
|
|
48
|
+
|
|
49
|
+
const maybeRef = effect.value as unknown;
|
|
50
|
+
|
|
51
|
+
if (PathRepository.isPathRef(maybeRef, state)) {
|
|
52
|
+
const pathRef = effect.value as PathReference<TState, TPath>;
|
|
53
|
+
const path = pathRef.slice(1) as TPath;
|
|
54
|
+
|
|
55
|
+
return PathRepository.valueFromPath(path, state) as TValue;
|
|
56
|
+
} else {
|
|
57
|
+
return effect.value as TValue;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
makeExecutable: (effect) => (state, context) => {
|
|
62
|
+
type TState = typeof state;
|
|
63
|
+
|
|
64
|
+
if (isTransformEffect(effect as Effect<TState>)) {
|
|
65
|
+
const transformEffect = effect as TransformEffect<TState>;
|
|
66
|
+
const transformResult = transformEffect.transform(state, context);
|
|
67
|
+
if (transformResult.success) {
|
|
68
|
+
return {
|
|
69
|
+
success: true,
|
|
70
|
+
state: transformResult.state,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
success: false,
|
|
75
|
+
error: transformResult.error,
|
|
76
|
+
} satisfies EffectFailure;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!isPathEffect(effect as Effect<TState>)) {
|
|
80
|
+
throw new Error(`Invalid effect operation: ${(effect as { operation: string }).operation}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const pathEffect = effect as PathEffect<TState>;
|
|
84
|
+
const path = pathEffect.path as Path<TState>;
|
|
85
|
+
type TPath = typeof path;
|
|
86
|
+
|
|
87
|
+
const currentValue = PathRepository.valueFromPath(path, state);
|
|
88
|
+
|
|
89
|
+
function mergeAndValidate<TValue>(nextValue: TValue): EffectSuccess<TState> {
|
|
90
|
+
const validatedValue = validateMutation(path, state, nextValue as Value<TState, TPath>);
|
|
91
|
+
const nextState = mergeValue(state, path, validatedValue);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
success: true,
|
|
95
|
+
state: nextState,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const effectValue = EffectRepository.resolveValue(pathEffect, state);
|
|
100
|
+
switch (pathEffect.operation) {
|
|
101
|
+
case "set":
|
|
102
|
+
return mergeAndValidate(effectValue);
|
|
103
|
+
|
|
104
|
+
case "add":
|
|
105
|
+
return mergeAndValidate(Number(currentValue) + Number(effectValue));
|
|
106
|
+
|
|
107
|
+
case "subtract":
|
|
108
|
+
return mergeAndValidate(Number(currentValue) - Number(effectValue));
|
|
109
|
+
|
|
110
|
+
case "multiply":
|
|
111
|
+
return mergeAndValidate(Number(currentValue) * Number(effectValue));
|
|
112
|
+
|
|
113
|
+
case "divide":
|
|
114
|
+
return mergeAndValidate(Number(currentValue) / Number(effectValue));
|
|
115
|
+
|
|
116
|
+
case "append":
|
|
117
|
+
return mergeAndValidate(String(currentValue) + String(effectValue));
|
|
118
|
+
|
|
119
|
+
case "prepend":
|
|
120
|
+
return mergeAndValidate(String(effectValue) + String(currentValue));
|
|
121
|
+
|
|
122
|
+
case "cut":
|
|
123
|
+
return mergeAndValidate(String(currentValue).replace(String(effectValue), ""));
|
|
124
|
+
|
|
125
|
+
default:
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Invalid effect operation: ${(pathEffect as { operation: string }).operation}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
} as const;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Path, Value } from "../path/domain";
|
|
2
|
+
import type { Transition } from "../transition/domain";
|
|
3
|
+
|
|
4
|
+
export type Scalar = number | string | boolean | undefined | null;
|
|
5
|
+
export type Metadata = Record<string, Scalar>;
|
|
6
|
+
|
|
7
|
+
export type EffectSuccess<TState extends object> = {
|
|
8
|
+
success: true;
|
|
9
|
+
state: TState;
|
|
10
|
+
meta?: Metadata;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type EffectFailure = {
|
|
14
|
+
success: false;
|
|
15
|
+
error: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type EffectResult<TState extends object> = EffectSuccess<TState> | EffectFailure;
|
|
19
|
+
|
|
20
|
+
/** Whole-state transform; receives and may replace the entire state. */
|
|
21
|
+
export type TransformFn<TState extends object> = (
|
|
22
|
+
state: TState,
|
|
23
|
+
context?: unknown,
|
|
24
|
+
) => EffectResult<TState>;
|
|
25
|
+
|
|
26
|
+
type NumericEffect<TState extends object, P extends Path<TState>> = {
|
|
27
|
+
path: P;
|
|
28
|
+
operation: "add" | "subtract" | "multiply" | "divide";
|
|
29
|
+
value: number;
|
|
30
|
+
meta?: Metadata;
|
|
31
|
+
cost?: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type StringEffect<TState extends object, P extends Path<TState>> = {
|
|
35
|
+
path: P;
|
|
36
|
+
operation: "prepend" | "append" | "cut";
|
|
37
|
+
value: string;
|
|
38
|
+
meta?: Metadata;
|
|
39
|
+
cost?: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type SetEffect<TState extends object, P extends Path<TState>> = {
|
|
43
|
+
path: P;
|
|
44
|
+
operation: "set";
|
|
45
|
+
value: Value<TState, P> | `$${Path<TState>}`;
|
|
46
|
+
meta?: Metadata;
|
|
47
|
+
cost?: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Path-focused ops for a single path (no transform). */
|
|
51
|
+
type EffectForPath<TState extends object, P extends Path<TState>> =
|
|
52
|
+
| SetEffect<TState, P>
|
|
53
|
+
| (Value<TState, P> extends number ? NumericEffect<TState, P> : never)
|
|
54
|
+
| (Value<TState, P> extends string ? StringEffect<TState, P> : never);
|
|
55
|
+
|
|
56
|
+
export type PathEffect<TState extends object> = {
|
|
57
|
+
[P in Path<TState>]: EffectForPath<TState, P>;
|
|
58
|
+
}[Path<TState>];
|
|
59
|
+
|
|
60
|
+
export type TransformEffect<TState extends object> = {
|
|
61
|
+
operation: "transform";
|
|
62
|
+
transform: TransformFn<TState>;
|
|
63
|
+
meta?: Metadata;
|
|
64
|
+
cost?: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type Effect<TState extends object> = PathEffect<TState> | TransformEffect<TState>;
|
|
68
|
+
|
|
69
|
+
export function isPathEffect<TState extends object>(
|
|
70
|
+
effect: Effect<TState>,
|
|
71
|
+
): effect is PathEffect<TState> {
|
|
72
|
+
return "path" in effect;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isTransformEffect<TState extends object>(
|
|
76
|
+
effect: Effect<TState>,
|
|
77
|
+
): effect is TransformEffect<TState> {
|
|
78
|
+
return effect.operation === "transform";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface IEffectRepository {
|
|
82
|
+
readonly makeExecutable: <TState extends object>(
|
|
83
|
+
effect: Effect<TState>,
|
|
84
|
+
) => (state: TState, context?: unknown) => EffectResult<TState>;
|
|
85
|
+
|
|
86
|
+
readonly apply: <TState extends object>(
|
|
87
|
+
state: TState,
|
|
88
|
+
transition: Transition<TState>,
|
|
89
|
+
validator: (state: TState) => boolean,
|
|
90
|
+
context?: unknown,
|
|
91
|
+
) => EffectResult<TState>;
|
|
92
|
+
|
|
93
|
+
readonly resolveValue: <TState extends object>(
|
|
94
|
+
effect: PathEffect<TState>,
|
|
95
|
+
state: TState,
|
|
96
|
+
) => Value<TState, Path<TState>>;
|
|
97
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { produce } from "immer";
|
|
2
|
+
import { PathRepository } from "../path/adapters";
|
|
3
|
+
import type { Path, Value } from "../path/domain";
|
|
4
|
+
|
|
5
|
+
export const mergeValue = <T extends object, P extends Path<T>>(
|
|
6
|
+
state: T,
|
|
7
|
+
path: P,
|
|
8
|
+
nextValue: Value<T, P>,
|
|
9
|
+
): T => {
|
|
10
|
+
return produce(state, (draft) => {
|
|
11
|
+
const segments = PathRepository.parsePathSegments(path);
|
|
12
|
+
let current: object = draft;
|
|
13
|
+
|
|
14
|
+
const childAt = (
|
|
15
|
+
parent: object,
|
|
16
|
+
segment: { type: "property"; key: string } | { type: "index"; index: number },
|
|
17
|
+
): object => {
|
|
18
|
+
const next =
|
|
19
|
+
segment.type === "property"
|
|
20
|
+
? (parent as Record<string, unknown>)[segment.key]
|
|
21
|
+
: (parent as unknown[])[segment.index];
|
|
22
|
+
if (typeof next !== "object" || next === null) {
|
|
23
|
+
throw new Error(`Invalid path: ${String(path)}`);
|
|
24
|
+
}
|
|
25
|
+
return next;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
29
|
+
const segment = segments[i];
|
|
30
|
+
if (!segment) throw new Error(`Invalid path: ${String(path)}`);
|
|
31
|
+
current = childAt(current, segment);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const lastSegment = segments[segments.length - 1];
|
|
35
|
+
if (!lastSegment) throw new Error(`Invalid path: ${String(path)}`);
|
|
36
|
+
if (lastSegment.type === "property") {
|
|
37
|
+
(current as Record<string, unknown>)[lastSegment.key] = nextValue;
|
|
38
|
+
} else {
|
|
39
|
+
(current as unknown[])[lastSegment.index] = nextValue;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const validateMutation = <T extends object, P extends Path<T>>(
|
|
45
|
+
path: P,
|
|
46
|
+
state: T,
|
|
47
|
+
nextValue: Value<T, P>,
|
|
48
|
+
): Value<T, P> => {
|
|
49
|
+
if (!operationMaintainsType(nextValue, path, state)) {
|
|
50
|
+
const currentValue = PathRepository.valueFromPath(path, state);
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Mutation of path '${String(
|
|
53
|
+
path,
|
|
54
|
+
)}' resulted in incompatible type. Expected ${typeof currentValue}, got ${typeof nextValue}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return nextValue;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const operationMaintainsType = <T extends object, P extends Path<T>>(
|
|
61
|
+
value: unknown,
|
|
62
|
+
path: P,
|
|
63
|
+
object: T,
|
|
64
|
+
): value is Value<T, P> => {
|
|
65
|
+
if (!PathRepository.isPath(path, object)) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Get the current value at the path path to infer the expected type
|
|
70
|
+
const currentValue = PathRepository.valueFromPath(path, object);
|
|
71
|
+
|
|
72
|
+
// Basic runtime type checking
|
|
73
|
+
if (typeof currentValue === "number") {
|
|
74
|
+
return typeof value === "number";
|
|
75
|
+
}
|
|
76
|
+
if (typeof currentValue === "string") {
|
|
77
|
+
return typeof value === "string";
|
|
78
|
+
}
|
|
79
|
+
if (typeof currentValue === "boolean") {
|
|
80
|
+
return typeof value === "boolean";
|
|
81
|
+
}
|
|
82
|
+
if (currentValue === null) {
|
|
83
|
+
return value === null;
|
|
84
|
+
}
|
|
85
|
+
if (currentValue === undefined) {
|
|
86
|
+
return value === undefined;
|
|
87
|
+
}
|
|
88
|
+
if (typeof currentValue === "object") {
|
|
89
|
+
return typeof value === "object" && value !== null;
|
|
90
|
+
}
|
|
91
|
+
if (Array.isArray(currentValue)) {
|
|
92
|
+
return Array.isArray(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// For other types, just check they're the same type
|
|
96
|
+
return typeof value === typeof currentValue;
|
|
97
|
+
};
|