@cynodia/axiom-runtime 0.3.1-alpha.1
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 +28 -0
- package/dist/dom.d.ts +42 -0
- package/dist/dom.js +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +9 -0
- package/dist/memory-host.d.ts +52 -0
- package/dist/memory-host.js +145 -0
- package/dist/mutation/mutation-engine.d.ts +44 -0
- package/dist/mutation/mutation-engine.js +92 -0
- package/dist/mutation/resolve-location.d.ts +37 -0
- package/dist/mutation/resolve-location.js +108 -0
- package/dist/mutation/store.d.ts +14 -0
- package/dist/mutation/store.js +19 -0
- package/dist/mutation/transaction.d.ts +21 -0
- package/dist/mutation/transaction.js +45 -0
- package/dist/mutation/values.d.ts +17 -0
- package/dist/mutation/values.js +73 -0
- package/dist/runtime.d.ts +48 -0
- package/dist/runtime.js +1149 -0
- package/dist/source.d.ts +5 -0
- package/dist/source.js +71 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AskTech AS
|
|
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,28 @@
|
|
|
1
|
+
# Axiom Runtime
|
|
2
|
+
|
|
3
|
+
Part of [Axiom](https://github.com/cynodia/axiom), an AI-native semantic web application
|
|
4
|
+
framework.
|
|
5
|
+
|
|
6
|
+
**Status: experimental / alpha.** The API may change between alpha releases.
|
|
7
|
+
|
|
8
|
+
The domain-independent runtime: the state store, expression evaluation, the mutation
|
|
9
|
+
engine, constraint checking, the semantic UI renderer and routing. It takes its whole
|
|
10
|
+
environment through a `HostEnvironment`, so it runs in a browser or headlessly.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @cynodia/axiom-runtime@alpha
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Most applications should install the facade package instead, which re-exports this one:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @cynodia/axiom@alpha
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## License
|
|
25
|
+
|
|
26
|
+
MIT
|
|
27
|
+
|
|
28
|
+
Copyright (c) 2026 AskTech AS.
|
package/dist/dom.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The narrow DOM surface the renderer uses. Declaring it structurally keeps the runtime
|
|
3
|
+
* free of DOM library types and lets tests supply an in-memory implementation.
|
|
4
|
+
*/
|
|
5
|
+
export interface DomEvent {
|
|
6
|
+
type: string;
|
|
7
|
+
preventDefault?(): void;
|
|
8
|
+
target?: unknown;
|
|
9
|
+
}
|
|
10
|
+
export type DomListener = (event: DomEvent) => void;
|
|
11
|
+
export interface DomElement {
|
|
12
|
+
tagName: string;
|
|
13
|
+
textContent: string | null;
|
|
14
|
+
value?: string;
|
|
15
|
+
checked?: boolean;
|
|
16
|
+
selectionStart?: number | null;
|
|
17
|
+
setAttribute(name: string, value: string): void;
|
|
18
|
+
appendChild(child: DomElement): unknown;
|
|
19
|
+
replaceChildren(...children: DomElement[]): void;
|
|
20
|
+
addEventListener(type: string, listener: DomListener): void;
|
|
21
|
+
focus?(): void;
|
|
22
|
+
}
|
|
23
|
+
export interface DomDocument {
|
|
24
|
+
createElement(tagName: string): DomElement;
|
|
25
|
+
}
|
|
26
|
+
export interface StorageAdapter {
|
|
27
|
+
read(key: string): string | null;
|
|
28
|
+
write(key: string, value: string): void;
|
|
29
|
+
}
|
|
30
|
+
/** Everything the runtime needs from its environment, so nothing is read from globals. */
|
|
31
|
+
export interface HostEnvironment {
|
|
32
|
+
document: DomDocument;
|
|
33
|
+
getPath(): string;
|
|
34
|
+
pushPath(path: string): void;
|
|
35
|
+
onPathChange(listener: () => void): void;
|
|
36
|
+
confirm(message: string): boolean;
|
|
37
|
+
now(): string;
|
|
38
|
+
uuid(): string;
|
|
39
|
+
storage?: StorageAdapter;
|
|
40
|
+
report?(message: string): void;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=dom.d.ts.map
|
package/dist/dom.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './dom.js';
|
|
2
|
+
export * from './mutation/values.js';
|
|
3
|
+
export * from './mutation/store.js';
|
|
4
|
+
export * from './mutation/transaction.js';
|
|
5
|
+
export * from './mutation/resolve-location.js';
|
|
6
|
+
export * from './mutation/mutation-engine.js';
|
|
7
|
+
export * from './runtime.js';
|
|
8
|
+
export * from './memory-host.js';
|
|
9
|
+
export * from './source.js';
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './dom.js';
|
|
2
|
+
export * from './mutation/values.js';
|
|
3
|
+
export * from './mutation/store.js';
|
|
4
|
+
export * from './mutation/transaction.js';
|
|
5
|
+
export * from './mutation/resolve-location.js';
|
|
6
|
+
export * from './mutation/mutation-engine.js';
|
|
7
|
+
export * from './runtime.js';
|
|
8
|
+
export * from './memory-host.js';
|
|
9
|
+
export * from './source.js';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { DomDocument, DomElement, DomEvent, DomListener, HostEnvironment, StorageAdapter } from './dom.js';
|
|
2
|
+
/**
|
|
3
|
+
* An in-memory DOM and host. The runtime never touches browser globals directly, so the
|
|
4
|
+
* same renderer that drives a real page can be driven headlessly in tests.
|
|
5
|
+
*/
|
|
6
|
+
export declare class MemoryElement implements DomElement {
|
|
7
|
+
readonly tagName: string;
|
|
8
|
+
readonly attributes: Map<string, string>;
|
|
9
|
+
children: MemoryElement[];
|
|
10
|
+
textContent: string | null;
|
|
11
|
+
value?: string;
|
|
12
|
+
checked?: boolean;
|
|
13
|
+
selectionStart: number | null;
|
|
14
|
+
focused: boolean;
|
|
15
|
+
private readonly listeners;
|
|
16
|
+
constructor(tagName: string);
|
|
17
|
+
setAttribute(name: string, value: string): void;
|
|
18
|
+
getAttribute(name: string): string | null;
|
|
19
|
+
appendChild(child: DomElement): unknown;
|
|
20
|
+
replaceChildren(...children: DomElement[]): void;
|
|
21
|
+
addEventListener(type: string, listener: DomListener): void;
|
|
22
|
+
focus(): void;
|
|
23
|
+
dispatch(type: string, event?: Partial<DomEvent>): void;
|
|
24
|
+
hasListener(type: string): boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare class MemoryDocument implements DomDocument {
|
|
27
|
+
createElement(tagName: string): DomElement;
|
|
28
|
+
}
|
|
29
|
+
export interface MemoryHostOptions {
|
|
30
|
+
path?: string;
|
|
31
|
+
confirm?: boolean | (() => boolean);
|
|
32
|
+
storage?: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface MemoryHost extends HostEnvironment {
|
|
35
|
+
root: MemoryElement;
|
|
36
|
+
path: string;
|
|
37
|
+
reports: string[];
|
|
38
|
+
confirmations: string[];
|
|
39
|
+
storage?: StorageAdapter;
|
|
40
|
+
}
|
|
41
|
+
export declare function createMemoryHost(options?: MemoryHostOptions): MemoryHost;
|
|
42
|
+
/** Depth-first collection of every element matching a predicate. */
|
|
43
|
+
export declare function findAll(root: MemoryElement, predicate: (element: MemoryElement) => boolean): MemoryElement[];
|
|
44
|
+
export declare function findByNodeId(root: MemoryElement, nodeId: string): MemoryElement[];
|
|
45
|
+
export declare function findByTag(root: MemoryElement, tagName: string): MemoryElement[];
|
|
46
|
+
/** Concatenated text of an element and its descendants. */
|
|
47
|
+
export declare function textOf(element: MemoryElement): string;
|
|
48
|
+
export declare function typeInto(element: MemoryElement, value: string): void;
|
|
49
|
+
export declare function toggle(element: MemoryElement, checked: boolean): void;
|
|
50
|
+
export declare function click(element: MemoryElement): void;
|
|
51
|
+
export declare function submit(element: MemoryElement): void;
|
|
52
|
+
//# sourceMappingURL=memory-host.d.ts.map
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory DOM and host. The runtime never touches browser globals directly, so the
|
|
3
|
+
* same renderer that drives a real page can be driven headlessly in tests.
|
|
4
|
+
*/
|
|
5
|
+
export class MemoryElement {
|
|
6
|
+
tagName;
|
|
7
|
+
attributes = new Map();
|
|
8
|
+
children = [];
|
|
9
|
+
textContent = null;
|
|
10
|
+
value;
|
|
11
|
+
checked;
|
|
12
|
+
selectionStart = null;
|
|
13
|
+
focused = false;
|
|
14
|
+
listeners = new Map();
|
|
15
|
+
constructor(tagName) {
|
|
16
|
+
this.tagName = tagName;
|
|
17
|
+
}
|
|
18
|
+
setAttribute(name, value) {
|
|
19
|
+
this.attributes.set(name, value);
|
|
20
|
+
}
|
|
21
|
+
getAttribute(name) {
|
|
22
|
+
return this.attributes.get(name) ?? null;
|
|
23
|
+
}
|
|
24
|
+
appendChild(child) {
|
|
25
|
+
this.children.push(child);
|
|
26
|
+
return child;
|
|
27
|
+
}
|
|
28
|
+
replaceChildren(...children) {
|
|
29
|
+
this.children = children.map((child) => child);
|
|
30
|
+
}
|
|
31
|
+
addEventListener(type, listener) {
|
|
32
|
+
const existing = this.listeners.get(type) ?? [];
|
|
33
|
+
existing.push(listener);
|
|
34
|
+
this.listeners.set(type, existing);
|
|
35
|
+
}
|
|
36
|
+
focus() {
|
|
37
|
+
this.focused = true;
|
|
38
|
+
}
|
|
39
|
+
dispatch(type, event = {}) {
|
|
40
|
+
const payload = { type, target: this, preventDefault: () => undefined, ...event };
|
|
41
|
+
for (const listener of this.listeners.get(type) ?? []) {
|
|
42
|
+
listener(payload);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
hasListener(type) {
|
|
46
|
+
return (this.listeners.get(type) ?? []).length > 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export class MemoryDocument {
|
|
50
|
+
createElement(tagName) {
|
|
51
|
+
return new MemoryElement(tagName);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function createMemoryHost(options = {}) {
|
|
55
|
+
const listeners = [];
|
|
56
|
+
const values = new Map();
|
|
57
|
+
let counter = 0;
|
|
58
|
+
const confirmResult = options.confirm ?? true;
|
|
59
|
+
const host = {
|
|
60
|
+
root: new MemoryElement('div'),
|
|
61
|
+
path: options.path ?? '/',
|
|
62
|
+
reports: [],
|
|
63
|
+
confirmations: [],
|
|
64
|
+
document: new MemoryDocument(),
|
|
65
|
+
getPath: () => host.path,
|
|
66
|
+
pushPath: (next) => {
|
|
67
|
+
host.path = next;
|
|
68
|
+
},
|
|
69
|
+
onPathChange: (listener) => {
|
|
70
|
+
listeners.push(listener);
|
|
71
|
+
},
|
|
72
|
+
confirm: (message) => {
|
|
73
|
+
host.confirmations.push(message);
|
|
74
|
+
return typeof confirmResult === 'function' ? confirmResult() : confirmResult;
|
|
75
|
+
},
|
|
76
|
+
now: () => {
|
|
77
|
+
counter += 1;
|
|
78
|
+
return `2026-01-01T00:00:${String(counter).padStart(2, '0')}.000Z`;
|
|
79
|
+
},
|
|
80
|
+
uuid: () => {
|
|
81
|
+
counter += 1;
|
|
82
|
+
return `id-${counter}`;
|
|
83
|
+
},
|
|
84
|
+
report: (message) => {
|
|
85
|
+
host.reports.push(message);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
if (options.storage) {
|
|
89
|
+
host.storage = {
|
|
90
|
+
read: (key) => values.get(key) ?? null,
|
|
91
|
+
write: (key, value) => {
|
|
92
|
+
values.set(key, value);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return host;
|
|
97
|
+
}
|
|
98
|
+
/** Depth-first collection of every element matching a predicate. */
|
|
99
|
+
export function findAll(root, predicate) {
|
|
100
|
+
const found = [];
|
|
101
|
+
const visit = (element) => {
|
|
102
|
+
if (predicate(element)) {
|
|
103
|
+
found.push(element);
|
|
104
|
+
}
|
|
105
|
+
for (const child of element.children) {
|
|
106
|
+
visit(child);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
visit(root);
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
112
|
+
export function findByNodeId(root, nodeId) {
|
|
113
|
+
return findAll(root, (element) => element.getAttribute('data-node') === nodeId);
|
|
114
|
+
}
|
|
115
|
+
export function findByTag(root, tagName) {
|
|
116
|
+
return findAll(root, (element) => element.tagName === tagName);
|
|
117
|
+
}
|
|
118
|
+
/** Concatenated text of an element and its descendants. */
|
|
119
|
+
export function textOf(element) {
|
|
120
|
+
const parts = [];
|
|
121
|
+
const visit = (current) => {
|
|
122
|
+
if (current.textContent) {
|
|
123
|
+
parts.push(current.textContent);
|
|
124
|
+
}
|
|
125
|
+
for (const child of current.children) {
|
|
126
|
+
visit(child);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
visit(element);
|
|
130
|
+
return parts.join(' ');
|
|
131
|
+
}
|
|
132
|
+
export function typeInto(element, value) {
|
|
133
|
+
element.value = value;
|
|
134
|
+
element.dispatch('input');
|
|
135
|
+
}
|
|
136
|
+
export function toggle(element, checked) {
|
|
137
|
+
element.checked = checked;
|
|
138
|
+
element.dispatch('change');
|
|
139
|
+
}
|
|
140
|
+
export function click(element) {
|
|
141
|
+
element.dispatch('click');
|
|
142
|
+
}
|
|
143
|
+
export function submit(element) {
|
|
144
|
+
element.dispatch('submit');
|
|
145
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { MutationOperation, NodeId } from '@cynodia/axiom-core';
|
|
2
|
+
import type { LocationRuntime, ResolvedPath } from './resolve-location.js';
|
|
3
|
+
/** Where a mutation came from, so every state change stays attributable. */
|
|
4
|
+
export interface MutationContext {
|
|
5
|
+
source: 'action' | 'ui' | 'system' | 'native';
|
|
6
|
+
sourceNodeId?: NodeId;
|
|
7
|
+
transactionId?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface MutationResult {
|
|
10
|
+
affectedStates: NodeId[];
|
|
11
|
+
affectedLocations: ResolvedPath[];
|
|
12
|
+
}
|
|
13
|
+
export interface MutationLogEntry {
|
|
14
|
+
transactionId?: string;
|
|
15
|
+
source: MutationContext['source'];
|
|
16
|
+
sourceNodeId?: NodeId;
|
|
17
|
+
operation: MutationOperation['kind'];
|
|
18
|
+
path: ResolvedPath;
|
|
19
|
+
description: string;
|
|
20
|
+
oldValue?: unknown;
|
|
21
|
+
newValue?: unknown;
|
|
22
|
+
/** Set when the surrounding transaction settles. */
|
|
23
|
+
outcome?: 'committed' | 'rolled-back';
|
|
24
|
+
}
|
|
25
|
+
export interface MutationEngineOptions {
|
|
26
|
+
runtime: LocationRuntime;
|
|
27
|
+
/** Records previous and next values in the log. */
|
|
28
|
+
recordValues?: boolean;
|
|
29
|
+
onLog?(entry: MutationLogEntry): void;
|
|
30
|
+
}
|
|
31
|
+
export interface MutationEngine {
|
|
32
|
+
apply(operation: MutationOperation, scope: unknown, context: MutationContext): MutationResult;
|
|
33
|
+
/** Applies a value directly to a location, used by inputs and native results. */
|
|
34
|
+
set(location: MutationOperation extends {
|
|
35
|
+
target: infer L;
|
|
36
|
+
} ? L : never, value: unknown, scope: unknown, context: MutationContext): MutationResult;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The single place Axiom-managed state is written. Everything else — actions, inputs,
|
|
40
|
+
* native results — goes through here, which is what makes every mutation observable and
|
|
41
|
+
* attributable.
|
|
42
|
+
*/
|
|
43
|
+
export declare function createMutationEngine(options: MutationEngineOptions): MutationEngine;
|
|
44
|
+
//# sourceMappingURL=mutation-engine.d.ts.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describePath, resolveLocation } from './resolve-location.js';
|
|
2
|
+
import { cloneValue, isRecord, valuesEqual } from './values.js';
|
|
3
|
+
/**
|
|
4
|
+
* The single place Axiom-managed state is written. Everything else — actions, inputs,
|
|
5
|
+
* native results — goes through here, which is what makes every mutation observable and
|
|
6
|
+
* attributable.
|
|
7
|
+
*/
|
|
8
|
+
export function createMutationEngine(options) {
|
|
9
|
+
const { runtime } = options;
|
|
10
|
+
const recordValues = options.recordValues !== false;
|
|
11
|
+
function log(entry) {
|
|
12
|
+
options.onLog?.(entry);
|
|
13
|
+
}
|
|
14
|
+
function applyValue(location, value, scope, context, kind) {
|
|
15
|
+
const resolved = resolveLocation(location, scope, runtime);
|
|
16
|
+
const previous = recordValues ? cloneValue(resolved.read()) : undefined;
|
|
17
|
+
resolved.write(value);
|
|
18
|
+
log({
|
|
19
|
+
...context,
|
|
20
|
+
operation: kind,
|
|
21
|
+
path: resolved.path,
|
|
22
|
+
description: describePath(resolved.path),
|
|
23
|
+
...(recordValues ? { oldValue: previous, newValue: cloneValue(value) } : {}),
|
|
24
|
+
});
|
|
25
|
+
return { affectedStates: [resolved.rootStateId], affectedLocations: [resolved.path] };
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
set(location, value, scope, context) {
|
|
29
|
+
return applyValue(location, value, scope, context, 'set');
|
|
30
|
+
},
|
|
31
|
+
apply(operation, scope, context) {
|
|
32
|
+
switch (operation.kind) {
|
|
33
|
+
case 'set':
|
|
34
|
+
return applyValue(operation.target, runtime.evaluate(operation.value, scope), scope, context, 'set');
|
|
35
|
+
case 'insert': {
|
|
36
|
+
const resolved = resolveLocation(operation.target, scope, runtime);
|
|
37
|
+
const current = resolved.read();
|
|
38
|
+
const items = Array.isArray(current) ? current : [];
|
|
39
|
+
const value = cloneValue(runtime.evaluate(operation.value, scope));
|
|
40
|
+
const next = operation.position === 'start' ? [value, ...items] : [...items, value];
|
|
41
|
+
resolved.write(next);
|
|
42
|
+
log({
|
|
43
|
+
...context,
|
|
44
|
+
operation: 'insert',
|
|
45
|
+
path: resolved.path,
|
|
46
|
+
description: describePath(resolved.path),
|
|
47
|
+
...(recordValues ? { newValue: value } : {}),
|
|
48
|
+
});
|
|
49
|
+
return { affectedStates: [resolved.rootStateId], affectedLocations: [resolved.path] };
|
|
50
|
+
}
|
|
51
|
+
case 'remove': {
|
|
52
|
+
const collection = resolveLocation(operation.target.collection, scope, runtime);
|
|
53
|
+
const current = collection.read();
|
|
54
|
+
const items = Array.isArray(current) ? current : [];
|
|
55
|
+
const selector = operation.target.selector;
|
|
56
|
+
const position = selector.kind === 'identity'
|
|
57
|
+
? items.findIndex((item) => isRecord(item) &&
|
|
58
|
+
valuesEqual(item[selector.fieldId], runtime.evaluate(selector.value, scope)))
|
|
59
|
+
: Number(runtime.evaluate(selector.index, scope));
|
|
60
|
+
if (position < 0 || position >= items.length) {
|
|
61
|
+
return { affectedStates: [], affectedLocations: [] };
|
|
62
|
+
}
|
|
63
|
+
const removed = recordValues ? cloneValue(items[position]) : undefined;
|
|
64
|
+
collection.write(items.filter((_, index) => index !== position));
|
|
65
|
+
const path = {
|
|
66
|
+
rootStateId: collection.path.rootStateId,
|
|
67
|
+
segments: [
|
|
68
|
+
...collection.path.segments,
|
|
69
|
+
selector.kind === 'identity'
|
|
70
|
+
? {
|
|
71
|
+
kind: 'collection-item',
|
|
72
|
+
fieldId: selector.fieldId,
|
|
73
|
+
identity: runtime.evaluate(selector.value, scope),
|
|
74
|
+
}
|
|
75
|
+
: { kind: 'collection-item', index: position },
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
log({
|
|
79
|
+
...context,
|
|
80
|
+
operation: 'remove',
|
|
81
|
+
path,
|
|
82
|
+
description: describePath(path),
|
|
83
|
+
...(recordValues ? { oldValue: removed } : {}),
|
|
84
|
+
});
|
|
85
|
+
return { affectedStates: [collection.path.rootStateId], affectedLocations: [path] };
|
|
86
|
+
}
|
|
87
|
+
default:
|
|
88
|
+
throw new Error(`Unknown mutation kind "${operation.kind}"`);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Expression, FieldId, Location, NodeId } from '@cynodia/axiom-core';
|
|
2
|
+
export interface ResolvedPathSegment {
|
|
3
|
+
kind: 'field' | 'collection-item';
|
|
4
|
+
fieldId?: FieldId;
|
|
5
|
+
/** The resolved identity value, for an identity selector. */
|
|
6
|
+
identity?: unknown;
|
|
7
|
+
index?: number;
|
|
8
|
+
}
|
|
9
|
+
/** Semantic provenance of a resolved location: which state, then how to get inside it. */
|
|
10
|
+
export interface ResolvedPath {
|
|
11
|
+
rootStateId: NodeId;
|
|
12
|
+
segments: ResolvedPathSegment[];
|
|
13
|
+
}
|
|
14
|
+
export interface ResolvedLocation<T = unknown> {
|
|
15
|
+
read(): T;
|
|
16
|
+
write(value: T): void;
|
|
17
|
+
rootStateId: NodeId;
|
|
18
|
+
path: ResolvedPath;
|
|
19
|
+
}
|
|
20
|
+
export declare class LocationResolutionError extends Error {
|
|
21
|
+
readonly path: ResolvedPath;
|
|
22
|
+
constructor(message: string, path: ResolvedPath);
|
|
23
|
+
}
|
|
24
|
+
export interface LocationRuntime {
|
|
25
|
+
readState(stateId: NodeId): unknown;
|
|
26
|
+
writeState(stateId: NodeId, value: unknown): void;
|
|
27
|
+
evaluate(expression: Expression, scope: unknown): unknown;
|
|
28
|
+
}
|
|
29
|
+
/** Renders a resolved path the way the inspector and the mutation log show it. */
|
|
30
|
+
export declare function describePath(path: ResolvedPath): string;
|
|
31
|
+
/**
|
|
32
|
+
* Turns a Location into a readable and writable address. Writes rebuild the path from
|
|
33
|
+
* the root state, so a mutation never depends on the identity of an object that some
|
|
34
|
+
* expression happened to return.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveLocation<T = unknown>(location: Location, scope: unknown, runtime: LocationRuntime): ResolvedLocation<T>;
|
|
37
|
+
//# sourceMappingURL=resolve-location.d.ts.map
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { isRecord, valuesEqual } from './values.js';
|
|
2
|
+
export class LocationResolutionError extends Error {
|
|
3
|
+
path;
|
|
4
|
+
constructor(message, path) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'LocationResolutionError';
|
|
7
|
+
this.path = path;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Renders a resolved path the way the inspector and the mutation log show it. */
|
|
11
|
+
export function describePath(path) {
|
|
12
|
+
const segments = path.segments.map((segment) => {
|
|
13
|
+
if (segment.kind === 'field') {
|
|
14
|
+
return String(segment.fieldId);
|
|
15
|
+
}
|
|
16
|
+
return segment.index === undefined ? `[${String(segment.identity)}]` : `[#${segment.index}]`;
|
|
17
|
+
});
|
|
18
|
+
return [path.rootStateId, ...segments].join(' → ');
|
|
19
|
+
}
|
|
20
|
+
function toPath(location, scope, runtime) {
|
|
21
|
+
switch (location.kind) {
|
|
22
|
+
case 'state':
|
|
23
|
+
return { rootStateId: location.stateId, segments: [] };
|
|
24
|
+
case 'field': {
|
|
25
|
+
const parent = toPath(location.target, scope, runtime);
|
|
26
|
+
return {
|
|
27
|
+
rootStateId: parent.rootStateId,
|
|
28
|
+
segments: [...parent.segments, { kind: 'field', fieldId: location.fieldId }],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
case 'collection-item': {
|
|
32
|
+
const parent = toPath(location.collection, scope, runtime);
|
|
33
|
+
const segment = location.selector.kind === 'identity'
|
|
34
|
+
? {
|
|
35
|
+
kind: 'collection-item',
|
|
36
|
+
fieldId: location.selector.fieldId,
|
|
37
|
+
identity: runtime.evaluate(location.selector.value, scope),
|
|
38
|
+
}
|
|
39
|
+
: {
|
|
40
|
+
kind: 'collection-item',
|
|
41
|
+
index: Number(runtime.evaluate(location.selector.index, scope)),
|
|
42
|
+
};
|
|
43
|
+
return { rootStateId: parent.rootStateId, segments: [...parent.segments, segment] };
|
|
44
|
+
}
|
|
45
|
+
default:
|
|
46
|
+
throw new Error(`Unknown location kind "${location.kind}"`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function indexOfItem(collection, segment) {
|
|
50
|
+
if (segment.index !== undefined) {
|
|
51
|
+
return segment.index >= 0 && segment.index < collection.length ? segment.index : -1;
|
|
52
|
+
}
|
|
53
|
+
return collection.findIndex((item) => isRecord(item) && valuesEqual(item[String(segment.fieldId)], segment.identity));
|
|
54
|
+
}
|
|
55
|
+
function readAt(value, segments, index) {
|
|
56
|
+
if (index >= segments.length) {
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
const segment = segments[index];
|
|
60
|
+
if (segment.kind === 'field') {
|
|
61
|
+
const source = isRecord(value) ? value[String(segment.fieldId)] : undefined;
|
|
62
|
+
return readAt(source === undefined ? null : source, segments, index + 1);
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(value)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const position = indexOfItem(value, segment);
|
|
68
|
+
return position < 0 ? null : readAt(value[position], segments, index + 1);
|
|
69
|
+
}
|
|
70
|
+
/** Rebuilds the value with one position replaced. Nothing existing is mutated. */
|
|
71
|
+
function writeAt(value, segments, index, next, path) {
|
|
72
|
+
if (index >= segments.length) {
|
|
73
|
+
return next;
|
|
74
|
+
}
|
|
75
|
+
const segment = segments[index];
|
|
76
|
+
if (segment.kind === 'field') {
|
|
77
|
+
const base = isRecord(value) ? value : {};
|
|
78
|
+
const key = String(segment.fieldId);
|
|
79
|
+
return { ...base, [key]: writeAt(base[key], segments, index + 1, next, path) };
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(value)) {
|
|
82
|
+
throw new LocationResolutionError(`${describePath(path)} does not address a collection`, path);
|
|
83
|
+
}
|
|
84
|
+
const position = indexOfItem(value, segment);
|
|
85
|
+
if (position < 0) {
|
|
86
|
+
throw new LocationResolutionError(`No item matches ${describePath(path)}`, path);
|
|
87
|
+
}
|
|
88
|
+
const copy = value.slice();
|
|
89
|
+
copy[position] = writeAt(value[position], segments, index + 1, next, path);
|
|
90
|
+
return copy;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Turns a Location into a readable and writable address. Writes rebuild the path from
|
|
94
|
+
* the root state, so a mutation never depends on the identity of an object that some
|
|
95
|
+
* expression happened to return.
|
|
96
|
+
*/
|
|
97
|
+
export function resolveLocation(location, scope, runtime) {
|
|
98
|
+
const path = toPath(location, scope, runtime);
|
|
99
|
+
return {
|
|
100
|
+
rootStateId: path.rootStateId,
|
|
101
|
+
path,
|
|
102
|
+
read: () => readAt(runtime.readState(path.rootStateId), path.segments, 0),
|
|
103
|
+
write: (value) => {
|
|
104
|
+
const root = runtime.readState(path.rootStateId);
|
|
105
|
+
runtime.writeState(path.rootStateId, writeAt(root, path.segments, 0, value, path));
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The state store. It hands out only frozen values and is the sole owner of the map, so
|
|
3
|
+
* no consumer can change application state by holding on to something it read.
|
|
4
|
+
*/
|
|
5
|
+
export interface StateStore {
|
|
6
|
+
has(stateId: string): boolean;
|
|
7
|
+
read(stateId: string): unknown;
|
|
8
|
+
write(stateId: string, value: unknown): void;
|
|
9
|
+
keys(): string[];
|
|
10
|
+
capture(): unknown;
|
|
11
|
+
restore(snapshot: unknown): void;
|
|
12
|
+
}
|
|
13
|
+
export declare function createStateStore(): StateStore;
|
|
14
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { deepFreeze } from './values.js';
|
|
2
|
+
export function createStateStore() {
|
|
3
|
+
const values = new Map();
|
|
4
|
+
return {
|
|
5
|
+
has: (stateId) => values.has(stateId),
|
|
6
|
+
read: (stateId) => values.get(stateId),
|
|
7
|
+
write: (stateId, value) => {
|
|
8
|
+
values.set(stateId, deepFreeze(value));
|
|
9
|
+
},
|
|
10
|
+
keys: () => [...values.keys()],
|
|
11
|
+
capture: () => new Map(values),
|
|
12
|
+
restore: (snapshot) => {
|
|
13
|
+
values.clear();
|
|
14
|
+
for (const [key, value] of snapshot) {
|
|
15
|
+
values.set(key, value);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime transactions. The semantic API is independent of the snapshot strategy, so a
|
|
3
|
+
* coarse whole-store snapshot can be replaced later without touching graph semantics.
|
|
4
|
+
*/
|
|
5
|
+
export interface StoreSnapshot {
|
|
6
|
+
capture(): unknown;
|
|
7
|
+
restore(snapshot: unknown): void;
|
|
8
|
+
}
|
|
9
|
+
export interface RuntimeTransaction {
|
|
10
|
+
id: string;
|
|
11
|
+
/** True for the outermost transaction; nested ones join their parent. */
|
|
12
|
+
isRoot: boolean;
|
|
13
|
+
commit(): void;
|
|
14
|
+
rollback(): void;
|
|
15
|
+
}
|
|
16
|
+
export interface TransactionManager {
|
|
17
|
+
begin(): RuntimeTransaction;
|
|
18
|
+
currentId(): string | undefined;
|
|
19
|
+
}
|
|
20
|
+
export declare function createTransactionManager(store: StoreSnapshot, nextId: () => string): TransactionManager;
|
|
21
|
+
//# sourceMappingURL=transaction.d.ts.map
|