@statorjs/stator 2.10.0-next.0 → 2.10.0

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.
@@ -35,6 +35,14 @@ export interface DiscoveredRoute {
35
35
  PUT?: ApiRouteDefinition
36
36
  PATCH?: ApiRouteDefinition
37
37
  DELETE?: ApiRouteDefinition
38
+ /** True for a `404.stator`/`404.ts` file — sugar for a `[...name]`
39
+ * catch-all (same synthesized pattern, same matcher), except
40
+ * `sortRoutes` forces it to the absolute lowest priority: below even a
41
+ * real `[...name]` at the same depth. It's the last resort, only ever
42
+ * reached once nothing else — including a genuine catch-all a request
43
+ * matched first — could actually serve the request (a 404 cascades by
44
+ * default; see the GET dispatch loop in http.ts). */
45
+ isNotFoundFallback?: boolean
38
46
  }
39
47
 
40
48
  /**
@@ -47,6 +55,11 @@ export interface DiscoveredRoute {
47
55
  * - `routes/[a]/[b].ts` → `/:a/:b`
48
56
  * - `routes/foo/[...rest].ts` → `/foo/*rest` (catch-all: terminal, matches
49
57
  * zero+ segments, param is the raw remainder incl. slashes)
58
+ * - `routes/foo/404.ts` → sugar for `routes/foo/[...___].ts` — a
59
+ * catch-all without needing to know the bracket-rest convention, EXCEPT
60
+ * `sortRoutes` also forces it to the absolute lowest match priority,
61
+ * below even a real `[...rest]` at the same depth — see
62
+ * `DiscoveredRoute.isNotFoundFallback`.
50
63
  *
51
64
  * Files may export any combination of `GET`/`POST`/`PUT`/`PATCH`/`DELETE`.
52
65
  * GET is a `defineRoute` (page renderer) or a `defineApiRoute` declaring
@@ -123,11 +136,12 @@ export async function discoverRoutes(
123
136
  continue
124
137
  }
125
138
 
126
- const { urlPath, paramNames } = filePathToRoute(absDir, filePath)
139
+ const { urlPath, paramNames, isNotFoundFallback } = filePathToRoute(absDir, filePath)
127
140
  routes.push({
128
141
  urlPath,
129
142
  paramNames,
130
143
  filePath,
144
+ ...(isNotFoundFallback ? { isNotFoundFallback } : {}),
131
145
  GET: get,
132
146
  POST: post,
133
147
  PUT: put,
@@ -169,6 +183,10 @@ function mergeByUrlPath(routes: DiscoveredRoute[]): DiscoveredRoute[] {
169
183
  /**
170
184
  * Sort routes most-specific-first (Astro's model). At match time the first
171
185
  * matcher that matches wins, so order encodes priority:
186
+ * - a `404.stator`/`404.ts` file (`isNotFoundFallback`) ranks absolute
187
+ * last, below even a real `[...name]` at the same depth — it's the
188
+ * final resort, only reached once nothing else (a genuine catch-all
189
+ * included) could serve the request
172
190
  * - routes without a rest/catch-all segment rank before those with one
173
191
  * - per segment, left to right: static (0) > suffixed param `:id.json` (1)
174
192
  * > bare param (2) > rest (3) — a suffixed param outranks a bare one so
@@ -186,6 +204,10 @@ export function sortRoutes(routes: DiscoveredRoute[]): DiscoveredRoute[] {
186
204
  )
187
205
 
188
206
  return [...routes].sort((a, b) => {
207
+ const aFallback = !!a.isNotFoundFallback
208
+ const bFallback = !!b.isNotFoundFallback
209
+ if (aFallback !== bFallback) return aFallback ? 1 : -1 // 404 fallback always last
210
+
189
211
  const ka = kinds(a.urlPath)
190
212
  const kb = kinds(b.urlPath)
191
213
  const aRest = ka.includes(3)
@@ -230,7 +252,7 @@ async function walkRouteFiles(dir: string): Promise<string[]> {
230
252
  export function filePathToRoute(
231
253
  absDir: string,
232
254
  filePath: string,
233
- ): { urlPath: string; paramNames: string[] } {
255
+ ): { urlPath: string; paramNames: string[]; isNotFoundFallback: boolean } {
234
256
  const ext = extname(filePath)
235
257
  const rel = relative(absDir, filePath)
236
258
  const dirSegments = dirname(rel)
@@ -240,9 +262,18 @@ export function filePathToRoute(
240
262
  // sibling `<name>.stator.ts` files, which must map to the same URL as the
241
263
  // `<name>.stator` source did in dev.
242
264
  const fileBase = basename(rel, ext).replace(/\.stator$/, '')
243
- const segments = fileBase === 'index' ? dirSegments : [...dirSegments, fileBase]
265
+ // `404` is sugar for a synthesized `[...name]` at this exact position —
266
+ // reuses the rest-segment branch below verbatim, so it gets the same
267
+ // matcher/regex a real catch-all would. `sortRoutes` is what actually
268
+ // forces it below a real `[...name]` at the same depth; this alone would
269
+ // just be an ordinary (if oddly-named) catch-all.
270
+ const isNotFoundFallback = fileBase === '404'
271
+ const segments =
272
+ fileBase === 'index'
273
+ ? dirSegments
274
+ : [...dirSegments, isNotFoundFallback ? '[...__stator_notfound__]' : fileBase]
244
275
 
245
- if (segments.length === 0) return { urlPath: '/', paramNames: [] }
276
+ if (segments.length === 0) return { urlPath: '/', paramNames: [], isNotFoundFallback }
246
277
 
247
278
  const paramNames: string[] = []
248
279
  const urlSegments = segments.map((seg) => {
@@ -272,5 +303,5 @@ export function filePathToRoute(
272
303
  return seg
273
304
  })
274
305
 
275
- return { urlPath: `/${urlSegments.join('/')}`, paramNames }
306
+ return { urlPath: `/${urlSegments.join('/')}`, paramNames, isNotFoundFallback }
276
307
  }
@@ -198,6 +198,7 @@ export function defineApiRoute<TReads extends ReadonlyArray<AnyMachineDef>>(
198
198
  __isStatorQueryRoute: true,
199
199
  reads: config.reads ? [...config.reads] : [],
200
200
  handler: config.handler,
201
+ revision: config.revision,
201
202
  }
202
203
  }
203
204
  return {
@@ -248,6 +249,7 @@ export interface QueryRouteDefinition {
248
249
  request: RouteRequest,
249
250
  helpers: QueryRouteHelpers,
250
251
  ) => Promise<QueryRouteResult> | QueryRouteResult
252
+ revision?: () => string | number | Promise<string | number>
251
253
  }
252
254
 
253
255
  export interface DefineQueryRouteConfig<TReads extends ReadonlyArray<AnyMachineDef> = readonly []> {
@@ -259,6 +261,12 @@ export interface DefineQueryRouteConfig<TReads extends ReadonlyArray<AnyMachineD
259
261
  request: RouteRequest,
260
262
  helpers: QueryRouteHelpers<TReads>,
261
263
  ) => Promise<QueryRouteResult> | QueryRouteResult
264
+ /** The revision ledger: a CHEAP fingerprint of everything the body depends
265
+ * on (e.g. `() => String(maxUpdatedAt())` — one indexed SELECT). When
266
+ * present, the ETag derives from it and a matching `If-None-Match`
267
+ * answers 304 WITHOUT invoking the handler — a polling crawler costs the
268
+ * fingerprint, not the render. Must change whenever the body could. */
269
+ revision?: () => string | number | Promise<string | number>
262
270
  }
