@statorjs/stator 1.4.1 → 1.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "1.4.1",
3
+ "version": "1.5.1",
4
4
  "description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source (Vite/tsx-native by design).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -88,7 +88,7 @@
88
88
  "pino-pretty": "^13.1.3",
89
89
  "vite": "^6.0.0",
90
90
  "vitest": "^2.1.0",
91
- "@statorjs/stator": "1.4.1"
91
+ "@statorjs/stator": "1.5.1"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --noEmit",
package/src/build/sync.ts CHANGED
@@ -2,6 +2,7 @@ import type { Dirent } from 'node:fs'
2
2
  import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
3
3
  import { dirname, join, relative, sep } from 'node:path'
4
4
  import { generateDts } from '../compiler/dts.ts'
5
+ import { toVirtualCode } from '../compiler/virtual-code.ts'
5
6
 
6
7
  /**
7
8
  * Type sync: generate a `<name>.stator.d.ts` for each component so editors and
@@ -16,35 +17,61 @@ import { generateDts } from '../compiler/dts.ts'
16
17
  *
17
18
  * Route pages (`routes/*.stator`) are skipped — they export `GET`, not a render
18
19
  * function.
20
+ *
21
+ * Sync ALSO emits each template's virtual TSX (the same code the language
22
+ * server typechecks in-editor) under `.stator/check/`, so plain `tsc --noEmit`
23
+ * covers TEMPLATE INTERNALS in CI — frontmatter/prop mismatches otherwise
24
+ * surface only as runtime ReferenceErrors. Opt-in per app: add
25
+ * ".stator/check" to `rootDirs` and its .tsx files to `include` (see the
26
+ * example tsconfigs); apps that don't are unaffected (the files sit ignored).
19
27
  */
20
28
  export interface SyncResult {
21
29
  /** Number of `.stator.d.ts` files written. */
22
30
  written: number
31
+ /** Number of `.check.tsx` virtual files written. */
32
+ checks: number
23
33
  /** The generated-types directory. */
24
34
  outDir: string
25
35
  }
26
36
 
27
37
  const TYPES_DIR = join('.stator', 'types')
38
+ const CHECK_DIR = join('.stator', 'check')
28
39
 
29
40
  export async function syncTypes(root: string): Promise<SyncResult> {
30
41
  const outDir = join(root, TYPES_DIR)
42
+ const checkDir = join(root, CHECK_DIR)
31
43
  await rm(outDir, { recursive: true, force: true })
44
+ await rm(checkDir, { recursive: true, force: true })
32
45
 
33
46
  const files = await walk(root)
34
47
  let written = 0
48
+ let checks = 0
35
49
  for (const file of files) {
36
- const kind = file.split(sep).includes('routes') ? 'route' : 'component'
37
- const dts = generateDts(await readFile(file, 'utf8'), { kind })
38
- if (dts === null) continue
39
- // Mirror the source path under .stator/types: templates/x.stator →
40
- // .stator/types/templates/x.stator.d.ts.
50
+ const source = await readFile(file, 'utf8')
41
51
  const rel = relative(root, file)
42
- const target = join(outDir, `${rel}.d.ts`)
43
- await mkdir(dirname(target), { recursive: true })
44
- await writeFile(target, dts)
45
- written++
52
+
53
+ const kind = file.split(sep).includes('routes') ? 'route' : 'component'
54
+ const dts = generateDts(source, { kind })
55
+ if (dts !== null) {
56
+ // Mirror the source path under .stator/types: templates/x.stator →
57
+ // .stator/types/templates/x.stator.d.ts.
58
+ const target = join(outDir, `${rel}.d.ts`)
59
+ await mkdir(dirname(target), { recursive: true })
60
+ await writeFile(target, dts)
61
+ written++
62
+ }
63
+
64
+ // Same mirroring for the virtual TSX — the exact code the editor
65
+ // typechecks, HTML→TSX compatibility included (see compiler/virtual-code
66
+ // appendTemplateTsx). Relative imports resolve through the app's
67
+ // `rootDirs`; ambient Stator/JSX scaffolding is self-contained per file.
68
+ const virtual = toVirtualCode(source)
69
+ const checkTarget = join(checkDir, `${rel}.check.tsx`)
70
+ await mkdir(dirname(checkTarget), { recursive: true })
71
+ await writeFile(checkTarget, virtual.tsx.code)
72
+ checks++
46
73
  }
47
- return { written, outDir }
74
+ return { written, checks, outDir }
48
75
  }
49
76
 
