orez 0.0.47 → 0.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/admin/http-proxy.d.ts.map +1 -1
  2. package/dist/admin/http-proxy.js.map +1 -1
  3. package/dist/admin/log-store.d.ts.map +1 -1
  4. package/dist/admin/log-store.js.map +1 -1
  5. package/dist/admin/server.d.ts +2 -2
  6. package/dist/admin/server.d.ts.map +1 -1
  7. package/dist/admin/server.js.map +1 -1
  8. package/dist/admin/ui.d.ts.map +1 -1
  9. package/dist/admin/ui.js +2 -2
  10. package/dist/admin/ui.js.map +1 -1
  11. package/dist/cli.js +1 -1
  12. package/dist/cli.js.map +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +68 -37
  16. package/dist/index.js.map +1 -1
  17. package/dist/pg-proxy.d.ts.map +1 -1
  18. package/dist/pg-proxy.js.map +1 -1
  19. package/dist/pglite-manager.d.ts.map +1 -1
  20. package/dist/pglite-manager.js +7 -1
  21. package/dist/pglite-manager.js.map +1 -1
  22. package/dist/replication/change-tracker.d.ts.map +1 -1
  23. package/dist/replication/change-tracker.js +16 -29
  24. package/dist/replication/change-tracker.js.map +1 -1
  25. package/dist/replication/handler.d.ts.map +1 -1
  26. package/dist/replication/handler.js +23 -6
  27. package/dist/replication/handler.js.map +1 -1
  28. package/dist/vite-plugin.js +1 -1
  29. package/dist/vite-plugin.js.map +1 -1
  30. package/package.json +2 -2
  31. package/src/admin/http-proxy.ts +5 -1
  32. package/src/admin/log-store.ts +4 -1
  33. package/src/admin/server.ts +7 -3
  34. package/src/admin/ui.ts +682 -680
  35. package/src/cli.ts +19 -18
  36. package/src/index.ts +87 -40
  37. package/src/pg-proxy.ts +3 -1
  38. package/src/pglite-manager.ts +8 -1
  39. package/src/replication/change-tracker.ts +20 -30
  40. package/src/replication/handler.ts +40 -13
  41. package/src/replication/pgoutput-encoder.test.ts +217 -0
  42. package/src/replication/zero-compat.test.ts +232 -1
  43. package/src/shim/hooks.mjs +33 -0
  44. package/src/vite-plugin.ts +1 -1