263
271
 
264
272
  export function isStatorQueryRoute(v: unknown): v is QueryRouteDefinition {
@@ -23,9 +23,18 @@ export interface SessionState {
23
23
  claimsDirty: boolean
24
24
  }
25
25
 
26
+ /** Layer-3 provability tracking: did this request USE the session plane
27
+ * (establish/dispatch/session-read) or READ claims? Either makes the
28
+ * response unprovable as anonymous-identical, so no derived Cache-Control. */
29
+ export interface SessionUse {
30
+ used: boolean
31
+ claimsRead: boolean
32
+ }
33
+
26
34
  declare module 'hono' {
27
35
  interface ContextVariableMap {
28
36
  statorSession: SessionState
37
+ statorSessionUse: SessionUse
29
38
  }
30
39
  }
31
40
 
@@ -34,6 +43,40 @@ export function getSessionState(c: Context): SessionState | undefined {
34
43
  return c.get('statorSession')
35
44
  }
36
45
 
46
+ /** The request's session-use flags (created on first access). */
47
+ export function sessionUse(c: Context): SessionUse {
48
+ let use = c.get('statorSessionUse')
49
+ if (!use) {
50
+ use = { used: false, claimsRead: false }
51
+ c.set('statorSessionUse', use)
52
+ }
53
+ return use
54
+ }
55
+
56
+ /** The session id this request WOULD have — established state or the arriving
57
+ * cookie — without creating anything. Null for a first-contact visitor. */
58
+ export function peekSessionId(c: Context): string | null {
59
+ return getSessionState(c)?.sid ?? getCookie(c, SESSION_COOKIE) ?? null
60
+ }
61
+
62
+ /** Resume an EXISTING session from the arriving cookie (populate the shared
63
+ * state, no cookie write) — or return null for first contact. Resuming is
64
+ * not establishment: laziness governs CREATION only. */
65
+ export function resumeSession(c: Context): SessionState | null {
66
+ const established = getSessionState(c)
67
+ if (established) return established
68
+ const cookieSid = getCookie(c, SESSION_COOKIE)
69
+ if (cookieSid === undefined) return null
70
+ const state: SessionState = {
71
+ sid: cookieSid,
72
+ isNew: false,
73
+ claims: undefined,
74
+ claimsDirty: false,
75
+ }
76
+ c.set('statorSession', state)
77
+ return state
78
+ }
79
+
37
80
  /** Secure cookie flag — set when running behind HTTPS. Enabled by
38
81
  * NODE_ENV=production; can be overridden via STATOR_SECURE_COOKIE. */
39
82
  function shouldUseSecureCookie(): boolean {
@@ -65,22 +108,26 @@ export function setSessionCookie(c: Context, sessionId: string): void {
65
108
  })
66
109
  }