50
77
  async function walk(dir: string): Promise<string[]> {
@@ -1,4 +1,5 @@
1
1
  import ts from 'typescript'
2
+ import { analyzeScriptClasses } from './client-script.ts'
2
3
  import { splitStator } from './split.ts'
3
4
 
4
5
  /**
@@ -19,11 +20,36 @@ export function generateDts(
19
20
  ): string | null {
20
21
  if ((opts.kind ?? 'component') === 'route') return null
21
22
 
22
- const { frontmatter, template } = splitStator(source)
23
+ const { frontmatter, template, scripts } = splitStator(source)
23
24
  const { hoisted, propsType } = extractFrontmatterTypes(frontmatter)
24
- const propsT = componentPropsType(propsType, template)
25
+ let propsT = componentPropsType(propsType, template)
26
+ let needsReadResult = false
27
+
28
+ // Client islands declare their surface with `static attrs`, not
29
+ // `Stator.props`. Each attr accepts its coerced kind OR a live `read()`
30
+ // binding — the compiler lowers read() island props to live attr bindings,
31
+ // so the type surface must admit both.
32
+ if (!propsType && scripts.length > 0) {
33
+ const attrs = new Map<string, string>()
34
+ for (const cls of analyzeScriptClasses(scripts.join('\n'))) {
35
+ for (const [key, kind] of cls.staticAttrs) attrs.set(key, kind)
36
+ }
37
+ if (attrs.size > 0) {
38
+ needsReadResult = true
39
+ const fields = [...attrs]
40
+ .map(([key, kind]) => `${key}?: ${kind} | __SReadResult<${kind}>`)
41
+ .join('; ')
42
+ // Declared attrs are typed strictly; the open tail admits the richer
43
+ // props an island's SERVER-rendered markup may consume (they never
44
+ // cross the attr wire, so `static attrs` can't know about them).
45
+ propsT = `{ ${fields} } & { [prop: string]: unknown }`
46
+ }
47
+ }
25
48
 
26
49
  const lines = ["import type { HtmlFragment } from '@statorjs/stator/template'"]
50
+ if (needsReadResult) {
51
+ lines.push("import type { ReadResult as __SReadResult } from '@statorjs/stator/template'")
52
+ }
27
53
  if (hoisted) lines.push(hoisted)
28
54
  lines.push('')
29
55
  lines.push(`declare const _default: (props: ${propsT}) => HtmlFragment`)
@@ -180,13 +180,137 @@ function buildServerTsx(regions: ScannedRegions): VirtualFile {
180
180
  code += `${seg.text}\n`
181
181
  }
182
182
  code += ' return (<>'
183
- push(mappings, tplOffset, code.length, tpl.length)
184
- code += tpl
183
+ code = appendTemplateTsx(code, mappings, tpl, tplOffset)
185
184
  code += '</>);\n}\n'
186
185
 
187
186
  return { lang: 'tsx', code, mappings }
188
187
  }
189
188
 
189
+ /** HTML void elements — legal unclosed in templates, not in TSX. */
190
+ const VOID_TAGS = new Set([
191
+ 'area',
192
+ 'base',
193
+ 'br',
194
+ 'col',
195
+ 'embed',
196
+ 'hr',
197
+ 'img',
198
+ 'input',
199
+ 'link',
200
+ 'meta',
201
+ 'source',
202
+ 'track',
203
+ 'wbr',
204
+ ])
205
+
206
+ /**
207
+ * Append the template to the virtual TSX with HTML→TSX compatibility applied
208
+ * AND offset mappings kept exact around every edit:
209
+ * - HTML comments: dropped (not JSX),
210
+ * - `is:inline` scripts: body blanked to `<script />` (raw JS can't parse
211
+ * as JSX children, and typechecking it as TSX would be meaningless),
212
+ * - void elements: self-closed (`<input>` → `<input />`).
213
+ * The walk is brace- and quote-aware so attribute expressions
214
+ * (`checked={(t) => t.done}`, `title="a > b"`) are never rewritten. Verbatim
215
+ * runs get 1:1 mappings; inserted text maps to nothing; dropped source simply
216
+ * has no generated counterpart. Both the editor (Volar) and the emitted
217
+ * `.stator/check` files share this one surface.
218
+ */
219
+ function appendTemplateTsx(
220
+ code: string,
221
+ mappings: VirtualMapping[],
222
+ tpl: string,
223
+ tplOffset: number,
224
+ ): string {
225
+ let out = code
226
+ let i = 0
227
+ let runStart = 0
228
+
229
+ const flush = (end: number): void => {
230
+ if (end > runStart) {
231
+ push(mappings, tplOffset + runStart, out.length, end - runStart)
232
+ out += tpl.slice(runStart, end)
233
+ }
234
+ }
235
+
236
+ while (i < tpl.length) {
237
+ if (tpl.startsWith('<!--', i)) {
238
+ flush(i)
239
+ const end = tpl.indexOf('-->', i)
240
+ i = end === -1 ? tpl.length : end + 3
241
+ runStart = i
242
+ continue
243
+ }
244
+ if (/^<script\b/i.test(tpl.slice(i, i + 8))) {
245
+ flush(i)
246
+ const end = tpl.toLowerCase().indexOf('</script>', i)
247
+ out += '<script />'
248
+ i = end === -1 ? tpl.length : end + '</script>'.length
249
+ runStart = i
250
+ continue
251
+ }
252
+ if (tpl[i] === '{') {
253
+ i = expressionEnd(tpl, i)
254
+ continue
255
+ }
256
+ if (tpl[i] === '<' && /[a-zA-Z]/.test(tpl[i + 1] ?? '')) {
257
+ const tag = /^[a-zA-Z][\w-]*/.exec(tpl.slice(i + 1))![0]
258
+ let j = i + 1 + tag.length
259
+ while (j < tpl.length) {
260
+ const ch = tpl[j]!
261
+ if (ch === '"' || ch === "'") {
262
+ const q = tpl.indexOf(ch, j + 1)
263
+ j = q === -1 ? tpl.length : q + 1
264
+ continue
265
+ }
266
+ if (ch === '{') {
267
+ j = expressionEnd(tpl, j)
268
+ continue
269
+ }
270
+ if (ch === '>') break
271
+ j++
272
+ }
273
+ if (VOID_TAGS.has(tag.toLowerCase()) && j < tpl.length) {
274
+ // Self-close unless the author already did.
275
+ let k = j - 1
276
+ while (k > i && /\s/.test(tpl[k]!)) k--
277
+ if (tpl[k] !== '/') {
278
+ flush(j)
279
+ out += ' /'
280
+ runStart = j // continue the run at the original `>`
281
+ }
282
+ }
283
+ i = j + 1
284
+ continue
285
+ }
286
+ i++
287
+ }
288
+ flush(tpl.length)
289
+ return out
290
+ }
291
+
292
+ /** Index just past the `}` closing the expression opened at `start` (which
293
+ * must point at `{`), respecting nested braces and string/template literals. */
294
+ function expressionEnd(tpl: string, start: number): number {
295
+ let depth = 0
296
+ let quote: string | null = null
297
+ for (let i = start; i < tpl.length; i++) {
298
+ const ch = tpl[i]!
299
+ if (quote !== null) {
300
+ if (ch === '\\') i++
301
+ else if (ch === quote) quote = null
302
+ continue
303
+ }
304
+ if (ch === '"' || ch === "'" || ch === '`') quote = ch
305
+ else if (ch === '{') depth++
306
+ else if (ch === '}') {
307
+ depth--
308
+ if (depth === 0) return i + 1
309
+ }
310
+ }
311
+ return tpl.length
312
+ }
313
+
190
314
  interface FmSegment {
191
315
  sourceOffset: number
192
316
  text: string
@@ -31,6 +31,12 @@ export interface Actor<C, E extends EventObject> {
31
31
  ): () => void
32
32
  /** Compact snapshot for the Store / client hydration seed. */
33
33
  getPersistedSnapshot(): Snapshot<C>
34
+ /** Host acknowledgment that the entry effect `effectId` SETTLED (completion
35
+ * delivered or null-resolved): clears the snapshot's pendingEntry marker so
36
+ * hydration stops re-invoking it. No-op on mismatch (the state moved on and
37
+ * the marker already belongs to a different invocation). Returns whether a
38
+ * marker was cleared — the host persists when true. */
39
+ settleEntry(effectId: string): boolean
34
40
  }
35
41
 
36
42
  /** The "top type" for an actor — same variance argument as `AnyMachineDef`:
@@ -68,8 +74,19 @@ export interface CreateActorOptions<C> {
68
74
  * config; `ctx` is the current context (for context-dependent delays). */
69
75
  onStateEnter?: (stateKey: string, ctx: C) => void
70
76
  /** Host hook: a state was LEFT (a value-changing transition). The server uses
71
- * it to cancel that state's `after` timers. */
77
+ * it to cancel that state's `after` timers and abort its entry effect. */
72
78
  onStateExit?: (stateKey: string) => void
79
+ /** Host hook: a HYDRATED actor started — the state was restored, not entered.
80
+ * The server uses it to re-arm `after` timers with elapsed credit
81
+ * (`enteredAt`) and, when `pendingEntry` reports an unsettled entry effect,
82
+ * to re-invoke it via `refireEntry` (same effectId — idempotency keys hold
83
+ * across the retry). `pendingEntry` is only reported when the restored state
84
+ * still declares an entry effect. */
85
+ onStateRestore?: (
86
+ stateKey: string,
87
+ ctx: C,
88
+ info: { enteredAt?: number; pendingEntry?: { effectId: string }; refireEntry: () => void },
89
+ ) => void
73
90
  }
