@mantaq/sugar 0.0.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 +418 -0
- package/dist/index.d.mts +68 -0
- package/dist/index.mjs +122 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anders Can
|
|
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,418 @@
|
|
|
1
|
+
# @mantaq/sugar
|
|
2
|
+
|
|
3
|
+
Convenience helpers for [@mantaq/core](../core) actors. State/event batching, pattern matching, effect utilities, and dynamic child management.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @mantaq/sugar
|
|
9
|
+
# or
|
|
10
|
+
pnpm add @mantaq/sugar
|
|
11
|
+
# or
|
|
12
|
+
yarn add @mantaq/sugar
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { states, events, matches, withTimeout } from "@mantaq/sugar";
|
|
19
|
+
import { Actor, VirtualClock } from "@mantaq/core";
|
|
20
|
+
|
|
21
|
+
const s = states("idle", "loading", "success", "error");
|
|
22
|
+
const e = events("fetch", "resolve", "fail");
|
|
23
|
+
|
|
24
|
+
const clock = new VirtualClock();
|
|
25
|
+
|
|
26
|
+
const machine = new Actor({
|
|
27
|
+
inputs: [e.fetch],
|
|
28
|
+
internal: [e.resolve, e.fail],
|
|
29
|
+
context: {},
|
|
30
|
+
states: [s.idle, s.loading, s.success, s.error],
|
|
31
|
+
initial: s.idle,
|
|
32
|
+
clock,
|
|
33
|
+
setup: (m) => {
|
|
34
|
+
m.on(s.idle, e.fetch, () => ({ state: s.loading }));
|
|
35
|
+
m.effect(s.loading, (input) => withTimeout(2000, input, () => e.resolve.create()));
|
|
36
|
+
m.on(s.loading, e.resolve, () => ({ state: s.success }));
|
|
37
|
+
m.on(s.loading, e.fail, () => ({ state: s.error }));
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
matches(machine, "idle"); // true
|
|
42
|
+
machine.send(e.fetch.create());
|
|
43
|
+
matches(machine, "loading"); // true
|
|
44
|
+
clock.advance(2000);
|
|
45
|
+
matches(machine, "success"); // true
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Patterns
|
|
49
|
+
|
|
50
|
+
### Type-safe state groups with `states()`
|
|
51
|
+
|
|
52
|
+
Use `states()` to create a record of `StateRef`s with full type safety. The returned keys match the input strings exactly.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { states } from "@mantaq/sugar";
|
|
56
|
+
|
|
57
|
+
const s = states("idle", "loading", "success", "error");
|
|
58
|
+
|
|
59
|
+
s.idle.name; // "idle"
|
|
60
|
+
s.success.final(); // StateRef with isFinal = true
|
|
61
|
+
|
|
62
|
+
const actor = new Actor({
|
|
63
|
+
states: [s.idle, s.loading, s.success, s.error],
|
|
64
|
+
initial: s.idle,
|
|
65
|
+
// ...
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Anti-pattern:** Creating states manually without `states()` and losing type safety:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { state } from "@mantaq/core";
|
|
73
|
+
|
|
74
|
+
const idle = state("idle")();
|
|
75
|
+
const loading = state("loading")();
|
|
76
|
+
const success = state("success")();
|
|
77
|
+
const error = state("error")();
|
|
78
|
+
// No single record — easy to miss one, no autocomplete, no compile check
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`states()` guarantees all names exist as typed keys. Manual creation requires you to keep references in sync yourself.
|
|
82
|
+
|
|
83
|
+
### Type-safe event groups with `events()`
|
|
84
|
+
|
|
85
|
+
Same as `states()` but for events. Creates a typed record with `.create()`, `.is()`, and other `EventRef` methods.
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { events } from "@mantaq/sugar";
|
|
89
|
+
|
|
90
|
+
const e = events("fetch", "resolve", "fail");
|
|
91
|
+
|
|
92
|
+
e.fetch.create(); // { id: "fetch" }
|
|
93
|
+
e.resolve.is(emittedEvent); // boolean
|
|
94
|
+
|
|
95
|
+
const actor = new Actor({
|
|
96
|
+
inputs: [e.fetch],
|
|
97
|
+
internal: [e.resolve],
|
|
98
|
+
// ...
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Anti-pattern:** Scattering event refs across files without a shared group:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { event } from "@mantaq/core";
|
|
106
|
+
|
|
107
|
+
const fetchEvent = event("fetch")();
|
|
108
|
+
const resolveEvent = event("resolve")();
|
|
109
|
+
const failEvent = event("fail")();
|
|
110
|
+
// No single source of truth — naming drift, typos not caught
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`events()` creates a single typed object. Any typo in a key is a compile error.
|
|
114
|
+
|
|
115
|
+
### Pattern matching with `matches()`
|
|
116
|
+
|
|
117
|
+
`matches()` uses dot-separated paths. The format alternates between state names and region keys: `"state.regionKey.state"`.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { matches } from "@mantaq/sugar";
|
|
121
|
+
|
|
122
|
+
// flat
|
|
123
|
+
matches(actor, "idle"); // true if in idle
|
|
124
|
+
|
|
125
|
+
// hierarchical — state, region key, nested state
|
|
126
|
+
matches(actor, "connected.default.active");
|
|
127
|
+
|
|
128
|
+
// parallel — match any region
|
|
129
|
+
matches(actor, "player.playing");
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Anti-pattern:** Using wrong path format:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// WRONG — region key must come after parent state
|
|
136
|
+
matches(actor, "active.connected.default");
|
|
137
|
+
|
|
138
|
+
// WRONG — trailing dot always returns false
|
|
139
|
+
matches(actor, "idle.");
|
|
140
|
+
|
|
141
|
+
// WRONG — empty string returns false
|
|
142
|
+
matches(actor, "");
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Path format: `"parentState.regionKey.childState"`. The region key is the key you passed to the actor's `regions` option, not the nested state's name.
|
|
146
|
+
|
|
147
|
+
### Using `tag()` for state grouping
|
|
148
|
+
|
|
149
|
+
`tag()` groups multiple `StateRef`s and tests if a snapshot matches any of them. Useful for UI that needs to branch on state categories.
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { tag } from "@mantaq/sugar";
|
|
153
|
+
|
|
154
|
+
const s = states("idle", "loading", "success", "error");
|
|
155
|
+
const busy = tag(s.idle, s.loading);
|
|
156
|
+
const terminal = tag(s.success, s.error);
|
|
157
|
+
|
|
158
|
+
// In UI
|
|
159
|
+
if (busy.has(snapshot)) {
|
|
160
|
+
showSpinner();
|
|
161
|
+
} else if (terminal.has(snapshot)) {
|
|
162
|
+
showResult();
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**Anti-pattern:** Testing individual states manually with `matches()`:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
if (matches(actor, "idle") || matches(actor, "loading")) {
|
|
170
|
+
showSpinner();
|
|
171
|
+
}
|
|
172
|
+
// Breaks when hierarchy changes, verbose, error-prone
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`tag()` searches recursively through parallel regions and nested hierarchies — works regardless of depth.
|
|
176
|
+
|
|
177
|
+
### ActorMap patterns
|
|
178
|
+
|
|
179
|
+
`ActorMap` manages dynamic children by string key. Always use `ensure()` to avoid duplicate spawns.
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { ActorMap, isIn } from "@mantaq/sugar";
|
|
183
|
+
|
|
184
|
+
const map = new ActorMap(parentActor);
|
|
185
|
+
|
|
186
|
+
// Safe spawn — no-op if key exists
|
|
187
|
+
map.ensure("child1", () => createChild());
|
|
188
|
+
|
|
189
|
+
// Send events
|
|
190
|
+
map.send("child1", someEvent);
|
|
191
|
+
|
|
192
|
+
// Snapshot a child
|
|
193
|
+
const snap = map.snapshot("child1");
|
|
194
|
+
if (snap && isIn(snap, "active")) {
|
|
195
|
+
// child is active
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Kill when done
|
|
199
|
+
map.kill("child1");
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Anti-pattern:** Using `spawn()` without checking for duplicates:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
map.spawn("child1", () => createChild());
|
|
206
|
+
map.spawn("child1", () => createChild()); // overwrites silently — previous child lost
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`ensure()` is idempotent — safe to call repeatedly. `spawn()` always replaces.
|
|
210
|
+
|
|
211
|
+
### Effect utilities: `withPromise` vs manual handling
|
|
212
|
+
|
|
213
|
+
Always use `withPromise()` for promise-to-event bridging. It handles abort signals automatically.
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
import { withPromise } from "@mantaq/sugar";
|
|
217
|
+
|
|
218
|
+
// Correct — abort-aware
|
|
219
|
+
withPromise(fetchData(), input.signal, input.emit, {
|
|
220
|
+
success: (data) => ({ id: "loaded", data }),
|
|
221
|
+
error: (err) => ({ id: "loadFailed", error: String(err) }),
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**Anti-pattern:** Manual `.then()/.catch()` without abort handling:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
fetchData()
|
|
229
|
+
.then((data) => {
|
|
230
|
+
input.emit({ id: "loaded", data }); // fires even if actor destroyed
|
|
231
|
+
})
|
|
232
|
+
.catch((err) => {
|
|
233
|
+
input.emit({ id: "loadFailed", error: String(err) }); // same problem
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`withPromise` checks `isAborted(signal)` before each emit. Manual chains fire events into destroyed actors.
|
|
238
|
+
|
|
239
|
+
## Helpers
|
|
240
|
+
|
|
241
|
+
### `matches(actor, pattern)`
|
|
242
|
+
|
|
243
|
+
Dot-notation state pattern matching against an actor's snapshot. Works with flat, hierarchical, and parallel states.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import { matches } from "@mantaq/sugar";
|
|
247
|
+
|
|
248
|
+
// flat
|
|
249
|
+
matches(actor, "idle"); // true/false
|
|
250
|
+
|
|
251
|
+
// hierarchical — region path
|
|
252
|
+
matches(actor, "connected.default.active");
|
|
253
|
+
|
|
254
|
+
// parallel — any region matches
|
|
255
|
+
matches(actor, "player.playback.playing");
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Pattern format: `"state.regionKey.state"` or just `"state"` for flat. Empty string or trailing dots return `false`.
|
|
259
|
+
|
|
260
|
+
### `ActorMap`
|
|
261
|
+
|
|
262
|
+
Dynamic child actor registry. Spawns, sends to, kills, and snapshots children by string key. Optionally wires child output to a parent actor.
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
import { ActorMap, isIn } from "@mantaq/sugar";
|
|
266
|
+
|
|
267
|
+
const map = new ActorMap(); // or new ActorMap(parentActor)
|
|
268
|
+
|
|
269
|
+
map.spawn("child1", () => childActor);
|
|
270
|
+
map.spawn("child2", () => otherActor);
|
|
271
|
+
|
|
272
|
+
map.send("child1", someEvent);
|
|
273
|
+
map.kill("child2");
|
|
274
|
+
map.keys(); // ["child1"]
|
|
275
|
+
|
|
276
|
+
const snap = map.snapshot("child1"); // Snapshot | undefined
|
|
277
|
+
if (snap && isIn(snap, "active")) {
|
|
278
|
+
// child is active
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
**`ensure(key, factory)`** — spawn only if key missing:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
map.ensure("child1", () => childActor); // no-op if exists
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
**Typing children:** `ActorMap` is untyped by design. Access children through your own typed references or use `matches()` / `snapshot()` for type-safe checks.
|
|
289
|
+
|
|
290
|
+
**Parent wiring:** When constructed with a parent actor, child outputs are automatically forwarded to the parent:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
const map = new ActorMap(parentActor);
|
|
294
|
+
map.spawn("child", () => childActor);
|
|
295
|
+
// child emits → parent receives
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### `broadcast(map, event)`
|
|
299
|
+
|
|
300
|
+
Send an event to every key in an `ActorMap` (or any `SendableMap`):
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
import { broadcast } from "@mantaq/sugar";
|
|
304
|
+
|
|
305
|
+
broadcast(map, { id: "ping" });
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Works with `ActorMap` or any object implementing `{ keys(): string[]; send(key, event): void }`.
|
|
309
|
+
|
|
310
|
+
### `tag(...stateRefs)`
|
|
311
|
+
|
|
312
|
+
Group multiple `StateRef`s and test if a snapshot matches any of them. Useful for UI state grouping.
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
import { tag } from "@mantaq/sugar";
|
|
316
|
+
import { state } from "@mantaq/core";
|
|
317
|
+
|
|
318
|
+
const idle = state("idle")();
|
|
319
|
+
const loading = state("loading")();
|
|
320
|
+
const busy = tag(idle, loading);
|
|
321
|
+
|
|
322
|
+
busy.has(snapshot); // true if snapshot is idle or loading (any depth)
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Searches recursively through parallel regions and nested hierarchies.
|
|
326
|
+
|
|
327
|
+
### `states(...names)`
|
|
328
|
+
|
|
329
|
+
Batch-create `StateRef`s as a typed record:
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
import { states } from "@mantaq/sugar";
|
|
333
|
+
|
|
334
|
+
const s = states("idle", "loading", "success");
|
|
335
|
+
s.idle.name; // "idle"
|
|
336
|
+
s.success.final(); // StateRef with isFinal = true
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### `events(...names)`
|
|
340
|
+
|
|
341
|
+
Batch-create `EventRef`s as a typed record:
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
import { events } from "@mantaq/sugar";
|
|
345
|
+
|
|
346
|
+
const e = events("click", "submit");
|
|
347
|
+
e.click.create(); // { id: "click" }
|
|
348
|
+
e.click.is(emittedEvent); // boolean
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### `withPromise(promise, signal, emit, events)`
|
|
352
|
+
|
|
353
|
+
Bridge a promise into actor events. Emits `success` on resolve, `error` on reject. Respects `AbortSignal` — skips emit if aborted before settlement.
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
import { withPromise } from "@mantaq/sugar";
|
|
357
|
+
|
|
358
|
+
withPromise(fetchData(), input.signal, input.emit, {
|
|
359
|
+
success: (data) => ({ id: "loaded", data }),
|
|
360
|
+
error: (err) => ({ id: "loadFailed", error: String(err) }),
|
|
361
|
+
});
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### `withTimeout(ms, input, eventFn)`
|
|
365
|
+
|
|
366
|
+
Schedule a timeout event through the actor's clock. Aborts cleanly if the actor is destroyed before timeout fires.
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
import { withTimeout } from "@mantaq/sugar";
|
|
370
|
+
|
|
371
|
+
withTimeout(5000, input, () => ({ id: "timeout", reason: "exceeded" }));
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
## Migration from Core
|
|
375
|
+
|
|
376
|
+
Starting with `@mantaq/core`? Here's what sugar adds.
|
|
377
|
+
|
|
378
|
+
| Core | Sugar | Benefit |
|
|
379
|
+
| --------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
|
|
380
|
+
| `state("idle")()` × N | `states("idle", "loading")` | Single call. Typed record. No missed names. |
|
|
381
|
+
| `event("FETCH")()` × N | `events("FETCH", "RESOLVE")` | Single call. Typed record. Shared source of truth. |
|
|
382
|
+
| `snapshot().path.includes("active")` | `matches(actor, "active")` | Works on actor directly. Dot-notation for hierarchies: `"idle.region.a"`. |
|
|
383
|
+
| Manual `.then()/.catch()` + abort check | `withPromise(promise, signal, emit, events)` | Auto abort-aware. No forgotten `signal.aborted` guard. |
|
|
384
|
+
| Group states by variable naming | `tag(stateA, stateB).has(snapshot)` | Recursive matching. Works through parallel regions. |
|
|
385
|
+
|
|
386
|
+
**Core alone:**
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
import { state, event } from "@mantaq/core";
|
|
390
|
+
|
|
391
|
+
const idle = state("idle")();
|
|
392
|
+
const loading = state("loading")();
|
|
393
|
+
const success = state("success")();
|
|
394
|
+
const failEvent = event("FAIL")();
|
|
395
|
+
|
|
396
|
+
actor.snapshot().path.includes("idle"); // flat name only
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**With sugar:**
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
import { states, events, matches, tag } from "@mantaq/sugar";
|
|
403
|
+
|
|
404
|
+
const s = states("idle", "loading", "success");
|
|
405
|
+
const e = events("FAIL");
|
|
406
|
+
|
|
407
|
+
matches(actor, "idle"); // actor, not snapshot
|
|
408
|
+
matches(actor, "connected.default.active"); // hierarchical
|
|
409
|
+
|
|
410
|
+
const busy = tag(s.idle, s.loading);
|
|
411
|
+
busy.has(actor.snapshot()); // recursive
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
No migration needed. Sugar wraps core — use both side by side.
|
|
415
|
+
|
|
416
|
+
## License
|
|
417
|
+
|
|
418
|
+
MIT
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { AnyActor, AnyEventRef, EffectInput, EventRef, InternalEvent, Snapshot, StateRef } from "@mantaq/core";
|
|
2
|
+
//#region src/actors/matches.d.ts
|
|
3
|
+
declare function matches(actor: {
|
|
4
|
+
snapshot(): Snapshot;
|
|
5
|
+
}, pattern: string): boolean;
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/transitions/broadcast.d.ts
|
|
8
|
+
type EventLike = {
|
|
9
|
+
id: string;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
};
|
|
12
|
+
type SendableEvent = AnyEventRef | EventLike;
|
|
13
|
+
interface SendableMap<T extends SendableEvent = SendableEvent> {
|
|
14
|
+
keys(): string[];
|
|
15
|
+
send(key: string, event: T): void;
|
|
16
|
+
}
|
|
17
|
+
declare function broadcast<const T extends SendableEvent>(map: SendableMap<T>, event: T): void;
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/actors/actor-map.d.ts
|
|
20
|
+
declare class ActorMap implements SendableMap<SendableEvent> {
|
|
21
|
+
#private;
|
|
22
|
+
constructor(parent?: AnyActor);
|
|
23
|
+
spawn(key: string, factory: () => AnyActor): void;
|
|
24
|
+
send(key: string, event: SendableEvent): void;
|
|
25
|
+
kill(key: string): void;
|
|
26
|
+
get size(): number;
|
|
27
|
+
has(key: string): boolean;
|
|
28
|
+
keys(): string[];
|
|
29
|
+
ensure(key: string, factory: () => AnyActor): void;
|
|
30
|
+
snapshot(key: string): Snapshot | undefined;
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/state.d.ts
|
|
34
|
+
declare function states<T extends string>(...names: T[]): { [K in T]: StateRef<K>; };
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/event.d.ts
|
|
37
|
+
declare function events<T extends string>(...names: T[]): { [K in T]: EventRef<K>; };
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/effects/promise.d.ts
|
|
40
|
+
type EmitFn = (event: {
|
|
41
|
+
id: string;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}) => void;
|
|
44
|
+
declare function withPromise<T>(promise: Promise<T>, signal: AbortSignal, emit: EmitFn, events: {
|
|
45
|
+
success: (data: T) => {
|
|
46
|
+
id: string;
|
|
47
|
+
[key: string]: unknown;
|
|
48
|
+
};
|
|
49
|
+
error: (err: unknown) => {
|
|
50
|
+
id: string;
|
|
51
|
+
[key: string]: unknown;
|
|
52
|
+
};
|
|
53
|
+
}): void;
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/effects/timeout.d.ts
|
|
56
|
+
declare function withTimeout<ActorContext>(ms: number, input: EffectInput<ActorContext>, event: () => InternalEvent): void;
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/tags.d.ts
|
|
59
|
+
interface Tag {
|
|
60
|
+
has(snapshot: Snapshot): boolean;
|
|
61
|
+
}
|
|
62
|
+
declare function tag(...stateNames: StateRef<string, unknown>[]): Tag;
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/snapshot.d.ts
|
|
65
|
+
declare function isIn(snapshot: Snapshot, stateRefName: string): boolean;
|
|
66
|
+
declare function activeLeaves(snapshot: Snapshot): string[];
|
|
67
|
+
//#endregion
|
|
68
|
+
export { ActorMap, type EventLike, type SendableEvent, type SendableMap, activeLeaves, broadcast, events, isIn, matches, states, tag, withPromise, withTimeout };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { abortEffects, setOutputHandler } from "@mantaq/core/internal";
|
|
2
|
+
import { Either } from "@mantaq/utils";
|
|
3
|
+
import { event, state } from "@mantaq/core";
|
|
4
|
+
//#region src/actors/matches.ts
|
|
5
|
+
function matchSnapshot(snapshot, parts, index) {
|
|
6
|
+
const stateName = snapshot.path[0];
|
|
7
|
+
for (let end = index; end < parts.length; end++) {
|
|
8
|
+
if (parts.slice(index, end + 1).join(".") !== stateName) continue;
|
|
9
|
+
if (end === parts.length - 1) return true;
|
|
10
|
+
const regionSnap = snapshot.regions[parts[end + 1]];
|
|
11
|
+
if (!regionSnap) return false;
|
|
12
|
+
if (end + 1 === parts.length - 1) return true;
|
|
13
|
+
return matchSnapshot(regionSnap, parts, end + 2);
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
function matches(actor, pattern) {
|
|
18
|
+
if (!pattern || pattern.endsWith(".") || pattern.startsWith(".") || pattern.includes("..")) return false;
|
|
19
|
+
const parts = pattern.split(".");
|
|
20
|
+
return matchSnapshot(actor.snapshot(), parts, 0);
|
|
21
|
+
}
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region src/actors/actor-map.ts
|
|
24
|
+
var ActorMap = class {
|
|
25
|
+
#actors = /* @__PURE__ */ new Map();
|
|
26
|
+
#parent;
|
|
27
|
+
constructor(parent) {
|
|
28
|
+
this.#parent = parent ?? null;
|
|
29
|
+
}
|
|
30
|
+
spawn(key, factory) {
|
|
31
|
+
if (this.#actors.has(key)) {
|
|
32
|
+
console.warn(`[ActorMap] spawning over existing key "${key}". Old actor will be aborted.`);
|
|
33
|
+
this.kill(key);
|
|
34
|
+
}
|
|
35
|
+
const child = factory();
|
|
36
|
+
if (this.#parent) Either.match(setOutputHandler(child, (event) => {
|
|
37
|
+
this.#parent.send(event);
|
|
38
|
+
}), (err) => console.error(err.message), () => {});
|
|
39
|
+
this.#actors.set(key, child);
|
|
40
|
+
}
|
|
41
|
+
send(key, event) {
|
|
42
|
+
this.#actors.get(key)?.send(event);
|
|
43
|
+
}
|
|
44
|
+
kill(key) {
|
|
45
|
+
const actor = this.#actors.get(key);
|
|
46
|
+
if (actor) Either.match(abortEffects(actor), (err) => console.error(err.message), () => {});
|
|
47
|
+
this.#actors.delete(key);
|
|
48
|
+
}
|
|
49
|
+
get size() {
|
|
50
|
+
return this.#actors.size;
|
|
51
|
+
}
|
|
52
|
+
has(key) {
|
|
53
|
+
return this.#actors.has(key);
|
|
54
|
+
}
|
|
55
|
+
keys() {
|
|
56
|
+
return [...this.#actors.keys()];
|
|
57
|
+
}
|
|
58
|
+
ensure(key, factory) {
|
|
59
|
+
if (!this.#actors.has(key)) this.spawn(key, factory);
|
|
60
|
+
}
|
|
61
|
+
snapshot(key) {
|
|
62
|
+
return this.#actors.get(key)?.snapshot();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/state.ts
|
|
67
|
+
function states(...names) {
|
|
68
|
+
const result = {};
|
|
69
|
+
for (const name of names) result[name] = state(name)();
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/event.ts
|
|
74
|
+
function events(...names) {
|
|
75
|
+
const result = {};
|
|
76
|
+
for (const name of names) result[name] = event(name)();
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/effects/promise.ts
|
|
81
|
+
function withPromise(promise, signal, emit, events) {
|
|
82
|
+
promise.then((data) => {
|
|
83
|
+
if (!signal.aborted) emit(events.success(data));
|
|
84
|
+
}).catch((err) => {
|
|
85
|
+
if (!signal.aborted) emit(events.error(err));
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/effects/timeout.ts
|
|
90
|
+
function withTimeout(ms, input, event) {
|
|
91
|
+
if (typeof ms !== "number" || ms < 0 || !Number.isFinite(ms)) console.warn(`[withTimeout] invalid ms value: ${ms}. Timeout may not fire.`);
|
|
92
|
+
input.clock.setTimeout(ms, () => {
|
|
93
|
+
if (input.signal.aborted) return;
|
|
94
|
+
input.emit(event());
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region src/transitions/broadcast.ts
|
|
99
|
+
function broadcast(map, event) {
|
|
100
|
+
for (const key of map.keys()) map.send(key, event);
|
|
101
|
+
}
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region src/snapshot.ts
|
|
104
|
+
function isIn(snapshot, stateRefName) {
|
|
105
|
+
if (snapshot.path[0] === stateRefName) return true;
|
|
106
|
+
for (const region of Object.values(snapshot.regions)) if (isIn(region, stateRefName)) return true;
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
function activeLeaves(snapshot) {
|
|
110
|
+
if (Object.keys(snapshot.regions).length === 0) return [snapshot.path.join(".")];
|
|
111
|
+
const leaves = [];
|
|
112
|
+
for (const [regionName, region] of Object.entries(snapshot.regions)) for (const leaf of activeLeaves(region)) leaves.push(`${snapshot.path[0]}.${regionName}.${leaf}`);
|
|
113
|
+
return leaves;
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/tags.ts
|
|
117
|
+
function tag(...stateNames) {
|
|
118
|
+
const names = stateNames.map((s) => s.name);
|
|
119
|
+
return { has: (snapshot) => names.some((name) => isIn(snapshot, name)) };
|
|
120
|
+
}
|
|
121
|
+
//#endregion
|
|
122
|
+
export { ActorMap, activeLeaves, broadcast, events, isIn, matches, states, tag, withPromise, withTimeout };
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mantaq/sugar",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Userland sugar for mantaq actors — matches, effect helpers, transition utilities.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/AndersCan/mantaq",
|
|
9
|
+
"directory": "packages/sugar"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./dist/index.mjs",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@mantaq/core": "0.0.1",
|
|
24
|
+
"@mantaq/utils": "0.0.1"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@stryker-mutator/core": "^9",
|
|
28
|
+
"@stryker-mutator/vitest-runner": "^9",
|
|
29
|
+
"@types/node": "^26.2.0",
|
|
30
|
+
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
31
|
+
"@vitest/coverage-v8": "4.1.10",
|
|
32
|
+
"typescript": "^7.0.2",
|
|
33
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.8",
|
|
34
|
+
"vite-plus": "0.2.8",
|
|
35
|
+
"vitest": "4.1.10",
|
|
36
|
+
"@mantaq/pbt": "0.0.1"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "vp pack",
|
|
40
|
+
"dev": "vp pack --watch",
|
|
41
|
+
"test": "vp test",
|
|
42
|
+
"check": "vp check",
|
|
43
|
+
"mutation": "stryker run",
|
|
44
|
+
"coverage": "vp test run --coverage"
|
|
45
|
+
}
|
|
46
|
+
}
|