67
110
 
111
+ /**
112
+ * ESTABLISH the session — the lazy-creation trigger (dispatches, session-
113
+ * machine reads, SSE connects, explicit session ops call this; anonymous
114
+ * page/data GETs never do). Idempotent per request: the first caller
115
+ * establishes, everyone after reads the same state. A first-contact visitor
116
+ * gets a fresh sid + Set-Cookie HERE and nowhere earlier — the whole
117
+ * cacheable-read-path layer 1.
118
+ */
68
119
  export function getOrCreateSessionId(c: Context): {
69
120
  sessionId: string
70
121
  isNew: boolean
71
122
  } {
72
- // Idempotent per request: the first caller (middleware or handler) establishes
73
- // the session; everyone after reads the same one. Closes the double-create bug.
74
- const established = c.get('statorSession')
75
- if (established) return { sessionId: established.sid, isNew: established.isNew }
76
-
77
- const cookieSid = getCookie(c, SESSION_COOKIE)
78
- const sessionId = cookieSid ?? randomUUID()
79
- const isNew = cookieSid === undefined
80
- if (isNew) setSessionCookie(c, sessionId)
123
+ sessionUse(c).used = true
124
+ const resumed = resumeSession(c)
125
+ if (resumed) return { sessionId: resumed.sid, isNew: resumed.isNew }
81
126
 
82
- c.set('statorSession', { sid: sessionId, isNew, claims: undefined, claimsDirty: false })
83
- return { sessionId, isNew }
127
+ const sessionId = randomUUID()
128
+ setSessionCookie(c, sessionId)
129
+ c.set('statorSession', { sid: sessionId, isNew: true, claims: undefined, claimsDirty: false })
130
+ return { sessionId, isNew: true }
84
131
  }
85
132
 
86
133
  /**
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
  }
@@ -94,6 +94,11 @@ export interface ElementSpecificAttributes {
94
94
  autofocus?: boolean
95
95
  accept?: string
96
96
  multiple?: boolean
97
+ /** Associates this element with a `<form>` elsewhere in the document by
98
+ * id — the standard way to submit an element that can't live inside
99
+ * the form's own DOM subtree (e.g. a per-row control alongside a
100
+ * page-wide bulk-action form). `button` already carries this. */
101
+ form?: string
97
102
  }
98
103
  label: { for?: string }
99
104
  form: {
@@ -101,6 +106,11 @@ export interface ElementSpecificAttributes {
101
106
  method?: 'get' | 'post' | 'GET' | 'POST' | 'dialog'
102
107
  enctype?: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'
103
108
  novalidate?: boolean
109
+ /** Native inline event-handler attribute — a literal JS source string
110
+ * (`"return confirm(...)"`), distinct from the `on:submit={...}`
111
+ * Stator directive. Useful for a plain, no-hydration `confirm()`
112
+ * guard that doesn't otherwise need an island. */
113
+ onsubmit?: string
104
114
  }
105
115
  select: {
106
116
  name?: string