74
91
 
75
92
  /** Unique per-invocation effect id — usable as an idempotency key, so it must
@@ -104,9 +121,12 @@ export function createActor<C extends object, E extends EventObject, S extends s
104
121
  ? (structuredClone(opts.snapshot.context) as C)
105
122
  : (structuredClone(def.context) as C)
106
123
  // Hydrated actors have already lived: their current state's entry effect fired
107
- // when it was entered, so `start()` must NOT re-fire it. Fresh actors (no
108
- // snapshot) enter their initial state for the first time.
124
+ // when it was entered, so `start()` must NOT blanket-re-fire it the host
125
+ // decides via `onStateRestore` (re-invoke only while `pendingEntry` reports
126
+ // an unsettled completion). Fresh actors enter their initial state for real.
109
127
  const hydrated = opts.snapshot !== undefined
128
+ let enteredAt: number | undefined = opts.snapshot?.enteredAt
129
+ let pendingEntry: { effectId: string } | undefined = opts.snapshot?.pendingEntry
110
130
 
111
131
  const subscribers = new Set<(s: Snapshot<C>) => void>()
112
132
  const emitListeners = new Map<string, Set<(e: EmittedEvent) => void>>()
@@ -116,7 +136,12 @@ export function createActor<C extends object, E extends EventObject, S extends s
116
136
 
117
137
  let commits = 0
118
138
 
119
- const snapshot = (): Snapshot<C> => ({ value: [...value], context })
139
+ const snapshot = (): Snapshot<C> => ({
140
+ value: [...value],
141
+ context,
142
+ ...(enteredAt !== undefined ? { enteredAt } : {}),
143
+ ...(pendingEntry !== undefined ? { pendingEntry } : {}),
144
+ })
120
145
 
121
146
  const notify = (): void => {
122
147
  const snap = snapshot()
@@ -128,15 +153,20 @@ export function createActor<C extends object, E extends EventObject, S extends s
128
153
  // event. Firing does NOT bump `commits` (an entry is not a committed
129
154
  // transition); the server persists an entry-firing machine via the host's
130
155
  // pending-effects queue instead (see server/session-runtime.ts).
131
- const fireEntryEffect = (stateKey: string): void => {
156
+ const fireEntryEffect = (stateKey: string, reuseEffectId?: string): void => {
132
157
  const entry = def.states[stateKey]?.entry
133
158
  if (!entry) return
134
- const effectId = newEffectId()
159
+ // A re-invocation keeps its logical id, so idempotency keys threaded to
160
+ // external calls hold across the retry.
161
+ const effectId = reuseEffectId ?? newEffectId()
162
+ pendingEntry = { effectId }
135
163
  const ctxSnapshot = structuredClone(context)
136
164
  const invocation: EffectInvocation = {
137
165
  machineName: def.name,
138
166
  effectId,
139
- run: () => Promise.resolve(entry(ctxSnapshot, { effectId })),
167
+ kind: 'entry',
168
+ stateKey,
169
+ run: (signal) => Promise.resolve(entry(ctxSnapshot, { effectId, signal })),
140
170
  }
141
171
  if (opts.onEffect) {
142
172
  opts.onEffect(invocation)
@@ -144,6 +174,7 @@ export function createActor<C extends object, E extends EventObject, S extends s
144
174
  void invocation
145
175
  .run()
146
176
  .then((completion) => {
177
+ actor.settleEntry(effectId)
147
178
  if (completion) actor.send(completion as E)
148
179
  })
149
180
  .catch((err) => {
@@ -164,11 +195,23 @@ export function createActor<C extends object, E extends EventObject, S extends s
164
195
  if (!started) {
165
196
  started = true
166
197
  notify() // let subscribe-before-start consumers sync initial state
167
- // Fresh initial-state entry (a hydrated actor already fired its).
198
+ const current = value[value.length - 1]!
168
199
  if (!hydrated) {
169
- const initial = value[value.length - 1]!
170
- fireEntryEffect(initial)
171
- opts.onStateEnter?.(initial, context)
200
+ // Fresh initial-state entry.
201
+ enteredAt = Date.now()
202
+ fireEntryEffect(current)
203
+ opts.onStateEnter?.(current, context)
204
+ } else if (opts.onStateRestore) {
205
+ // Restored, not entered: hand the host what it needs to re-arm
206
+ // timers (elapsed credit) and recover an unsettled entry effect.
207
+ const pending = pendingEntry && def.states[current]?.entry ? pendingEntry : undefined
208
+ opts.onStateRestore(current, context, {
209
+ enteredAt,
210
+ pendingEntry: pending,
211
+ refireEntry: () => {
212
+ if (pending) fireEntryEffect(current, pending.effectId)
213
+ },
214
+ })
172
215
  }
173
216
  }
174
217
  return actor
@@ -258,7 +301,9 @@ export function createActor<C extends object, E extends EventObject, S extends s
258
301
  const invocation: EffectInvocation = {
259
302
  machineName: def.name,
260
303
  effectId,
261
- run: () => Promise.resolve(effect(ctxSnapshot, evSnapshot as never, { effectId })),
304
+ kind: 'transition',
305
+ run: (signal) =>
306
+ Promise.resolve(effect(ctxSnapshot, evSnapshot as never, { effectId, signal })),
262
307
  }
263
308
  if (opts.onEffect) {
264
309
  opts.onEffect(invocation)
@@ -283,6 +328,8 @@ export function createActor<C extends object, E extends EventObject, S extends s
283
328
  // timers (self-transitions and action-only transitions do neither).
284
329
  if (config.to && config.to !== stateKey) {
285
330
  opts.onStateExit?.(stateKey)
331
+ enteredAt = Date.now()
332
+ pendingEntry = undefined // the old state's marker dies with it
286
333
  fireEntryEffect(config.to)
287
334
  opts.onStateEnter?.(config.to, context)
288
335
  }
@@ -294,6 +341,12 @@ export function createActor<C extends object, E extends EventObject, S extends s
294
341
  getPersistedSnapshot: snapshot,
295
342
  getCommitCount: () => commits,
296
343
 
344
+ settleEntry(effectId: string) {
345
+ if (pendingEntry?.effectId !== effectId) return false
346
+ pendingEntry = undefined
347
+ return true
348
+ },
349
+
297
350
  subscribe(listener) {
298
351
  subscribers.add(listener)
299
352
  return {
@@ -53,10 +53,14 @@ export type Guard<C, E extends EventObject, R = Record<string, any>> = (
53
53
  ) => boolean
54
54
 
55
55
  /** Passed to an effect alongside the snapshots. `effectId` is unique per
56
- * invocation — thread it to external calls as an idempotency key (1.x
57
- * durability implies at-least-once) and use it for log correlation. */
56
+ * LOGICAL invocation — a re-invoked entry effect keeps its id, so threading it
57
+ * to external calls as an idempotency key holds across retries. `signal`
58
+ * aborts when the owning state is exited (entry effects only — command-role
59
+ * transition effects are never aborted); wire it into fetch/etc. to stop
60
+ * wasted work at the source. */
58
61
  export interface EffectMeta {
59
62
  effectId: string
63
+ signal?: AbortSignal
60
64
  }