package/src/cli.ts CHANGED
@@ -788,23 +788,24 @@ const main = defineCommand({
788
788
  },
789
789
  async run({ args }) {
790
790
  const startTime = Date.now()
791
- const { config, stop, logStore, zeroEnv, actions, httpLogStore } = await startZeroLite({
792
- pgPort: Number(args['pg-port']),
793
- zeroPort: Number(args['zero-port']),
794
- dataDir: args['data-dir'],
795
- migrationsDir: args.migrations,
796
- seedFile: args.seed,
797
- pgUser: args['pg-user'],
798
- pgPassword: args['pg-password'],
799
- skipZeroCache: args['skip-zero-cache'],
800
- disableWasmSqlite: args['disable-wasm-sqlite'],
801
- logLevel: (args['log-level'] as 'error' | 'warn' | 'info' | 'debug') || undefined,
802
- logEnv: args['log-env'],
803
- onDbReady: args['on-db-ready'],
804
- admin: args.admin,
805
- adminPort: Number(args['admin-port']),
806
- adminLogs: args['admin-logs'],
807
- })
791
+ const { config, stop, logStore, zeroEnv, actions, httpLogStore } =
792
+ await startZeroLite({
793
+ pgPort: Number(args['pg-port']),
794
+ zeroPort: Number(args['zero-port']),
795
+ dataDir: args['data-dir'],
796
+ migrationsDir: args.migrations,
797
+ seedFile: args.seed,
798
+ pgUser: args['pg-user'],
799
+ pgPassword: args['pg-password'],
800
+ skipZeroCache: args['skip-zero-cache'],
801
+ disableWasmSqlite: args['disable-wasm-sqlite'],
802
+ logLevel: (args['log-level'] as 'error' | 'warn' | 'info' | 'debug') || undefined,
803
+ logEnv: args['log-env'],
804
+ onDbReady: args['on-db-ready'],
805
+ admin: args.admin,
806
+ adminPort: Number(args['admin-port']),
807
+ adminLogs: args['admin-logs'],
808
+ })
808
809
 
809
810
  let s3Server: import('node:http').Server | null = null
810
811
  if (args.s3) {
@@ -818,7 +819,7 @@ const main = defineCommand({
818
819
  let adminServer: import('node:http').Server | null = null
819
820
  if (args.admin && logStore) {
820
821
  const { findPort } = await import('./port.js')
821
- const adminPort = Number(args['admin-port']) || (config.zeroPort + 2)
822
+ const adminPort = Number(args['admin-port']) || config.zeroPort + 2
822
823
  const resolvedPort = await findPort(adminPort)
823
824
  const { startAdminServer } = await import('./admin/server.js')
824
825
  adminServer = await startAdminServer({
package/src/index.ts CHANGED
@@ -7,7 +7,14 @@
7
7
  */
8
8
 
9
9
  import { spawn, type ChildProcess } from 'node:child_process'
10
- import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ readFileSync,
14
+ rmSync,
15
+ unlinkSync,
16
+ writeFileSync,
17
+ } from 'node:fs'
11
18
  import { createRequire } from 'node:module'
12
19
  import { totalmem } from 'node:os'
13
20
  import { dirname, resolve } from 'node:path'
@@ -20,10 +27,10 @@ import { createInstance, createPGliteInstances, runMigrations } from './pglite-m
20
27
  import { findPort } from './port.js'
21
28
  import { installChangeTracking } from './replication/change-tracker.js'
22
29
 
30
+ import type { HttpLogStore } from './admin/http-proxy.js'
31
+ import type { LogStore } from './admin/log-store.js'
23
32
  import type { ZeroLiteConfig } from './config.js'
24
33
  import type { PGlite } from '@electric-sql/pglite'
25
- import type { LogStore } from './admin/log-store.js'
26
- import type { HttpLogStore } from './admin/http-proxy.js'
27
34
 
28
35
  export { getConfig, getConnectionString } from './config.js'
29
36
  export type { LogLevel, ZeroLiteConfig } from './config.js'
@@ -47,8 +54,12 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
47
54
 
48
55
  // when admin ui enabled, create log store and capture all log output
49
56
  const SOURCE_MAP: Record<string, string> = {
50
- 'orez': 'orez', 'pglite': 'pglite', 'pg-proxy': 'proxy',
51
- 'zero': 'zero', 'zero-cache': 'zero', 'orez/s3': 's3',
57
+ orez: 'orez',
58
+ pglite: 'pglite',
59
+ 'pg-proxy': 'proxy',
60
+ zero: 'zero',
61
+ 'zero-cache': 'zero',
62
+ 'orez/s3': 's3',
52
63
  }
53
64
  let logStore: LogStore | null = null
54
65
  let removeLogListener: (() => void) | null = null
@@ -180,13 +191,17 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
180
191
  }
181
192
  const elapsed = now - cdbResets.lastReset
182
193
  if (elapsed < MIN_RESET_INTERVAL_MS) {
183
- log.zero(`change db reset too soon (${Math.round(elapsed / 1000)}s ago), not retrying`)
194
+ log.zero(
195
+ `change db reset too soon (${Math.round(elapsed / 1000)}s ago), not retrying`
196
+ )
184
197
  return
185
198
  }
186
199
 
187
200
  cdbResets.count++
188
201
  cdbResets.lastReset = now
189
- log.zero(`stale change db detected, resetting (${cdbResets.count}/${MAX_CDB_RESETS})`)
202
+ log.zero(
203
+ `stale change db detected, resetting (${cdbResets.count}/${MAX_CDB_RESETS})`
204
+ )
190
205
 
191
206
  try {
192
207
  await instances.cdb.close()
@@ -224,36 +239,54 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
224
239
 
225
240
  // admin action handlers
226
241
  const actions = {
227
- restartZero: config.skipZeroCache ? undefined : async () => {
228
- if (zeroCacheProcess && !zeroCacheProcess.killed) {
229
- zeroCacheProcess.kill('SIGTERM')
230
- await new Promise<void>((r) => {
231
- const t = setTimeout(() => { zeroCacheProcess?.kill('SIGKILL'); r() }, 3000)
232
- zeroCacheProcess!.on('exit', () => { clearTimeout(t); r() })
233
- })
234
- }
235
- const zc = await startZeroCache(config, zeroInternalPort)
236
- zeroCacheProcess = zc.child
237
- await waitForZeroCache(config, undefined, zeroInternalPort)
238
- log.zero(`restarted ${port(config.zeroPort, 'magenta')}`)
239
- },
240
- resetZero: config.skipZeroCache ? undefined : async () => {
241
- if (zeroCacheProcess && !zeroCacheProcess.killed) {
242
- zeroCacheProcess.kill('SIGTERM')
243
- await new Promise<void>((r) => {
244
- const t = setTimeout(() => { zeroCacheProcess?.kill('SIGKILL'); r() }, 3000)
245
- zeroCacheProcess!.on('exit', () => { clearTimeout(t); r() })
246
- })
247
- }
248
- const replicaPath = resolve(config.dataDir, 'zero-replica.db')
249
- for (const suffix of ['', '-wal', '-shm', '-wal2']) {
250
- try { if (existsSync(replicaPath + suffix)) unlinkSync(replicaPath + suffix) } catch {}
251
- }
252
- const zc = await startZeroCache(config, zeroInternalPort)
253
- zeroCacheProcess = zc.child
254
- await waitForZeroCache(config, undefined, zeroInternalPort)
255
- log.zero(`reset and restarted ${port(config.zeroPort, 'magenta')}`)
256
- },
242
+ restartZero: config.skipZeroCache
243
+ ? undefined
244
+ : async () => {
245
+ if (zeroCacheProcess && !zeroCacheProcess.killed) {
246
+ zeroCacheProcess.kill('SIGTERM')
247
+ await new Promise<void>((r) => {
248
+ const t = setTimeout(() => {
249
+ zeroCacheProcess?.kill('SIGKILL')
250
+ r()
251
+ }, 3000)
252
+ zeroCacheProcess!.on('exit', () => {
253
+ clearTimeout(t)
254
+ r()
255
+ })
256
+ })
257
+ }
258
+ const zc = await startZeroCache(config, zeroInternalPort)
259
+ zeroCacheProcess = zc.child
260
+ await waitForZeroCache(config, undefined, zeroInternalPort)
261
+ log.zero(`restarted ${port(config.zeroPort, 'magenta')}`)
262
+ },
263
+ resetZero: config.skipZeroCache
264
+ ? undefined
265
+ : async () => {
266
+ if (zeroCacheProcess && !zeroCacheProcess.killed) {
267
+ zeroCacheProcess.kill('SIGTERM')
268
+ await new Promise<void>((r) => {
269
+ const t = setTimeout(() => {
270
+ zeroCacheProcess?.kill('SIGKILL')
271
+ r()
272
+ }, 3000)
273
+ zeroCacheProcess!.on('exit', () => {
274
+ clearTimeout(t)
275
+ r()
276
+ })
277
+ })
278
+ }
279
+ const replicaPath = resolve(config.dataDir, 'zero-replica.db')
280
+ for (const suffix of ['', '-wal', '-shm', '-wal2']) {
281
+ try {
282
+ if (existsSync(replicaPath + suffix)) unlinkSync(replicaPath + suffix)
283
+ } catch {}
284
+ }
285
+ const zc = await startZeroCache(config, zeroInternalPort)
286
+ zeroCacheProcess = zc.child
287
+ await waitForZeroCache(config, undefined, zeroInternalPort)
288
+ log.zero(`reset and restarted ${port(config.zeroPort, 'magenta')}`)
289
+ },
257
290
  }
258
291
 
259
292
  const stop = async () => {
@@ -285,7 +318,18 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
285
318
  log.debug.orez('stopped')
286
319
  }
287
320
 
288
- return { config, stop, db, instances, pgPort: config.pgPort, zeroPort: config.zeroPort, logStore, zeroEnv, actions, httpLogStore }
321
+ return {
322
+ config,
323
+ stop,
324
+ db,
325
+ instances,
326
+ pgPort: config.pgPort,
327
+ zeroPort: config.zeroPort,
328
+ logStore,
329
+ zeroEnv,
330
+ actions,
331
+ httpLogStore,
332
+ }
289
333
  }
290
334
 
291
335
  function cleanupStaleLockFiles(config: ZeroLiteConfig): void {
@@ -364,7 +408,10 @@ function writeSqliteShim(): string {
364
408
  return registerPath
365
409
  }
366
410
 
367
- async function startZeroCache(config: ZeroLiteConfig, portOverride?: number): Promise<{ child: ChildProcess; env: Record<string, string>; stderrBuf: string }> {
411
+ async function startZeroCache(
412
+ config: ZeroLiteConfig,
413
+ portOverride?: number
414
+ ): Promise<{ child: ChildProcess; env: Record<string, string>; stderrBuf: string }> {
368
415
  // resolve @rocicorp/zero entry for finding zero-cache modules
369
416
  const zeroEntry = resolvePackage('@rocicorp/zero')
370
417
 
@@ -533,7 +580,7 @@ async function startZeroCache(config: ZeroLiteConfig, portOverride?: number): Pr
533
580
  async function waitForZeroCache(
534
581
  config: ZeroLiteConfig,
535
582
  timeoutMs = 120000,
536
- portOverride?: number,
583
+ portOverride?: number
537
584
  ): Promise<void> {
538
585
  const start = Date.now()
539
586
  const url = `http://127.0.0.1:${portOverride || config.zeroPort}/`
package/src/pg-proxy.ts CHANGED
@@ -694,7 +694,9 @@ export async function startPgProxy(
694
694
  // track active connections per database
695
695
  activeConns[dbName] = (activeConns[dbName] || 0) + 1
696
696
 
697
- console.info(`[orez-proxy#${connId}] connect db=${dbName} repl=${params.replication || 'none'}`)
697
+ console.info(
698
+ `[orez-proxy#${connId}] connect db=${dbName} repl=${params.replication || 'none'}`
699
+ )
698
700
 
699
701
  const { db } = getDbContext(dbName)
700
702
  await db.waitReady
@@ -133,8 +133,15 @@ export async function runMigrations(db: PGlite, config: ZeroLiteConfig): Promise
133
133
  continue
134
134
  }
135
135
 
136
+ const filePath = join(migrationsDir, file)
137
+ if (!existsSync(filePath)) {
138
+ // .ts-only custom migrations are handled by the app's own migration runner
139
+ log.debug.orez(`skipping migration (no .sql file): ${name}`)
140
+ continue
141
+ }
142
+
136
143
  log.debug.orez(`applying migration: ${name}`)
137
- const sql = readFileSync(join(migrationsDir, file), 'utf-8')
144
+ const sql = readFileSync(filePath, 'utf-8')
138
145
 
139
146
  // split by drizzle's statement-breakpoint marker
140
147
  const statements = sql
@@ -67,35 +67,6 @@ export async function installChangeTracking(db: PGlite): Promise<void> {
67
67
  $$ LANGUAGE plpgsql;
68
68
  `)
69
69
 
70
- // auto-install change tracking on tables created after startup (e.g. via restore
71
- // or wire protocol). uses a DDL event trigger that fires on CREATE TABLE.
72
- await db.exec(`
73
- CREATE OR REPLACE FUNCTION public._zero_auto_track() RETURNS event_trigger AS $$
74
- DECLARE
75
- obj record;
76
- BEGIN
77
- FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands()
78
- WHERE command_tag = 'CREATE TABLE'
79
- LOOP
80
- IF obj.schema_name = 'public'
81
- AND obj.object_identity NOT LIKE '%._zero_%'
82
- AND obj.object_identity NOT LIKE '%.migrations'
83
- THEN
84
- EXECUTE format(
85
- 'CREATE TRIGGER _zero_change_trigger AFTER INSERT OR UPDATE OR DELETE ON %s FOR EACH ROW EXECUTE FUNCTION public._zero_track_change()',
86
- obj.object_identity
87
- );
88
- END IF;
89
- END LOOP;
90
- END;
91
- $$ LANGUAGE plpgsql;
92
-
93
- DROP EVENT TRIGGER IF EXISTS _zero_auto_track_trigger;
94
- CREATE EVENT TRIGGER _zero_auto_track_trigger ON ddl_command_end
95
- WHEN TAG IN ('CREATE TABLE')
96
- EXECUTE FUNCTION public._zero_auto_track();
97
- `)
98
-
99
70
  // install triggers on all public tables
100
71
  await installTriggersOnAllTables(db)
101
72
  }
@@ -239,10 +210,29 @@ export async function installTriggersOnShardTables(db: PGlite): Promise<void> {
239
210
 
240
211
  if (result.rows.length === 0) return
241
212
 
213
+ // only track `clients` — that's the table zero-cache expects in the
214
+ // replication stream (needed for .server promise resolution). other shard
215
+ // tables like `replicas` are zero-cache internal state and streaming them
216
+ // back causes "Unknown table" crashes in zero-cache's change-processor.
242
217
  let count = 0
243
218
  for (const { nspname } of result.rows) {
219
+ // remove stale triggers from non-clients tables (from previous versions)
220
+ const stale = await db.query<{ event_object_table: string }>(
221
+ `SELECT DISTINCT event_object_table FROM information_schema.triggers
222
+ WHERE trigger_name = '_zero_change_trigger'
223
+ AND event_object_schema = $1
224
+ AND event_object_table != 'clients'`,
225
+ [nspname]
226
+ )
227
+ for (const { event_object_table } of stale.rows) {
228
+ const qs = quoteIdent(nspname)
229
+ const qt = quoteIdent(event_object_table)
230
+ await db.exec(`DROP TRIGGER IF EXISTS _zero_change_trigger ON ${qs}.${qt}`)
231
+ log.debug.pglite(`removed stale shard trigger from ${nspname}.${event_object_table}`)
232
+ }
233
+
244
234
  const tables = await db.query<{ tablename: string }>(
245
- `SELECT tablename FROM pg_tables WHERE schemaname = $1`,
235
+ `SELECT tablename FROM pg_tables WHERE schemaname = $1 AND tablename = 'clients'`,
246
236
  [nspname]
247
237
  )
248
238
 
@@ -12,7 +12,6 @@ import {
12
12
  getCurrentWatermark,
13
13
  purgeConsumedChanges,
14
14
  installTriggersOnShardTables,
15
- ensureChangeTrackingOnAllTables,
16
15
  type ChangeRecord,
17
16
  } from './change-tracker.js'
18
17
  import {
@@ -268,7 +267,9 @@ export async function handleStartReplication(
268
267
  ): Promise<void> {
269
268
  activeHandlerCount++
270
269
  const handlerId = activeHandlerCount
271
- console.info(`[orez-repl#${handlerId}] START_REPLICATION (active handlers: ${activeHandlerCount})`)
270
+ console.info(
271
+ `[orez-repl#${handlerId}] START_REPLICATION (active handlers: ${activeHandlerCount})`
272
+ )
272
273
  log.debug.proxy('replication: entering streaming mode')
273
274
 
274
275
  // send CopyBothResponse to enter streaming mode
@@ -292,9 +293,6 @@ export async function handleStartReplication(
292
293
  // "already in transaction" errors when they interleave.
293
294
  await mutex.acquire()
294
295
  try {
295
- // install change tracking triggers on any tables created after startup
296
- await ensureChangeTrackingOnAllTables(db)
297
-
298
296
  // install change tracking triggers on shard schema tables (e.g. chat_0.clients)
299
297
  // these track zero-cache's lastMutationID for .server promise resolution
300
298
  await installTriggersOnShardTables(db)
@@ -353,7 +351,7 @@ export async function handleStartReplication(
353
351
  for (const schema of relevantSchemas) {
354
352
  if (schema === 'public') continue
355
353
  const shardTables = await db.query<{ tablename: string }>(
356
- `SELECT tablename FROM pg_tables WHERE schemaname = $1`,
354
+ `SELECT tablename FROM pg_tables WHERE schemaname = $1 AND tablename = 'clients'`,
357
355
  [schema]
358
356
  )
359
357
  for (const { tablename } of shardTables.rows) {
@@ -463,7 +461,9 @@ export async function handleStartReplication(
463
461
  mutex.release()
464
462
  }
465
463
 
466
- console.info(`[orez-repl#${handlerId}] setup complete, starting poll (lastWatermark=${lastWatermark})`)
464
+ console.info(
465
+ `[orez-repl#${handlerId}] setup complete, starting poll (lastWatermark=${lastWatermark})`
466
+ )
467
467
 
468
468
  // track which tables we've sent RELATION messages for
469
469
  const sentRelations = new Set<string>()
@@ -492,8 +492,29 @@ export async function handleStartReplication(
492
492
  }
493
493
 
494
494
  if (changes.length > 0) {
495
- const tables = [...new Set(changes.map(c => c.table_name))].join(',')
496
- console.info(`[orez-repl#${handlerId}] found ${changes.length} changes [${tables}] (wm ${lastWatermark}→${changes[changes.length - 1].watermark}, type=${typeof changes[0].watermark})`)
495
+ // filter out shard tables that zero-cache doesn't expect.
496
+ // only `clients` is needed (for .server promise resolution).
497
+ // other shard tables (replicas, mutations) crash zero-cache
498
+ // with "Unknown table" in change-processor.
499
+ const batchEnd = changes[changes.length - 1].watermark
500
+ changes = changes.filter((c) => {
501
+ const dot = c.table_name.indexOf('.')
502
+ if (dot === -1) return true
503
+ const schema = c.table_name.substring(0, dot)
504
+ if (schema === 'public') return true
505
+ const table = c.table_name.substring(dot + 1)
506
+ return table === 'clients'
507
+ })
508
+
509
+ if (changes.length === 0) {
510
+ lastWatermark = batchEnd
511
+ continue
512
+ }
513
+
514
+ const tables = [...new Set(changes.map((c) => c.table_name))].join(',')
515
+ console.info(
516
+ `[orez-repl#${handlerId}] found ${changes.length} changes [${tables}] (wm ${lastWatermark}→${changes[changes.length - 1].watermark}, type=${typeof changes[0].watermark})`
517
+ )
497
518
  await streamChanges(
498
519
  changes,
499
520
  writer,
@@ -503,7 +524,7 @@ export async function handleStartReplication(
503
524
  excludedColumns,
504
525
  columnTypeOids
505
526
  )
506
- lastWatermark = changes[changes.length - 1].watermark
527
+ lastWatermark = batchEnd
507
528
 
508
529
  // purge consumed changes periodically to free wasm memory
509
530
  pollsSincePurge++
@@ -513,7 +534,9 @@ export async function handleStartReplication(
513
534
  try {
514
535
  const purged = await purgeConsumedChanges(db, lastWatermark)
515
536
  if (purged > 0) {
516
- console.info(`[orez-repl#${handlerId}] purged ${purged} changes (wm<=${lastWatermark})`)
537
+ console.info(
538
+ `[orez-repl#${handlerId}] purged ${purged} changes (wm<=${lastWatermark})`
539
+ )
517
540
  }
518
541
  } finally {
519
542
  mutex.release()
@@ -524,7 +547,9 @@ export async function handleStartReplication(
524
547
  const now = Date.now()
525
548
  if (now - lastIdleLog > 10000) {
526
549
  lastIdleLog = now
527
- console.info(`[orez-repl#${handlerId}] idle (lastWatermark=${lastWatermark}, type=${typeof lastWatermark})`)
550
+ console.info(
551
+ `[orez-repl#${handlerId}] idle (lastWatermark=${lastWatermark}, type=${typeof lastWatermark})`
552
+ )
528
553
  }
529
554
  }
530
555
 
@@ -550,7 +575,9 @@ export async function handleStartReplication(
550
575
  log.debug.proxy('replication: starting poll loop')
551
576
  await poll()
552
577
  activeHandlerCount--
553
- console.info(`[orez-repl#${handlerId}] poll loop exited (remaining handlers: ${activeHandlerCount})`)
578
+ console.info(
579
+ `[orez-repl#${handlerId}] poll loop exited (remaining handlers: ${activeHandlerCount})`
580
+ )
554
581
  }
555
582
 
556
583
  async function streamChanges(