@statorjs/stator 2.9.1 → 2.10.0-next.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.
@@ -18,6 +18,7 @@ import { discoverMiddleware } from './middleware.ts'
18
18
  import { discoverRoutes } from './route-discovery.ts'
19
19
  import { setSessionSameSite } from './session.ts'
20
20
  import { InMemoryStore, type Store } from './store.ts'
21
+ import { persistencePosture } from './store-intent.ts'
21
22
 
22
23
  export interface CreateAppConfig extends DeprecatedFlatConfig {
23
24
  machinesDir: string
@@ -169,7 +170,23 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
169
170
  return new Promise((resolveFn) => {
170
171
  const server = serve({ fetch: app.fetch, port, hostname: resolved.host }, async () => {
171
172
  // Always-on (level-independent) so `warn` prod still confirms boot.
172
- printStartupNotice({ port, machines: defs.length, routes: routes.length })
173
+ // Persistence posture in the notice, and a warning for anything
174
+ // actually at risk. Silence about in-memory storage is the defect
175
+ // this closes: the app runs, looks healthy, and loses every session
176
+ // on restart.
177
+ const posture = persistencePosture({
178
+ session: sessionStore,
179
+ app: store.appStore,
180
+ machines: defs,
181
+ production: process.env.NODE_ENV === 'production',
182
+ })
183
+ printStartupNotice({
184
+ port,
185
+ machines: defs.length,
186
+ routes: routes.length,
187
+ persistence: posture.label,
188
+ })
189
+ for (const warning of posture.warnings) logger.warn({}, `stator: ${warning}`)
173
190
  // Run boot.ts once the server is up — its long-lived work (a poll, a
174
191
  // subscription) starts here, not during createApp, so tests that only
175
192
  // `app.fetch` never trigger it.
@@ -22,7 +22,7 @@ import { isStatorQueryRoute, type RouteDefinition } from './routing.ts'
22
22
  import { CLAIMS_KEY, getOrCreateSessionId, getSessionState } from './session.ts'
23
23
  import { withSessionLock } from './session-lock.ts'
24
24
  import { SessionRuntime } from './session-runtime.ts'
25
- import { fanOut, registerConnection, unregisterConnection } from './sse.ts'
25
+ import { enqueue, fanOut, registerConnection, unregisterConnection } from './sse.ts'
26
26
 
27
27
  const httpLog = scopedLogger('http')
28
28
 
@@ -423,7 +423,7 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
423
423
  // otherwise never reach this page.
424
424
  const sync = initialSyncPatches(renderState, runtime)
425
425
  if (sync.length > 0) {
426
- await conn.send(JSON.stringify({ patches: sync }))
426
+ enqueue(conn, JSON.stringify({ patches: sync }))
427
427
  }
428
428
 
429
429
  // Heartbeat every 25s. This is a real DATA message, not a comment, on
@@ -433,9 +433,11 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
433
433
  // ever arrives, no error fires) unless liveness is OBSERVABLE. The
434
434
  // runtime tracks last-message time and reconnects when pings stop.
435
435
  const keepAlive = setInterval(() => {
436
- void conn.send('{"ping":true}').catch(() => {
437
- // Stream closed; the abort handler cleans up.
438
- })
436
+ // Through the same queue fan-out uses, so it is bounded like any other
437
+ // write: a half-open socket never settles its writes, and the heartbeat
438
+ // is what reaps it when the app is otherwise idle and no dispatch would
439
+ // ever notice.
440
+ enqueue(conn, '{"ping":true}')
439
441
  }, config.ssePingMs ?? 25_000)
440
442
 
441
443
  try {
@@ -547,7 +549,9 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
547
549
  // `touched`) — so it isn't re-created and re-fired next request.
548
550
  await runtime.persistTouched(new Set([...touched, ...runtime.entryFiredMachines()]))
549
551
 
550
- await fanOut(touched, {
552
+ // Queues patches on every affected channel and returns; no socket is
553
+ // awaited under the lock.
554
+ const { originatorQueued } = await fanOut(touched, {
551
555
  sessionId,
552
556
  originClientId: c.req.header('X-Stator-Client'),
553
557
  })
@@ -557,7 +561,15 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
557
561
  // normal event path in server/effects.ts.
558
562
  scheduleSessionEffects(runtime, config.store, sessionId)
559
563
 
560
- const envelope = { patches, directives: [], committed: touched.size > 0 }
564
+ // This page's patches normally ride the response. When its live
565
+ // channel already had a backlog, fan-out queued them behind it instead
566
+ // (two channels to one page must not reorder a positional list op), so
567
+ // the response carries none.
568
+ const envelope = {
569
+ patches: originatorQueued ? [] : patches,
570
+ directives: [],
571
+ committed: touched.size > 0,
572
+ }
561
573
  if (body.eventId) record(sessionId, body.eventId, JSON.stringify(envelope))
562
574
  return c.json(envelope)
563
575
  } finally {
@@ -136,3 +136,5 @@ export {
136
136
  } from './sse.ts'
137
137
  export type { Store } from './store.ts'
138
138
  export { InMemoryStore } from './store.ts'
139
+ export type { StoreFromEnvOptions } from './store-intent.ts'
140
+ export { appStore, sessionStore } from './store-intent.ts'
package/src/server/sse.ts CHANGED
@@ -17,13 +17,21 @@ const sseLog = scopedLogger('sse')
17
17
  * recompute will diff against when fan-out fires. Each push updates
18
18
  * `lastValue` for the bindings touched by the push, so subsequent pushes
19
19
  * only emit deltas.
20
+ *
21
+ * Writes never happen inline. Everything outbound — fan-out patches, the
22
+ * initial sync, the heartbeat, the dev broadcast — goes through `enqueue`,
23
+ * and one drain loop per connection owns the socket. A request or a lock is
24
+ * therefore never held across a socket write, and per-connection order is a
25
+ * property of the queue rather than of who awaited what.
20
26
  */
21
27
  export interface Connection {
22
28
  id: string
23
29
  sessionId: string
24
30
  /** The browser page-load identity (client-generated). Lets fan-out
25
- * recognize a dispatch's OWN connection: its baseline is advanced but
26
- * nothing is sent — the POST response already delivered those patches. */
31
+ * recognize a dispatch's OWN connection: its baseline is advanced and,
32
+ * when nothing is queued for it, nothing is sent — the POST response
33
+ * delivers those patches. With a backlog they ride the queue instead, so
34
+ * the two channels cannot reorder a positional list op. */
27
35
  clientId?: string
28
36
  routeKey: string
29
37
  route: RouteDefinition
@@ -39,14 +47,29 @@ export interface Connection {
39
47
  * transport; absent in tests that register a bare connection. */
40
48
  close?: () => void
41
49
  closed: boolean
50
+ /** Outbound writes waiting for the drain loop, oldest first. */
51
+ queue: string[]
52
+ /** Bytes waiting in `queue`. The item being written is not counted. */
53
+ queuedBytes: number
54
+ /** True while the drain loop owns the socket — an item is in flight. */
55
+ draining: boolean
42
56
  }
43
57
 
58
+ type ConnectionInit = Omit<Connection, 'id' | 'closed' | 'queue' | 'queuedBytes' | 'draining'>
59
+
44
60
  const connections = new Map<string, Connection>()
45
61
  let nextId = 0
46
62
 
47
- export function registerConnection(init: Omit<Connection, 'id' | 'closed'>): Connection {
63
+ export function registerConnection(init: ConnectionInit): Connection {
48
64
  const id = `sse${nextId++}`
49
- const conn: Connection = { ...init, id, closed: false }
65
+ const conn: Connection = {
66
+ ...init,
67
+ id,
68
+ closed: false,
69
+ queue: [],
70
+ queuedBytes: 0,
71
+ draining: false,
72
+ }
50
73
  // A page-load holds at most one channel per route, so an existing
51
74
  // connection with the same (clientId, routeKey) is a corpse whose abort we
52
75
  // never observed — a half-open socket, or a reconnect that raced its own
@@ -73,21 +96,16 @@ export function registerConnection(init: Omit<Connection, 'id' | 'closed'>): Con
73
96
  }
74
97
 
75
98
  /**
76
- * Push a pre-serialized envelope to every open connection, bypassing the
99
+ * Queue a pre-serialized envelope for every open connection, bypassing the
77
100
  * recompute path. The dev server's rebuild/error signals ride this so a dev
78
101
  * page holds ONE event-stream instead of two — see `dev-native.ts`. Not a
79
102
  * fan-out: no diffing, no baseline advance, no per-connection filtering.
80
103
  */
81
- export async function broadcastEnvelope(payload: unknown): Promise<void> {
104
+ export function broadcastEnvelope(payload: unknown): void {
82
105
  if (connections.size === 0) return
83
106
  const data = JSON.stringify(payload)
84
107
  for (const conn of connections.values()) {
85
- if (conn.closed) continue
86
- try {
87
- await conn.send(data)
88
- } catch (err) {
89
- sseLog.warn({ id: conn.id, err: String(err) }, 'broadcast failed')
90
- }
108
+ if (!conn.closed) enqueue(conn, data)
91
109
  }
92
110
  }
93
111
 
@@ -95,6 +113,10 @@ export function unregisterConnection(id: string): void {
95
113
  const conn = connections.get(id)
96
114
  if (!conn) return
97
115
  conn.closed = true
116
+ // Whatever was waiting is discarded with the socket: the client reconnects
117
+ // and resyncs. The drain loop, if mid-write, sees `closed` and exits.
118
+ conn.queue.length = 0
119
+ conn.queuedBytes = 0
98
120
  conn.runtime.dispose()
99
121
  connections.delete(id)
100
122
  sseLog.debug(
@@ -107,6 +129,133 @@ export function activeConnectionCount(): number {
107
129
  return connections.size
108
130
  }
109
131
 
132
+ /** How long one write to one connection may take before the connection is
133
+ * treated as dead. `STATOR_SSE_WRITE_TIMEOUT_MS` overrides it. */
134
+ const DEFAULT_WRITE_TIMEOUT_MS = 5_000
135
+
136
+ function writeTimeoutMs(): number {
137
+ const raw = process.env.STATOR_SSE_WRITE_TIMEOUT_MS
138
+ if (raw === undefined || raw === '') return DEFAULT_WRITE_TIMEOUT_MS
139
+ const ms = Number(raw)
140
+ return Number.isFinite(ms) && ms > 0 ? ms : DEFAULT_WRITE_TIMEOUT_MS
141
+ }
142
+
143
+ /** Bytes a connection may hold WAITING behind an in-flight write before it is
144
+ * treated as dead. The item being written is covered by the write deadline,
145
+ * not by this, and a single waiting item never trips it however large — an
146
+ * initial sync of a big table is one item. `STATOR_SSE_QUEUE_MAX_BYTES`
147
+ * overrides it. */
148
+ const DEFAULT_QUEUE_MAX_BYTES = 1_048_576
149
+
150
+ function queueMaxBytes(): number {
151
+ const raw = process.env.STATOR_SSE_QUEUE_MAX_BYTES
152
+ if (raw === undefined || raw === '') return DEFAULT_QUEUE_MAX_BYTES
153
+ const bytes = Number(raw)
154
+ return Number.isFinite(bytes) && bytes > 0 ? bytes : DEFAULT_QUEUE_MAX_BYTES
155
+ }
156
+
157
+ /** Whether anything is queued or in flight for this connection. */
158
+ export function hasBacklog(conn: Connection): boolean {
159
+ return conn.draining || conn.queue.length > 0
160
+ }
161
+
162
+ /**
163
+ * Queue one envelope for a connection and return at once. The drain loop
164
+ * writes it when everything ahead of it has been written, so order per
165
+ * connection is FIFO by construction. Overflow drops the connection, the
166
+ * same policy as a write that times out: the client reconnects and resyncs.
167
+ */
168
+ export function enqueue(conn: Connection, data: string): void {
169
+ if (conn.closed) return
170
+ conn.queue.push(data)
171
+ conn.queuedBytes += Buffer.byteLength(data)
172
+ if (conn.queue.length > 1 && conn.queuedBytes > queueMaxBytes()) {
173
+ sseLog.warn(
174
+ {
175
+ id: conn.id,
176
+ sid: conn.sessionId,
177
+ route: conn.routeKey,
178
+ waiting: conn.queue.length,
179
+ bytes: conn.queuedBytes,
180
+ max: queueMaxBytes(),
181
+ },
182
+ 'sse queue overflowed — dropping the connection',
183
+ )
184
+ unregisterConnection(conn.id)
185
+ conn.close?.()
186
+ return
187
+ }
188
+ if (!conn.draining) void drain(conn)
189
+ }
190
+
191
+ /** The one writer per connection. Runs until the queue is empty or the
192
+ * connection closes under it (a timed-out write, an overflow, an abort). */
193
+ async function drain(conn: Connection): Promise<void> {
194
+ conn.draining = true
195
+ try {
196
+ while (!conn.closed && conn.queue.length > 0) {
197
+ const data = conn.queue.shift()!
198
+ conn.queuedBytes -= Buffer.byteLength(data)
199
+ await pushToConnection(conn, data)
200
+ }
201
+ } finally {
202
+ conn.draining = false
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Write one envelope to one connection, bounded. The drain loop's single
208
+ * write; nothing else touches the socket.
209
+ *
210
+ * A write to a live socket resolves; a write to a closed one rejects. A write
211
+ * to a HALF-OPEN one does neither once its buffer is full — when a remote
212
+ * laptop sleeps or a NAT drops the mapping, no FIN ever arrives, the socket
213
+ * still looks writable, and the promise simply never settles. Fan-out used to
214
+ * await these serially with no deadline, so one such connection stalled the
215
+ * loop and every connection registered after it received nothing until the
216
+ * kernel gave up on the socket (minutes) or the same page reconnected and
217
+ * evicted it. Under the session lock the same stall ran into the lock's 30s
218
+ * backstop, failing the dispatching request each time.
219
+ *
220
+ * On a timeout the connection is dropped rather than retried: the client
221
+ * reconnects and gets a full resync, which is cheap by design. Note what the
222
+ * deadline measures: a write to a socket with buffer space resolves at once
223
+ * regardless of the peer, so this fires only when the buffer has stayed full
224
+ * for the whole window — not on a merely slow client. And because the clock
225
+ * starts when the drain loop issues the write, not when the item was queued,
226
+ * a burst behind a slow but healthy socket cannot trip it.
227
+ */
228
+ export async function pushToConnection(conn: Connection, data: string): Promise<Outcome> {
229
+ let timer: ReturnType<typeof setTimeout> | undefined
230
+ try {
231
+ const outcome = await Promise.race<Outcome>([
232
+ conn.send(data).then((): Outcome => 'sent'),
233
+ new Promise<Outcome>((settle) => {
234
+ timer = setTimeout(() => settle('timeout'), writeTimeoutMs())
235
+ timer.unref?.()
236
+ }),
237
+ ])
238
+ if (outcome === 'timeout') {
239
+ sseLog.warn(
240
+ { id: conn.id, sid: conn.sessionId, route: conn.routeKey, ms: writeTimeoutMs() },
241
+ 'sse write timed out — dropping the connection',
242
+ )
243
+ unregisterConnection(conn.id)
244
+ conn.close?.()
245
+ }
246
+ return outcome
247
+ } catch (err) {
248
+ // Bumped from debug to warn — silent push failures were why "30-50%
249
+ // delivery" was invisible in production logs.
250
+ sseLog.warn({ id: conn.id, sid: conn.sessionId, err: String(err) }, 'sse push failed')
251
+ return 'failed'
252
+ } finally {
253
+ clearTimeout(timer)
254
+ }
255
+ }
256
+
257
+ type Outcome = 'sent' | 'timeout' | 'failed'
258
+
110
259
  /**
111
260
  * Hang up every open connection — the shutdown path. A live response never ends
112
261
  * on its own, so `server.close()` cannot complete until these do; see
@@ -125,24 +274,31 @@ export function closeLiveConnections(): number {
125
274
  /**
126
275
  * After a state-changing dispatch settles, iterate every open connection
127
276
  * whose route's `reads:` intersects `touched`, recompute against that
128
- * connection's slot map, and push any resulting patches over its event
277
+ * connection's slot map, and queue any resulting patches on its event
129
278
  * stream. Recompute mutates the connection's `renderState.lastValue`s so
130
- * the next push is correctly diffed.
279
+ * the next push is correctly diffed. Returns as soon as everything is
280
+ * queued: no socket is awaited here, so a dispatch never waits on another
281
+ * page's network.
131
282
  *
132
283
  * Connections from any session receive pushes — that's the point: an
133
284
  * admin tab on session C sees updates triggered by session A's POSTs.
285
+ *
286
+ * `originatorQueued` tells the dispatch path whether the acting page's own
287
+ * patches were queued on its channel (because it already had a backlog)
288
+ * rather than left to the POST response. When true, the response must carry
289
+ * none, or the same positional list op lands twice.
134
290
  */
135
291
  export async function fanOut(
136
292
  touched: ReadonlySet<string>,
137
293
  source?: { sessionId?: string; originClientId?: string },
138
- ): Promise<void> {
139
- if (touched.size === 0 || connections.size === 0) return
294
+ ): Promise<{ originatorQueued: boolean }> {
295
+ if (touched.size === 0 || connections.size === 0) return { originatorQueued: false }
140
296
 
141
- let pushedCount = 0
297
+ let enqueued = 0
142
298
  let skippedNoIntersect = 0
143
299
  let skippedNoPatches = 0
144
300
  let skippedOriginator = 0
145
- let failed = 0
301
+ let originatorQueued = false
146
302
 
147
303
  // Expand through the reverse-reads graph once per fan-out: machines whose
148
304
  // SELECTORS derive from a touched machine must re-diff even though their
@@ -188,42 +344,43 @@ export async function fanOut(
188
344
  continue
189
345
  }
190
346
 
191
- // The dispatching page's own connection: the POST response already
192
- // delivered this diff. The recompute above advanced the baseline (so the
193
- // NEXT push diffs correctly); sending would double-apply — and keyed
194
- // insert/remove/move ops are not idempotent.
195
- if (
347
+ // The dispatching page's own connection. With nothing queued for it, the
348
+ // POST response delivers this diff: the recompute above advanced the
349
+ // baseline (so the NEXT push diffs correctly) and sending would
350
+ // double-apply — keyed insert/remove/move ops are not idempotent. With a
351
+ // backlog, the response would overtake what is still queued, and a
352
+ // positional op applied out of order corrupts a list rather than merely
353
+ // staling it — so the diff rides the queue and the response carries none.
354
+ const isOriginator =
196
355
  source?.originClientId !== undefined &&
197
356
  conn.clientId !== undefined &&
198
357
  conn.clientId === source.originClientId
199
- ) {
200
- skippedOriginator++
201
- continue
358
+ if (isOriginator) {
359
+ if (!hasBacklog(conn)) {
360
+ skippedOriginator++
361
+ continue
362
+ }
363
+ originatorQueued = true
202
364
  }
203
365
 
204
- try {
205
- await conn.send(JSON.stringify({ patches }))
206
- pushedCount++
207
- } catch (err) {
208
- // Bumped from debug to warn — silent push failures were why "30-50%
209
- // delivery" was invisible in production logs.
210
- failed++
211
- sseLog.warn({ id: conn.id, sid: conn.sessionId, err: String(err) }, 'sse push failed')
212
- }
366
+ enqueue(conn, JSON.stringify({ patches }))
367
+ enqueued++
213
368
  }
214
369
 
215
370
  // Log every fan-out at debug so the user can see touched-machines flow and
216
- // correlate against client-side inspector entries — noisy for prod, so debug.
371
+ // correlate against client-side inspector entries — noisy for prod, so
372
+ // debug. Write outcomes are logged by the drain loop as they happen.
217
373
  sseLog.debug(
218
374
  {
219
375
  touched: [...touched],
220
376
  total: connections.size,
221
- pushed: pushedCount,
377
+ enqueued,
222
378
  skippedNoIntersect,
223
379
  skippedNoPatches,
224
380
  skippedOriginator,
225
- failed,
381
+ originatorQueued,
226
382
  },
227
383
  'fan-out',
228
384
  )
385
+ return { originatorQueued }
229
386
  }
@@ -0,0 +1,175 @@
1
+ import type { AppStore } from './app-store.ts'
2
+ import { InMemoryAppStore } from './app-store.ts'
3
+ import { CachedStore, type CachedStoreOptions } from './cached-store.ts'
4
+ import { RedisAppStore, RedisStore } from './redis-store.ts'
5
+ import { InMemoryStore, type Store } from './store.ts'
6
+
7
+ /**
8
+ * Picking a store from the environment, without the silent downgrade.
9
+ *
10
+ * Every app that keeps state in Redis writes the same conditional: a URL from
11
+ * the environment, a durable store when it's there, in-memory when it isn't.
12
+ * That fallback is right for dev and for CI — a build machine has no business
13
+ * holding production credentials — and wrong in exactly one place, production,
14
+ * where it means the app runs, looks healthy, and quietly loses every session
15
+ * on restart. Written in userland the framework cannot tell that outcome from a
16
+ * deliberate choice, so it cannot say anything about it.
17
+ *
18
+ * Moving the conditional in here changes only that: the returned store carries
19
+ * whether in-memory was *chosen* or *fallen back to*, so startup can be silent
20
+ * in development and loud in production, naming the empty variable instead of
21
+ * reporting a generic posture.
22
+ *
23
+ * The environment stays in charge. A URL present means durable, wherever it
24
+ * comes from — so pointing CI at a test Redis is nothing special, it is just
25
+ * the variable being set. An empty or whitespace-only value counts as absent,
26
+ * which is what a CI system that defines every variable as `""` produces.
27
+ */
28
+
29
+ /** Recorded on a fallback store so startup can tell it from a deliberate one.
30
+ * Non-enumerable and internal: the value still satisfies `Store` exactly. */
31
+ const INTENT = Symbol.for('stator.store.intent')
32
+
33
+ interface StoreIntent {
34
+ /** What the app asked for. */
35
+ wanted: 'durable'
36
+ /** The config field that was empty, for the message. */
37
+ from: string
38
+ }
39
+
40
+ function mark<T extends object>(store: T, intent: StoreIntent): T {
41
+ Object.defineProperty(store, INTENT, { value: intent, enumerable: false })
42
+ return store
43
+ }
44
+
45
+ /** The recorded intent of a store, if it was built by these helpers. */
46
+ export function storeIntent(store: unknown): StoreIntent | undefined {
47
+ if (store === null || typeof store !== 'object') return undefined
48
+ return (store as Record<symbol, StoreIntent | undefined>)[INTENT]
49
+ }
50
+
51
+ /** True for the framework's own in-memory adapters — the ones that do not
52
+ * survive a restart. A store the app supplied is assumed durable; the
53
+ * framework has no way to ask, and no business guessing. */
54
+ export function isEphemeral(store: unknown): boolean {
55
+ return store instanceof InMemoryStore || store instanceof InMemoryAppStore
56
+ }
57
+
58
+ const blank = (url: string | undefined): boolean => url === undefined || url.trim() === ''
59
+
60
+ export interface StoreFromEnvOptions {
61
+ /** Redis connection string, typically `process.env.REDIS_URL`. Absent, empty
62
+ * or whitespace-only selects in-memory — normal in dev and CI, reported at
63
+ * startup in production. */
64
+ redisUrl?: string
65
+ /** Wrap Redis in a write-through memory cache. Off by default: caching is a
66
+ * performance decision, and this helper's job is the durable-or-not one. */
67
+ cache?: CachedStoreOptions | true
68
+ /** Key prefix, passed through to the Redis adapter. */
69
+ keyPrefix?: string
70
+ /** Name used in the startup report when the URL is empty. Default `REDIS_URL`. */
71
+ envName?: string
72
+ }
73
+
74
+ /**
75
+ * Session-machine storage from the environment: Redis when a URL is set,
76
+ * in-memory otherwise.
77
+ *
78
+ * persistence: { session: sessionStore({ redisUrl: process.env.REDIS_URL }) }
79
+ *
80
+ * Call it with nothing (or omit `persistence.session` entirely) to choose
81
+ * in-memory deliberately — that is a real choice and is never reported.
82
+ */
83
+ export function sessionStore(opts: StoreFromEnvOptions = {}): Store {
84
+ if (blank(opts.redisUrl)) {
85
+ // In-memory either way, but for two different reasons. Passing the key at
86
+ // all — `redisUrl: process.env.REDIS_URL` — says the app wants durability
87
+ // from the environment, so an empty value is worth reporting in production.
88
+ // Passing no key says in-memory was the choice, which is never reported.
89
+ return 'redisUrl' in opts
90
+ ? mark(new InMemoryStore(), { wanted: 'durable', from: opts.envName ?? 'REDIS_URL' })
91
+ : new InMemoryStore()
92
+ }
93
+ const redis = opts.keyPrefix
94
+ ? new RedisStore(opts.redisUrl as string, opts.keyPrefix)
95
+ : new RedisStore(opts.redisUrl as string)
96
+ if (!opts.cache) return redis
97
+ return new CachedStore(redis, opts.cache === true ? {} : opts.cache)
98
+ }
99
+
100
+ /**
101
+ * App-machine storage (`persist: true`) from the environment. Same rule as
102
+ * `sessionStore`.
103
+ */
104
+ export function appStore(opts: StoreFromEnvOptions = {}): AppStore {
105
+ if (blank(opts.redisUrl)) {
106
+ return 'redisUrl' in opts
107
+ ? mark(new InMemoryAppStore(), { wanted: 'durable', from: opts.envName ?? 'REDIS_URL' })
108
+ : new InMemoryAppStore()
109
+ }
110
+ return opts.keyPrefix
111
+ ? new RedisAppStore(opts.redisUrl as string, opts.keyPrefix)
112
+ : new RedisAppStore(opts.redisUrl as string)
113
+ }
114
+
115
+ /** One machine's persistence-relevant shape. */
116
+ export interface MachinePersistence {
117
+ lifecycle: string
118
+ persist: boolean
119
+ }
120
+
121
+ /**
122
+ * What to say about persistence at startup. The posture goes in the notice —
123
+ * printed at every level, so a deploy log always states whether state survives
124
+ * a restart — and anything worth alerting on comes back as a warning.
125
+ *
126
+ * Warnings fire in production only, and only when something is actually at
127
+ * risk: an app with no session machines has no session state to lose, and an
128
+ * app that deliberately chose in-memory made a real choice. Nothing here ever
129
+ * refuses to start; persistent storage is assumed to be wanted, never required.
130
+ */
131
+ export function persistencePosture(input: {
132
+ session: unknown
133
+ app: unknown
134
+ machines: readonly MachinePersistence[]
135
+ production: boolean
136
+ }): { label: string; warnings: string[] } {
137
+ const name = (store: unknown): string =>
138
+ isEphemeral(store)
139
+ ? 'in-memory'
140
+ : ((store as { constructor?: { name?: string } })?.constructor?.name ?? 'custom')
141
+
142
+ const sessionMachines = input.machines.filter((m) => m.lifecycle === 'session').length
143
+ const persistedApp = input.machines.filter((m) => m.lifecycle === 'app' && m.persist).length
144
+
145
+ let label = `sessions ${name(input.session)}`
146
+ if (persistedApp > 0) label += ` · app ${name(input.app)}`
147
+
148
+ const warnings: string[] = []
149
+ if (input.production) {
150
+ const sessionIntent = storeIntent(input.session)
151
+ if (isEphemeral(input.session) && sessionIntent) {
152
+ // The app configured a durable store and the variable was empty — the
153
+ // one case the framework can name precisely.
154
+ warnings.push(
155
+ `${sessionIntent.from} is empty, so session state is in memory and will not survive a restart`,
156
+ )
157
+ } else if (isEphemeral(input.session) && sessionMachines > 0) {
158
+ warnings.push(
159
+ `no durable session store is configured, so the state of ${sessionMachines} session machine${
160
+ sessionMachines === 1 ? '' : 's'
161
+ } will not survive a restart`,
162
+ )
163
+ }
164
+
165
+ const appIntent = storeIntent(input.app)
166
+ if (isEphemeral(input.app) && persistedApp > 0) {
167
+ warnings.push(
168
+ appIntent
169
+ ? `${appIntent.from} is empty, so ${persistedApp} persisted app machine${persistedApp === 1 ? '' : 's'} will not survive a restart`
170
+ : `no durable app store is configured, so ${persistedApp} persisted app machine${persistedApp === 1 ? '' : 's'} will not survive a restart`,
171
+ )
172
+ }
173
+ }
174
+ return { label, warnings }
175
+ }