61
65
 
62
66
  /**
@@ -86,10 +90,14 @@ export type Effect<C, E extends EventObject, EAll extends EventObject = EventObj
86
90
  /**
87
91
  * A state ENTRY effect: async I/O the host schedules when a state is *entered* —
88
92
  * a fresh start at the initial state, or a transition that changes the state
89
- * value; never on hydration. Same host-scheduled, off-lock, at-most-once
90
- * pipeline as a transition `Effect`, minus the event argument (a state entry has
91
- * no triggering event). Returns the completion event to dispatch (annotate the
92
- * return with your machine's event union, exactly as for `Effect`), or null.
93
+ * value. Entry effects are the LOAD role: how a state gets its data. The host
94
+ * may RE-INVOKE one on hydration when its completion never settled (a process
95
+ * died mid-flight), and aborts its `meta.signal` when the state is exited so
96
+ * write them as re-runnable reads and put non-idempotent external writes in
97
+ * transition effects (the command role) instead. Same host-scheduled, off-lock
98
+ * pipeline as a transition `Effect`, minus the event argument (a state entry
99
+ * has no triggering event). Returns the completion event to dispatch (annotate
100
+ * the return with your machine's event union, exactly as for `Effect`), or null.
93
101
  */
94
102
  export type EntryEffect<C, EAll extends EventObject = EventObject> = (
95
103
  ctx: C,
@@ -109,11 +117,17 @@ export interface AfterEntry<C, EAll extends EventObject = EventObject> {
109
117
  }
110
118
 
111
119
  /** A scheduled effect surfaced to the host: everything needed to run it and
112
- * dispatch its completion. `run` closes over the commit-time snapshots. */
120
+ * dispatch its completion. `run` closes over the commit-time snapshots; the
121
+ * host may pass an AbortSignal, forwarded to the effect's meta. `kind`
122
+ * carries the role split — entry = re-runnable load (abortable on state
123
+ * exit), transition = at-most-once command (never aborted, never re-run). */
113
124
  export interface EffectInvocation {
114
125
  machineName: string
115
126
  effectId: string
116
- run: () => Promise<EventObject | null>
127
+ kind: 'entry' | 'transition'
128
+ /** The owning state — set for entry effects, for exit-abort targeting. */
129
+ stateKey?: string
130
+ run: (signal?: AbortSignal) => Promise<EventObject | null>
117
131
  }
118
132
 
119
133
  /** Object form of a transition. A bare `Action` is sugar for `{ do: fn }`.
@@ -193,6 +207,15 @@ export interface Snapshot<C> {
193
207
  /** State path. Depth-1 today (`['idle']`); extensible to hierarchy. */
194
208
  value: string[]
195
209
  context: C
210
+ /** Epoch ms when the current state was entered. Lets a hydrating host re-arm
211
+ * `after` timers with elapsed credit (deadline = enteredAt + delay). Absent
212
+ * in pre-existing snapshots — hosts treat unknown as "restart full delay". */
213
+ enteredAt?: number
214
+ /** Present while the current state's entry effect has fired but its
215
+ * completion has not settled. A hydrating host uses it to re-invoke the
216
+ * effect (the load role is re-runnable by contract) instead of leaving the
217
+ * machine wedged waiting for a completion that died with another process. */
218
+ pendingEntry?: { effectId: string }
196
219
  }
197
220
 
198
221
  export type Lifecycle = 'app' | 'session'
