@statorjs/stator 2.10.0-next.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "2.10.0-next.0",
3
+ "version": "2.10.0-next.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, loaded natively by its own toolchain — no build step.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -95,7 +95,7 @@
95
95
  "pino-pretty": "^13.1.3",
96
96
  "vite": "^6.0.0",
97
97
  "vitest": "^2.1.0",
98
- "@statorjs/stator": "2.10.0-next.0"
98
+ "@statorjs/stator": "2.10.0-next.1"
99
99
  },
100
100
  "scripts": {
101
101
  "typecheck": "tsc --noEmit",
@@ -3,6 +3,7 @@ import { readdir, readFile, stat } from 'node:fs/promises'
3
3
  import { builtinModules } from 'node:module'
4
4
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
5
5
  import { build } from 'esbuild'
6
+ import ts from 'typescript'
6
7
  import { compile, regionResolverFor } from '../compiler/index.ts'
7
8
 
8
9
  /**
@@ -55,18 +56,10 @@ const NEVER_SOURCE = new Set(['node_modules', 'dist', 'tests', 'test', '__tests_
55
56
 
56
57
  const CODE = /\.(ts|tsx|mts|cts|js|mjs|cjs|jsx|stator)$/
57
58
  const BUILTIN = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)])
58
- /** `new URL('./x', import.meta.url)` — invisible to the bundler, so read the
59
- * literal directly. Same limit as the client seam: relative string literals.
60
- * (Injecting an import instead does NOT work: esbuild's TS loader elides an
61
- * import whose binding is unused, so the injected one disappears.) */
62
- const URL_ASSET_RE = /new\s+URL\(\s*(['"])(\.{1,2}\/[^'"\n]+)\1\s*,\s*import\.meta\.url\s*\)/g
63
59
  /** A `node_modules` path segment, either separator. Anything under one is a
64
60
  * dependency, never app source — and a real app's `node_modules` sits INSIDE
65
61
  * its root, so root containment alone cannot tell them apart. */
66
62
  const NODE_MODULES = /[\\/]node_modules[\\/]/
67
- /** `import(` not followed by a string or template literal. */
68
- const OPAQUE_IMPORT_RE = /(?<![.\w$])import\s*\(\s*([^'"`)\s])/g
69
-
70
63
  const norm = (p: string): string => p.replace(/\\/g, '/')
71
64
 
72
65
  export interface UntracedImport {
@@ -186,19 +179,67 @@ export async function resolveCopySet(opts: CopySetOptions): Promise<CopySet> {
186
179
  const unresolved = new Set<string>()
187
180
  const untraced: UntracedImport[] = []
188
181
 
189
- /** Record the non-import dependencies a source text carries. */
182
+ /**
183
+ * The two dependencies a module carries that the bundler's own graph misses,
184
+ * read from the SYNTAX rather than the text.
185
+ *
186
+ * Both used to be regex scans over raw source, which cannot tell code from
187
+ * prose: a comment explaining why the app avoids `import(name)`, or a string
188
+ * documenting the pattern, was picked up as a real untraceable import and
189
+ * failed the build. Found by dogfooding, and the reason this parses. Comments
190
+ * and string contents are simply not part of the tree.
191
+ */
190
192
  const scan = (text: string, file: string): string => {
191
- for (const m of text.matchAll(URL_ASSET_RE)) assets.add(resolve(dirname(file), m[2]!))
192
- for (const m of text.matchAll(OPAQUE_IMPORT_RE)) {
193
- untraced.push({
194
- file: norm(relative(realRoot, file)),
195
- line: text.slice(0, m.index).split('\n').length,
196
- source: `${text
197
- .slice(m.index, m.index + 60)
198
- .split('\n')[0]!
199
- .trim()}…`,
200
- })
193
+ const sourceFile = ts.createSourceFile(
194
+ file,
195
+ text,
196
+ ts.ScriptTarget.Latest,
197
+ false,
198
+ /\.(tsx|jsx)$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
199
+ )
200
+
201
+ /** `import.meta.url`. */
202
+ const isImportMetaUrl = (node: ts.Node): boolean =>
203
+ ts.isPropertyAccessExpression(node) &&
204
+ node.name.text === 'url' &&
205
+ ts.isMetaProperty(node.expression) &&
206
+ node.expression.keywordToken === ts.SyntaxKind.ImportKeyword
207
+
208
+ /** A specifier the bundler can follow: a string literal, or a template —
209
+ * esbuild glob-expands one with a fixed prefix, pulling in every match. */
210
+ const analysable = (node: ts.Node): boolean =>
211
+ ts.isStringLiteralLike(node) || ts.isTemplateExpression(node)
212
+
213
+ const visit = (node: ts.Node): void => {
214
+ // `new URL('./x', import.meta.url)` — an asset reference no import graph
215
+ // sees. String-literal specifiers only, the same limit as the client seam.
216
+ if (ts.isNewExpression(node) && ts.isIdentifier(node.expression)) {
217
+ if (node.expression.text === 'URL') {
218
+ const [spec, meta] = node.arguments ?? []
219
+ if (spec && ts.isStringLiteralLike(spec) && spec.text.startsWith('.')) {
220
+ if (meta && isImportMetaUrl(meta)) assets.add(resolve(dirname(file), spec.text))
221
+ }
222
+ }
223
+ }
224
+ // `import(expr)` the bundler cannot follow. It says nothing about these,
225
+ // so this is the only place they are caught.
226
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
227
+ const arg = node.arguments[0]
228
+ if (!arg || !analysable(arg)) {
229
+ const start = node.getStart(sourceFile)
230
+ untraced.push({
231
+ file: norm(relative(realRoot, file)),
232
+ line: sourceFile.getLineAndCharacterOfPosition(start).line + 1,
233
+ source: `${text
234
+ .slice(start, start + 60)
235
+ .split('\n')[0]!
236
+ .trim()}…`,
237
+ })
238
+ }
239
+ }
240
+ ts.forEachChild(node, visit)
201
241
  }
242
+ ts.forEachChild(sourceFile, visit)
202
243
  return text
203
244
  }
204
245
 
@@ -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 {
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
  }