@daltonr/pathwrite-core 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +2 -1
  2. package/src/index.ts +481 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daltonr/pathwrite-core",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Headless path engine — deterministic state machine with stack-based sub-path orchestration. Zero dependencies.",
@@ -29,6 +29,7 @@
29
29
  "types": "dist/index.d.ts",
30
30
  "files": [
31
31
  "dist",
32
+ "src",
32
33
  "README.md",
33
34
  "LICENSE"
34
35
  ],
package/src/index.ts ADDED
@@ -0,0 +1,481 @@
1
+ export type PathData = Record<string, unknown>;
2
+
3
+ export interface PathStepContext<TData extends PathData = PathData> {
4
+ readonly pathId: string;
5
+ readonly stepId: string;
6
+ readonly data: Readonly<TData>;
7
+ }
8
+
9
+ export interface PathStep<TData extends PathData = PathData> {
10
+ id: string;
11
+ title?: string;
12
+ meta?: Record<string, unknown>;
13
+ shouldSkip?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
14
+ canMoveNext?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
15
+ canMovePrevious?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
16
+ onEnter?: (ctx: PathStepContext<TData>) => Partial<TData> | void | Promise<Partial<TData> | void>;
17
+ onLeave?: (ctx: PathStepContext<TData>) => Partial<TData> | void | Promise<Partial<TData> | void>;
18
+ onSubPathComplete?: (
19
+ subPathId: string,
20
+ subPathData: PathData,
21
+ ctx: PathStepContext<TData>
22
+ ) => Partial<TData> | void | Promise<Partial<TData> | void>;
23
+ }
24
+
25
+ export interface PathDefinition<TData extends PathData = PathData> {
26
+ id: string;
27
+ title?: string;
28
+ steps: PathStep<TData>[];
29
+ }
30
+
31
+ export type StepStatus = "completed" | "current" | "upcoming";
32
+
33
+ export interface StepSummary {
34
+ id: string;
35
+ title?: string;
36
+ meta?: Record<string, unknown>;
37
+ status: StepStatus;
38
+ }
39
+
40
+ export interface PathSnapshot<TData extends PathData = PathData> {
41
+ pathId: string;
42
+ stepId: string;
43
+ stepTitle?: string;
44
+ stepMeta?: Record<string, unknown>;
45
+ stepIndex: number;
46
+ stepCount: number;
47
+ progress: number;
48
+ steps: StepSummary[];
49
+ isFirstStep: boolean;
50
+ isLastStep: boolean;
51
+ nestingLevel: number;
52
+ /** True while an async guard or hook is executing. Use to disable navigation controls. */
53
+ isNavigating: boolean;
54
+ /** Whether the current step's `canMoveNext` guard allows advancing. Async guards default to `true`. */
55
+ canMoveNext: boolean;
56
+ /** Whether the current step's `canMovePrevious` guard allows going back. Async guards default to `true`. */
57
+ canMovePrevious: boolean;
58
+ data: TData;
59
+ }
60
+
61
+ export type PathEvent =
62
+ | { type: "stateChanged"; snapshot: PathSnapshot }
63
+ | { type: "completed"; pathId: string; data: PathData }
64
+ | { type: "cancelled"; pathId: string; data: PathData }
65
+ | {
66
+ type: "resumed";
67
+ resumedPathId: string;
68
+ fromSubPathId: string;
69
+ snapshot: PathSnapshot;
70
+ };
71
+
72
+ interface ActivePath {
73
+ definition: PathDefinition;
74
+ currentStepIndex: number;
75
+ data: PathData;
76
+ }
77
+
78
+ export class PathEngine {
79
+ private activePath: ActivePath | null = null;
80
+ private readonly pathStack: ActivePath[] = [];
81
+ private readonly listeners = new Set<(event: PathEvent) => void>();
82
+ private _isNavigating = false;
83
+
84
+ public subscribe(listener: (event: PathEvent) => void): () => void {
85
+ this.listeners.add(listener);
86
+ return () => this.listeners.delete(listener);
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Public API
91
+ // ---------------------------------------------------------------------------
92
+
93
+ public start(path: PathDefinition, initialData: PathData = {}): Promise<void> {
94
+ this.assertPathHasSteps(path);
95
+ return this._startAsync(path, initialData);
96
+ }
97
+
98
+ /** Starts a sub-path on top of the currently active path. Throws if no path is running. */
99
+ public startSubPath(path: PathDefinition, initialData: PathData = {}): Promise<void> {
100
+ this.requireActivePath();
101
+ return this.start(path, initialData);
102
+ }
103
+
104
+ public next(): Promise<void> {
105
+ const active = this.requireActivePath();
106
+ return this._nextAsync(active);
107
+ }
108
+
109
+ public previous(): Promise<void> {
110
+ const active = this.requireActivePath();
111
+ return this._previousAsync(active);
112
+ }
113
+
114
+ /** Cancel is synchronous (no hooks). Returns a resolved Promise for API consistency. */
115
+ public cancel(): Promise<void> {
116
+ const active = this.requireActivePath();
117
+ if (this._isNavigating) return Promise.resolve();
118
+
119
+ const cancelledPathId = active.definition.id;
120
+ const cancelledData = { ...active.data };
121
+
122
+ if (this.pathStack.length > 0) {
123
+ this.activePath = this.pathStack.pop() ?? null;
124
+ this.emitStateChanged();
125
+ return Promise.resolve();
126
+ }
127
+
128
+ this.activePath = null;
129
+ this.emit({ type: "cancelled", pathId: cancelledPathId, data: cancelledData });
130
+ return Promise.resolve();
131
+ }
132
+
133
+ public setData(key: string, value: unknown): Promise<void> {
134
+ const active = this.requireActivePath();
135
+ active.data[key] = value;
136
+ this.emitStateChanged();
137
+ return Promise.resolve();
138
+ }
139
+
140
+ /** Jumps directly to the step with the given ID. Does not check guards or shouldSkip. */
141
+ public goToStep(stepId: string): Promise<void> {
142
+ const active = this.requireActivePath();
143
+ const targetIndex = active.definition.steps.findIndex((s) => s.id === stepId);
144
+ if (targetIndex === -1) {
145
+ throw new Error(`Step "${stepId}" not found in path "${active.definition.id}".`);
146
+ }
147
+ return this._goToStepAsync(active, targetIndex);
148
+ }
149
+
150
+ public snapshot(): PathSnapshot | null {
151
+ if (this.activePath === null) {
152
+ return null;
153
+ }
154
+
155
+ const active = this.activePath;
156
+ const step = this.getCurrentStep(active);
157
+ const { steps } = active.definition;
158
+ const stepCount = steps.length;
159
+
160
+ return {
161
+ pathId: active.definition.id,
162
+ stepId: step.id,
163
+ stepTitle: step.title,
164
+ stepMeta: step.meta,
165
+ stepIndex: active.currentStepIndex,
166
+ stepCount,
167
+ progress: stepCount <= 1 ? 1 : active.currentStepIndex / (stepCount - 1),
168
+ steps: steps.map((s, i) => ({
169
+ id: s.id,
170
+ title: s.title,
171
+ meta: s.meta,
172
+ status: i < active.currentStepIndex ? "completed" as const
173
+ : i === active.currentStepIndex ? "current" as const
174
+ : "upcoming" as const
175
+ })),
176
+ isFirstStep: active.currentStepIndex === 0,
177
+ isLastStep:
178
+ active.currentStepIndex === stepCount - 1 &&
179
+ this.pathStack.length === 0,
180
+ nestingLevel: this.pathStack.length,
181
+ isNavigating: this._isNavigating,
182
+ canMoveNext: this.evaluateGuardSync(step.canMoveNext, active),
183
+ canMovePrevious: this.evaluateGuardSync(step.canMovePrevious, active),
184
+ data: { ...active.data }
185
+ };
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Private async helpers
190
+ // ---------------------------------------------------------------------------
191
+
192
+ private async _startAsync(path: PathDefinition, initialData: PathData): Promise<void> {
193
+ if (this._isNavigating) return;
194
+
195
+ if (this.activePath !== null) {
196
+ this.pathStack.push(this.activePath);
197
+ }
198
+
199
+ this.activePath = {
200
+ definition: path,
201
+ currentStepIndex: 0,
202
+ data: { ...initialData }
203
+ };
204
+
205
+ this._isNavigating = true;
206
+
207
+ await this.skipSteps(1);
208
+
209
+ if (this.activePath.currentStepIndex >= path.steps.length) {
210
+ this._isNavigating = false;
211
+ await this.finishActivePath();
212
+ return;
213
+ }
214
+
215
+ this.emitStateChanged();
216
+
217
+ try {
218
+ this.applyPatch(await this.enterCurrentStep());
219
+ this._isNavigating = false;
220
+ this.emitStateChanged();
221
+ } catch (err) {
222
+ this._isNavigating = false;
223
+ this.emitStateChanged();
224
+ throw err;
225
+ }
226
+ }
227
+
228
+ private async _nextAsync(active: ActivePath): Promise<void> {
229
+ if (this._isNavigating) return;
230
+
231
+ this._isNavigating = true;
232
+ this.emitStateChanged();
233
+
234
+ try {
235
+ const step = this.getCurrentStep(active);
236
+
237
+ if (await this.canMoveNext(active, step)) {
238
+ this.applyPatch(await this.leaveCurrentStep(active, step));
239
+ active.currentStepIndex += 1;
240
+ await this.skipSteps(1);
241
+
242
+ if (active.currentStepIndex >= active.definition.steps.length) {
243
+ this._isNavigating = false;
244
+ await this.finishActivePath();
245
+ return;
246
+ }
247
+
248
+ this.applyPatch(await this.enterCurrentStep());
249
+ }
250
+
251
+ this._isNavigating = false;
252
+ this.emitStateChanged();
253
+ } catch (err) {
254
+ this._isNavigating = false;
255
+ this.emitStateChanged();
256
+ throw err;
257
+ }
258
+ }
259
+
260
+ private async _previousAsync(active: ActivePath): Promise<void> {
261
+ if (this._isNavigating) return;
262
+
263
+ this._isNavigating = true;
264
+ this.emitStateChanged();
265
+
266
+ try {
267
+ const step = this.getCurrentStep(active);
268
+
269
+ if (await this.canMovePrevious(active, step)) {
270
+ this.applyPatch(await this.leaveCurrentStep(active, step));
271
+ active.currentStepIndex -= 1;
272
+ await this.skipSteps(-1);
273
+
274
+ if (active.currentStepIndex < 0) {
275
+ this._isNavigating = false;
276
+ await this.cancel();
277
+ return;
278
+ }
279
+
280
+ this.applyPatch(await this.enterCurrentStep());
281
+ }
282
+
283
+ this._isNavigating = false;
284
+ this.emitStateChanged();
285
+ } catch (err) {
286
+ this._isNavigating = false;
287
+ this.emitStateChanged();
288
+ throw err;
289
+ }
290
+ }
291
+
292
+ private async _goToStepAsync(active: ActivePath, targetIndex: number): Promise<void> {
293
+ if (this._isNavigating) return;
294
+
295
+ this._isNavigating = true;
296
+ this.emitStateChanged();
297
+
298
+ try {
299
+ const currentStep = this.getCurrentStep(active);
300
+ this.applyPatch(await this.leaveCurrentStep(active, currentStep));
301
+
302
+ active.currentStepIndex = targetIndex;
303
+
304
+ this.applyPatch(await this.enterCurrentStep());
305
+ this._isNavigating = false;
306
+ this.emitStateChanged();
307
+ } catch (err) {
308
+ this._isNavigating = false;
309
+ this.emitStateChanged();
310
+ throw err;
311
+ }
312
+ }
313
+
314
+ private async finishActivePath(): Promise<void> {
315
+ const finished = this.requireActivePath();
316
+ const finishedPathId = finished.definition.id;
317
+ const finishedData = { ...finished.data };
318
+
319
+ if (this.pathStack.length > 0) {
320
+ this.activePath = this.pathStack.pop()!;
321
+ const parent = this.activePath;
322
+ const parentStep = this.getCurrentStep(parent);
323
+
324
+ if (parentStep.onSubPathComplete) {
325
+ const ctx: PathStepContext = {
326
+ pathId: parent.definition.id,
327
+ stepId: parentStep.id,
328
+ data: { ...parent.data }
329
+ };
330
+ this.applyPatch(
331
+ await parentStep.onSubPathComplete(finishedPathId, finishedData, ctx)
332
+ );
333
+ }
334
+
335
+ this.emit({
336
+ type: "resumed",
337
+ resumedPathId: parent.definition.id,
338
+ fromSubPathId: finishedPathId,
339
+ snapshot: this.snapshot()!
340
+ });
341
+ } else {
342
+ this.activePath = null;
343
+ this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
344
+ }
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Private helpers
349
+ // ---------------------------------------------------------------------------
350
+
351
+ private requireActivePath(): ActivePath {
352
+ if (this.activePath === null) {
353
+ throw new Error("No active path.");
354
+ }
355
+ return this.activePath;
356
+ }
357
+
358
+ private assertPathHasSteps(path: PathDefinition): void {
359
+ if (!path.steps || path.steps.length === 0) {
360
+ throw new Error(`Path "${path.id}" must have at least one step.`);
361
+ }
362
+ }
363
+
364
+ private emit(event: PathEvent): void {
365
+ for (const listener of this.listeners) {
366
+ listener(event);
367
+ }
368
+ }
369
+
370
+ private emitStateChanged(): void {
371
+ this.emit({ type: "stateChanged", snapshot: this.snapshot()! });
372
+ }
373
+
374
+ private getCurrentStep(active: ActivePath): PathStep {
375
+ return active.definition.steps[active.currentStepIndex];
376
+ }
377
+
378
+ private applyPatch(patch: Partial<PathData> | void | null | undefined): void {
379
+ if (patch && typeof patch === "object") {
380
+ const active = this.activePath;
381
+ if (active) {
382
+ Object.assign(active.data, patch);
383
+ }
384
+ }
385
+ }
386
+
387
+ private async skipSteps(direction: 1 | -1): Promise<void> {
388
+ const active = this.activePath;
389
+ if (!active) return;
390
+
391
+ while (
392
+ active.currentStepIndex >= 0 &&
393
+ active.currentStepIndex < active.definition.steps.length
394
+ ) {
395
+ const step = active.definition.steps[active.currentStepIndex];
396
+ if (!step.shouldSkip) break;
397
+ const ctx: PathStepContext = {
398
+ pathId: active.definition.id,
399
+ stepId: step.id,
400
+ data: { ...active.data }
401
+ };
402
+ const skip = await step.shouldSkip(ctx);
403
+ if (!skip) break;
404
+ active.currentStepIndex += direction;
405
+ }
406
+ }
407
+
408
+ private async enterCurrentStep(): Promise<Partial<PathData> | void> {
409
+ const active = this.activePath;
410
+ if (!active) return;
411
+ const step = this.getCurrentStep(active);
412
+ if (!step.onEnter) return;
413
+ const ctx: PathStepContext = {
414
+ pathId: active.definition.id,
415
+ stepId: step.id,
416
+ data: { ...active.data }
417
+ };
418
+ return step.onEnter(ctx);
419
+ }
420
+
421
+ private async leaveCurrentStep(
422
+ active: ActivePath,
423
+ step: PathStep
424
+ ): Promise<Partial<PathData> | void> {
425
+ if (!step.onLeave) return;
426
+ const ctx: PathStepContext = {
427
+ pathId: active.definition.id,
428
+ stepId: step.id,
429
+ data: { ...active.data }
430
+ };
431
+ return step.onLeave(ctx);
432
+ }
433
+
434
+ private async canMoveNext(
435
+ active: ActivePath,
436
+ step: PathStep
437
+ ): Promise<boolean> {
438
+ if (!step.canMoveNext) return true;
439
+ const ctx: PathStepContext = {
440
+ pathId: active.definition.id,
441
+ stepId: step.id,
442
+ data: { ...active.data }
443
+ };
444
+ return step.canMoveNext(ctx);
445
+ }
446
+
447
+ private async canMovePrevious(
448
+ active: ActivePath,
449
+ step: PathStep
450
+ ): Promise<boolean> {
451
+ if (!step.canMovePrevious) return true;
452
+ const ctx: PathStepContext = {
453
+ pathId: active.definition.id,
454
+ stepId: step.id,
455
+ data: { ...active.data }
456
+ };
457
+ return step.canMovePrevious(ctx);
458
+ }
459
+
460
+ /**
461
+ * Evaluates a guard function synchronously for inclusion in the snapshot.
462
+ * If the guard is absent, returns `true`.
463
+ * If the guard returns a `Promise`, returns `true` (optimistic default).
464
+ */
465
+ private evaluateGuardSync(
466
+ guard: ((ctx: PathStepContext) => boolean | Promise<boolean>) | undefined,
467
+ active: ActivePath
468
+ ): boolean {
469
+ if (!guard) return true;
470
+ const ctx: PathStepContext = {
471
+ pathId: active.definition.id,
472
+ stepId: this.getCurrentStep(active).id,
473
+ data: { ...active.data }
474
+ };
475
+ const result = guard(ctx);
476
+ if (typeof result === "boolean") return result;
477
+ // Async guard — default to true (optimistic); the engine will enforce the real result on navigation.
478
+ return true;
479
+ }
480
+ }
481
+