@@ -0,0 +1,112 @@
1
+ import type { TransitionConfig } from '../engine/index.ts'
2
+ import type { AnyMachineDef } from './define-machine.ts'
3
+
4
+ /**
5
+ * Dev-plane lints over the machine graph. Pure analysis — the dev server logs
6
+ * the findings; production builds never run this.
7
+ */
8
+
9
+ export interface PollLoopFinding {
10
+ machine: string
11
+ state: string
12
+ event: string
13
+ /** One representative cycle, e.g. ['ready', 'revalidating', 'ready']. */
14
+ cycle: string[]
15
+ }
16
+
17
+ /**
18
+ * Detect self-rescheduling poll loops on SESSION machines: a state's `after`
19
+ * timer sends an event whose transitions leave the state and eventually return
20
+ * to it, with an entry effect somewhere on the loop. That shape re-arms its
21
+ * own timer on every lap — server-side polling that runs for sessions nobody
22
+ * is watching (and, before the TTL guard, kept them alive doing it).
23
+ *
24
+ * Deliberately NOT flagged, by construction:
25
+ * - after-rescue (the timer fires once into an error/terminal state — no
26
+ * path back, no cycle),
27
+ * - action-only or same-state `after` handlers (no state change means no
28
+ * re-entry, so the timer never re-arms — not a loop),
29
+ * - app machines (process housekeeping is the legitimate home for
30
+ * non-durable server clocks).
31
+ *
32
+ * The steer for a real finding: put the clock on the client and the staleness
33
+ * policy in a guard — see the effects guide, "Who owns the clock".
34
+ */
35
+ export function findPollLoops(defs: readonly AnyMachineDef[]): PollLoopFinding[] {
36
+ const findings: PollLoopFinding[] = []
37
+
38
+ for (const def of defs) {
39
+ if (def.lifecycle !== 'session') continue
40
+
41
+ // State graph over value-changing transitions only (engine semantics: a
42
+ // missing or same-state `to` never re-enters, so it can't re-arm).
43
+ const edges = new Map<string, Set<string>>()
44
+ for (const [stateKey, node] of Object.entries(def.states)) {
45
+ const targets = new Set<string>()
46
+ for (const raw of Object.values(node.on ?? {})) {
47
+ const candidates = Array.isArray(raw) ? raw : [raw]
48
+ for (const c of candidates) {
49
+ const to =
50
+ typeof c === 'function' ? undefined : (c as TransitionConfig<object, never, string>).to
51
+ if (to && to !== stateKey) targets.add(to)
52
+ }
53
+ }
54
+ edges.set(stateKey, targets)
55
+ }
56
+
57
+ const hasEntry = (s: string): boolean => def.states[s]?.entry !== undefined
58
+
59
+ for (const [stateKey, node] of Object.entries(def.states)) {
60
+ for (const after of node.after ?? []) {
61
+ const event = (after.send as { type: string }).type
62
+ const raw = node.on?.[event]
63
+ if (!raw) continue
64
+ const candidates = Array.isArray(raw) ? raw : [raw]
65
+ for (const c of candidates) {
66
+ const to =
67
+ typeof c === 'function' ? undefined : (c as TransitionConfig<object, never, string>).to
68
+ if (!to || to === stateKey) continue
69
+
70
+ // BFS from the after-target: can we get back to stateKey?
71
+ const parent = new Map<string, string>()
72
+ const queue = [to]
73
+ const seen = new Set([to])
74
+ let found = false
75
+ while (queue.length > 0 && !found) {
76
+ const cur = queue.shift()!
77
+ for (const next of edges.get(cur) ?? []) {
78
+ if (next === stateKey) {
79
+ parent.set(next, cur)
80
+ found = true
81
+ break
82
+ }
83
+ if (!seen.has(next)) {
84
+ seen.add(next)
85
+ parent.set(next, cur)
86
+ queue.push(next)
87
+ }
88
+ }
89
+ }
90
+ if (!found) continue
91
+
92
+ // Reconstruct one lap and require an entry effect somewhere on it —
93
+ // a data-load somewhere in the loop is what makes it a POLL.
94
+ const path: string[] = []
95
+ let cursor: string | undefined = stateKey
96
+ while (cursor !== undefined) {
97
+ path.push(cursor)
98
+ if (cursor === to) break
99
+ cursor = parent.get(cursor)
100
+ }
101
+ path.reverse() // [to, ..., stateKey]
102
+ const lap = [stateKey, ...path]
103
+ if (!lap.some(hasEntry)) continue
104
+
105
+ findings.push({ machine: def.name, state: stateKey, event, cycle: lap })
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ return findings
112
+ }
package/src/server/dev.ts CHANGED
@@ -139,6 +139,17 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
139
139
  // initial-state entry effect fires during boot and needs a live scheduler.
140
140
  runtime.wireAppEffects(store)
141
141
  await store.bootAppMachines()
142
+ // Dev-only lint: a session machine whose `after` timer drives a loop with
143
+ // a data-loading entry effect is server-side polling that runs for
144
+ // sessions nobody is watching. Warn with the steer, don't block.
145
+ for (const f of runtime.findPollLoops(defs)) {
146
+ console.warn(
147
+ `stator: session machine "${f.machine}" self-reschedules through \`after\` ` +
148
+ `(${f.cycle.join(' → ')} via ${f.event}) with a data-loading entry effect on the loop. ` +
149
+ `This polls upstream for sessions nobody is watching. Prefer a client-owned clock ` +
150
+ `with a staleness guard — see the effects guide, "Who owns the clock".`,
151
+ )
152
+ }
142
153
  }
143
154
  const rebuildRoutes = async (): Promise<void> => {
144
155
  routes = await runtime.discoverRoutes(routesDir, loader)
@@ -8,6 +8,46 @@ import { fanOut } from './sse.ts'
8
8
 
9
9
  const effectLog = scopedLogger('effect')
10
10
 
11
+ /**
12
+ * Process-wide registry of IN-FLIGHT effect invocations. Two consumers:
13
+ * - hydration re-invoke dampening: a restore hook only re-fires a pending
14
+ * entry effect when its id is NOT already running in this process (normal
15
+ * request traffic hydrates constantly; only a post-crash hydration finds a
16
+ * pending marker with no live invocation),
17
+ * - exit-abort: leaving a state aborts its entry effect's signal, stopping
18
+ * wasted upstream work at the source (command-role transition effects are
19
+ * never aborted).
20
+ * In-memory and non-durable by design — it dies with the process, exactly like
21
+ * the work it tracks.
22
+ */
23
+ interface InFlightEffect {
24
+ controller: AbortController
25
+ scope: string
26
+ machineName: string
27
+ kind: 'entry' | 'transition'
28
+ stateKey?: string
29
+ }
30
+ const inFlight = new Map<string, InFlightEffect>()
31
+
32
+ export function isEffectInFlight(effectId: string): boolean {
33
+ return inFlight.has(effectId)
34
+ }
35
+
36
+ /** Abort the entry effect(s) owned by (scope, machine, state) — called from the
37
+ * host's state-exit hook alongside timer cancellation. */
38
+ export function abortEntryEffects(scope: string, machineName: string, stateKey: string): void {
39
+ for (const fx of inFlight.values()) {
40
+ if (
41
+ fx.kind === 'entry' &&
42
+ fx.scope === scope &&
43
+ fx.machineName === machineName &&
44
+ fx.stateKey === stateKey
45
+ ) {
46
+ fx.controller.abort()
47
+ }
48
+ }
49
+ }
50
+
11
51
  /**
12
52
  * Install the APP-plane effect scheduler on a MachineStore (injected
13
53
  * post-construction — see MachineStore.setAppEffectScheduler). App
@@ -26,26 +66,48 @@ export function wireAppEffects(store: MachineStore): void {
26
66
 
27
67
  async function runAppEffect(invocation: EffectInvocation, store: MachineStore): Promise<void> {
28
68
  const { machineName, effectId } = invocation
29
- let completion: Awaited<ReturnType<EffectInvocation['run']>>
30
- try {
31
- completion = await invocation.run()
32
- } catch (err) {
33
- effectLog.error(
34
- { machine: machineName, effectId, err: String(err) },
35
- 'effect threw — effects must catch and return their failure event; dropped',
36
- )
37
- return
38
- }
39
- if (!completion) return
69
+ const controller = new AbortController()
70
+ inFlight.set(effectId, {
71
+ controller,
72
+ scope: '@app',
73
+ machineName,
74
+ kind: invocation.kind,
75
+ stateKey: invocation.stateKey,
76
+ })
77
+ // The registry entry lives until the LOGICAL invocation settles — including
78
+ // completion delivery — so a hydration racing the completion sees it in
79
+ // flight and does not re-invoke.
40
80
  try {
41
- const def = store.getDef(machineName)
42
- if (!def) return // graph changed under us (dev reload) — drop
43
- await dispatchToApp(store, def as AnyMachineDef, completion as never)
44
- } catch (err) {
45
- effectLog.error(
46
- { machine: machineName, effectId, err: String(err) },
47
- 'effect completion dispatch failed',
48
- )
81
+ let completion: Awaited<ReturnType<EffectInvocation['run']>>
82
+ try {
83
+ completion = await invocation.run(controller.signal)
84
+ } catch (err) {
85
+ effectLog.error(
86
+ { machine: machineName, effectId, err: String(err) },
87
+ 'effect threw effects must catch and return their failure event; dropped',
88
+ )
89
+ return
90
+ }
91
+ // Settle the resident app actor's pendingEntry marker directly (persisted
92
+ // when the machine opted in), then dispatch any completion.
93
+ if (invocation.kind === 'entry') {
94
+ if (store.appInstance(machineName)?.actor.settleEntry(effectId)) {
95
+ await store.persistAppMachine(machineName)
96
+ }
97
+ }
98
+ if (!completion) return
99
+ try {
100
+ const def = store.getDef(machineName)
101
+ if (!def) return // graph changed under us (dev reload) — drop
102
+ await dispatchToApp(store, def as AnyMachineDef, completion as never)
103
+ } catch (err) {
104
+ effectLog.error(
105
+ { machine: machineName, effectId, err: String(err) },
106
+ 'effect completion dispatch failed',
107
+ )
108
+ }
109
+ } finally {
110
+ inFlight.delete(effectId)
49
111
  }
50
112
  }
51
113
 
@@ -80,30 +142,86 @@ async function runSessionEffect(
80
142
  sessionId: string,
81
143
  ): Promise<void> {
82
144
  const { machineName, effectId } = invocation
83
- let completion: Awaited<ReturnType<EffectInvocation['run']>>
145
+ const controller = new AbortController()
146
+ inFlight.set(effectId, {
147
+ controller,
148
+ scope: sessionId,
149
+ machineName,
150
+ kind: invocation.kind,
151
+ stateKey: invocation.stateKey,
152
+ })
153
+ // The registry entry lives until the LOGICAL invocation settles — including
154
+ // completion delivery — so a hydration racing the completion (e.g. the
155
+ // completion's own re-entry) sees it in flight and does not re-invoke.
84
156
  try {
85
- completion = await invocation.run()
86
- } catch (err) {
87
- // Backstop only — the type contract asks effects to catch and return
88
- // their failure event. Never crashes the host.
89
- effectLog.error(
90
- { machine: machineName, effectId, err: String(err) },
91
- 'effect threw — effects must catch and return their failure event; dropped',
92
- )
93
- return
94
- }
95
- if (!completion) return
157
+ let completion: Awaited<ReturnType<EffectInvocation['run']>>
158
+ try {
159
+ completion = await invocation.run(controller.signal)
160
+ } catch (err) {
161
+ // Backstop only — the type contract asks effects to catch and return
162
+ // their failure event. Never crashes the host.
163
+ effectLog.error(
164
+ { machine: machineName, effectId, err: String(err) },
165
+ 'effect threw — effects must catch and return their failure event; dropped',
166
+ )
167
+ return
168
+ }
169
+ // An entry effect that settled with no completion still clears its marker —
170
+ // otherwise every later hydration would re-invoke a fire-and-forget load.
171
+ const settles = invocation.kind === 'entry' ? effectId : undefined
172
+ if (!completion) {
173
+ if (settles) {
174
+ try {
175
+ await settleSessionEntry(store, sessionId, machineName, settles)
176
+ } catch (err) {
177
+ effectLog.error(
178
+ { machine: machineName, effectId, err: String(err) },
179
+ 'entry-effect settle failed',
180
+ )
181
+ }
182
+ }
183
+ return
184
+ }
96
185
 
97
- try {
98
- await reenterSessionEvent(store, sessionId, machineName, completion)
99
- } catch (err) {
100
- effectLog.error(
101
- { machine: machineName, effectId, err: String(err) },
102
- 'effect completion dispatch failed',
103
- )
186
+ try {
187
+ await reenterSessionEvent(store, sessionId, machineName, completion, { settles })
188
+ } catch (err) {
189
+ effectLog.error(
190
+ { machine: machineName, effectId, err: String(err) },
191
+ 'effect completion dispatch failed',
192
+ )
193
+ }
194
+ } finally {
195
+ inFlight.delete(effectId)
104
196
  }
105
197
  }
106
198
 
199
+ /** Clear a session machine's pendingEntry marker with no event to deliver —
200
+ * the null-completion settle path. Lock + hydrate + settle + persist (no TTL
201
+ * refresh: this is machine activity, not user activity). */
202
+ async function settleSessionEntry(
203
+ store: MachineStore,
204
+ sessionId: string,
205
+ machineName: string,
206
+ effectId: string,
207
+ ): Promise<void> {
208
+ await withSessionLock(sessionId, async () => {
209
+ const def = store.getDef(machineName)
210
+ if (!def) return
211
+ if ((await store.persistence.get(sessionId, machineName)) === null) return
212
+ const runtime = new SessionRuntime(sessionId, store)
213
+ try {
214
+ await runtime.loadGraph([def])
215
+ const handle = runtime.handleFor(machineName)
216
+ if (handle?.actor.settleEntry(effectId)) {
217
+ await runtime.persistTouched(new Set([machineName]), { refreshTtl: false })
218
+ }
219
+ } finally {
220
+ runtime.dispose()
221
+ }
222
+ })
223
+ }
224
+
107
225
  /**
108
226
  * Re-enter an out-of-band event (an effect completion, or an `after` timeout)
109
227
  * through the full session event path: fresh lock, hydrate the machine (the
@@ -117,18 +235,39 @@ export async function reenterSessionEvent(
117
235
  sessionId: string,
118
236
  machineName: string,
119
237
  event: EventObject,
238
+ opts?: { settles?: string },
120
239
  ): Promise<void> {
121
240
  await withSessionLock(sessionId, async () => {
241
+ const def = store.getDef(machineName)
242
+ if (!def) return // machine graph changed under us (dev reload) — drop
243
+ // Resurrection guard: a timer or completion outliving its session must not
244
+ // birth a fresh machine (whose initial entry effect would fire and persist
245
+ // a zombie). No snapshot ⇒ the session is gone ⇒ drop.
246
+ if ((await store.persistence.get(sessionId, machineName)) === null) {
247
+ effectLog.debug(
248
+ { sid: sessionId, machine: machineName, event: event.type },
249
+ 'out-of-band event for expired session dropped',
250
+ )
251
+ return
252
+ }
122
253
  const runtime = new SessionRuntime(sessionId, store)
123
254
  try {
124
- const def = store.getDef(machineName)
125
- if (!def) return // machine graph changed under us (dev reload) — drop
126
255
  // loadGraph pulls reads + subscribers transitively, so emits reach
127
256
  // cross-machine listeners like any other event.
128
257
  await runtime.loadGraph([def])
129
258
  runtime.wireSubscriptions()
130
259
  const touched = runtime.processEvent(machineName, event)
131
- await runtime.persistTouched(new Set([...touched, ...runtime.entryFiredMachines()]))
260
+ // Settle the entry-effect marker this completion belongs to (no-op if
261
+ // the state moved on and the marker died with it).
262
+ const settled =
263
+ opts?.settles !== undefined &&
264
+ (runtime.handleFor(machineName)?.actor.settleEntry(opts.settles) ?? false)
265
+ const toPersist = new Set([...touched, ...runtime.entryFiredMachines()])
266
+ if (settled) toPersist.add(machineName)
267
+ // Machine activity must not extend the session's life — only real user
268
+ // requests refresh the TTL. Without this, a self-rescheduling machine
269
+ // keeps its own session immortal.
270
+ await runtime.persistTouched(toPersist, { refreshTtl: false })
132
271
  await fanOut(touched, { sessionId })
133
272
  // A transition or entry effect chained off this event surfaces here.
134
273
  scheduleSessionEffects(runtime, store, sessionId)
@@ -22,6 +22,8 @@ export type {
22
22
  SubscribeEvent,
23
23
  } from './define-machine.ts'
24
24
  export { defineMachine, isStatorMachine } from './define-machine.ts'
25
+ export type { PollLoopFinding } from './dev-lint.ts'
26
+ export { findPollLoops } from './dev-lint.ts'
25
27
  export type { DiscoveryResult } from './discovery.ts'
26
28
  export { discoverMachines } from './discovery.ts'
27
29
  export type { DispatchContext } from './dispatch-context.ts'
@@ -2,6 +2,7 @@ import { createActor, type EffectInvocation, type Snapshot } from '../engine/ind
2
2
  import { type AppStore, InMemoryAppStore } from './app-store.ts'
3
3
  import type { AnyMachineDef, SubscribeEvent } from './define-machine.ts'
4
4
  import { recordTouch } from './dispatch-context.ts'
5
+ import { abortEntryEffects, isEffectInFlight } from './effects.ts'
5
6
  import { createInstanceProxy, type InstanceHandle } from './instance-proxy.ts'
6
7
  import { scopedLogger } from './logger.ts'
7
8
  import { serverReadsResolver } from './reads-helpers.ts'
@@ -199,8 +200,11 @@ export class MachineStore {
199
200
  `This is almost always a circular import between machine modules — the module ` +
200
201
  `defining "${def.name}" and the module defining its subscription source import ` +
201
202
  `each other, and the loader resolved the mid-cycle binding to undefined. ` +
202
- `Break the module cycle (e.g. move one machine or the subscription), ` +
203
- `or define the mutually-subscribing machines in one module.`,
203
+ `Break the module cycle: move the DISPLAY selectors into a third machine ` +
204
+ `that \`reads:\` both sides (the read/write split see the weather example's ` +
205
+ `PanelsMachine), or move one machine or the subscription. Note that defining ` +
206
+ `both machines in one module does NOT work with directory discovery, which ` +
207
+ `registers default exports only.`,
204
208
  )
205
209
  }
206
210
  if (!this.defs.has(sub.from.name)) {
@@ -265,7 +269,24 @@ export class MachineStore {
265
269
  // through dispatchToApp — no session, wall-clock only (revalidation,
266
270
  // circuit breakers). See armAfterTimers.
267
271
  onStateEnter: (stateKey, ctx) => armAfterTimers(this, APP_SCOPE, def, stateKey, ctx),
268
- onStateExit: (stateKey) => cancelAfterTimers(APP_SCOPE, def.name, stateKey),
272
+ onStateExit: (stateKey) => {
273
+ cancelAfterTimers(APP_SCOPE, def.name, stateKey)
274
+ abortEntryEffects(APP_SCOPE, def.name, stateKey)
275
+ },
276
+ // A persisted app machine restoring at boot gets the same work-
277
+ // lifetime semantics as a hydrating session machine: timers re-arm
278
+ // with elapsed credit, and an unsettled entry effect re-invokes
279
+ // (fresh process ⇒ nothing can be in flight, but the check keeps the
280
+ // contract uniform).
281
+ onStateRestore: (stateKey, ctx, info) => {
282
+ armAfterTimers(this, APP_SCOPE, def, stateKey, ctx, {
283
+ restore: true,
284
+ enteredAt: info.enteredAt,
285
+ })
286
+ if (info.pendingEntry && !isEffectInFlight(info.pendingEntry.effectId)) {
287
+ info.refireEntry()
288
+ }
289
+ },
269
290
  }).start()
270
291
  this.appInstances.set(
271
292
  def.name,
@@ -1,6 +1,7 @@
1
1
  import { createActor, type EffectInvocation, type Snapshot } from '../engine/index.ts'
2
2
  import type { AnyMachineDef } from './define-machine.ts'
3
3
  import { type DispatchContext, recordTouch, withDispatchContext } from './dispatch-context.ts'
4
+ import { abortEntryEffects, isEffectInFlight } from './effects.ts'
4
5
  import { createInstanceProxy, type InstanceHandle } from './instance-proxy.ts'
5
6
  import { buildDispatchEvent, MAX_CASCADE_DEPTH, type MachineStore } from './machine-store.ts'
6
7
  import { serverReadsResolver } from './reads-helpers.ts'
@@ -72,7 +73,26 @@ export class SessionRuntime {
72
73
  // process-wide registry (server/timers.ts), not this transient runtime.
73
74
  onStateEnter: (stateKey, ctx) =>
74
75
  armAfterTimers(this.store, this.sessionId, def, stateKey, ctx),
75
- onStateExit: (stateKey) => cancelAfterTimers(this.sessionId, def.name, stateKey),
76
+ // Exiting a state also aborts its in-flight entry effect (load role)
77
+ // the completion would be guard-dropped anyway; this stops the work.
78
+ onStateExit: (stateKey) => {
79
+ cancelAfterTimers(this.sessionId, def.name, stateKey)
80
+ abortEntryEffects(this.sessionId, def.name, stateKey)
81
+ },
82
+ // Restored (hydrated) state: re-arm timers with elapsed credit — the
83
+ // deadline is enteredAt + delay, so per-request re-arms are idempotent
84
+ // and a deadline that passed while no process was alive fires promptly.
85
+ // A pendingEntry marker with no live invocation in this process is a
86
+ // crashed load: re-invoke it (same effectId; load role is re-runnable).
87
+ onStateRestore: (stateKey, ctx, info) => {
88
+ armAfterTimers(this.store, this.sessionId, def, stateKey, ctx, {
89
+ restore: true,
90
+ enteredAt: info.enteredAt,
91
+ })
92
+ if (info.pendingEntry && !isEffectInFlight(info.pendingEntry.effectId)) {
93
+ info.refireEntry()
94
+ }
95
+ },
76
96
  }).start()
77
97
  this.actors.set(
78
98
  def.name,
@@ -225,8 +245,15 @@ export class SessionRuntime {
225
245
 
226
246
  /** Write the current persisted snapshot for each touched machine back to
227
247
  * its store: session machines to the session Store, app machines (touched
228
- * via session→app subscriptions) to the AppStore when they opted in. */
229
- async persistTouched(touched: ReadonlySet<string>): Promise<void> {
248
+ * via session→app subscriptions) to the AppStore when they opted in.
249
+ * `refreshTtl: false` (machine-driven re-entries: timers, effect
250
+ * completions) persists without extending the session's expiry — only real
251
+ * user requests count as user activity. */
252
+ async persistTouched(
253
+ touched: ReadonlySet<string>,
254
+ opts?: { refreshTtl?: boolean },
255
+ ): Promise<void> {
256
+ const refreshTtl = opts?.refreshTtl !== false
230
257
  const ttlSeconds = this.store.sessionTtlSeconds
231
258
  for (const name of touched) {
232
259
  const handle = this.actors.get(name)
@@ -237,11 +264,15 @@ export class SessionRuntime {
237
264
  continue
238
265
  }
239
266
  const snapshot = handle.actor.getPersistedSnapshot()
240
- // Every set refreshes the session's whole expiry — the user is
241
- // active, so all of their machines stay alive together.
242
- await this.store.persistence.set(this.sessionId, name, snapshot, {
243
- ttlSeconds,
244
- })
267
+ // A ttlSeconds-bearing set refreshes the session's whole expiry — the
268
+ // user is active, so all of their machines stay alive together. TTL-less
269
+ // sets (machine-driven work) leave the expiry untouched.
270
+ await this.store.persistence.set(
271
+ this.sessionId,
272
+ name,
273
+ snapshot,
274
+ refreshTtl ? { ttlSeconds } : undefined,
275
+ )
245
276
  }
246
277
  }
247
278
 
@@ -33,9 +33,17 @@ export function cancelAfterTimers(scope: string, machineName: string, stateKey:
33
33
  timers.delete(k)
34
34
  }
35
35
 
36
+ /** Floor for a re-armed timer whose deadline already passed while no process
37
+ * was watching — fires promptly, but after the arming request's lock work. */
38
+ const OVERDUE_FIRE_MS = 40
39
+
36
40
  /**
37
41
  * Arm the `after` timers declared on `stateKey` (a no-op if it declares none).
38
- * The host calls this when a machine ENTERS the state. Each timer, on elapse,
42
+ * The host calls this when a machine ENTERS the state or, with
43
+ * `opts.enteredAt`, when a hydrating runtime RESTORES it: the delay is then
44
+ * credited with time already served (deadline = enteredAt + delay, so re-arms
45
+ * across requests are deadline-idempotent, and a deadline that passed while no
46
+ * process was alive fires promptly instead of never). Each timer, on elapse,
39
47
  * dispatches its `send` event back through the machine's normal event path —
40
48
  * a session machine through the session re-entry (fresh lock + hydrate), an app
41
49
  * machine through `dispatchToApp` (no lock; the long-lived instance is sent
@@ -49,13 +57,25 @@ export function armAfterTimers(
49
57
  def: AnyMachineDef,
50
58
  stateKey: string,
51
59
  ctx: object,
60
+ opts?: { restore: true; enteredAt?: number },
52
61
  ): void {
53
62
  const after = def.states[stateKey]?.after
54
63
  if (!after || after.length === 0) return
55
- cancelAfterTimers(scope, def.name, stateKey)
56
64
  const k = key(scope, def.name, stateKey)
65
+ // Restore mode: an arm-set already ticking in this process is authoritative
66
+ // (same deadline by construction) — re-arming here would RESET an overdue
67
+ // timer's short fuse on every hydration, and a hot session could starve it
68
+ // forever. Restore only fills the void after a restart or lost timer. This
69
+ // holds even for pre-upgrade snapshots with no enteredAt (unknown elapsed
70
+ // time restarts the full delay — once, not per request).
71
+ if (opts?.restore && timers.has(k)) return
72
+ cancelAfterTimers(scope, def.name, stateKey)
57
73
  const handles = after.map((entry) => {
58
- const delay = typeof entry.delay === 'function' ? entry.delay(ctx as never) : entry.delay
74
+ const declared = typeof entry.delay === 'function' ? entry.delay(ctx as never) : entry.delay
75
+ const delay =
76
+ opts?.restore && opts.enteredAt !== undefined
77
+ ? Math.max(declared - (Date.now() - opts.enteredAt), OVERDUE_FIRE_MS)
78
+ : declared
59
79
  const h = setTimeout(() => {
60
80
  timers.delete(k) // a state has one arm-set; drop the whole key on fire
61
81
  const fire =