Skip to content

V3#26

Merged
Mikhus merged 9 commits into
masterfrom
v3
Jul 10, 2026
Merged

V3#26
Mikhus merged 9 commits into
masterfrom
v3

Conversation

@SerhiyGreench

Copy link
Copy Markdown
Contributor

No description provided.

- native ES modules: type=module, exports map, explicit .js import
  extensions, engines node >=22.12.0
- TypeScript 7 (tsgo) with es2024 target, nodenext resolution,
  verbatimModuleSyntax and explicit types: [node]
- tests migrated from mocha/chai/sinon/mock-require/nyc to node:test:
  module mocks (pg, constants) preloaded via --import, sinon replaced
  with a small spy shim over node:test mock that patches own properties
  (so spying inherited methods does not leak through prototypes)
- shutdown specs made deterministic: keep-alive timer for async signal
  delivery (Node 22 cancellation), exit timers drained into the stub
- LISTEN/UNLISTEN/NOTIFY specs now really assert (they were no-op
  expect() calls before) and scan all query calls
- ambient event declarations imported as type-only (required at runtime
  under ESM); farmhash imported via default import (native CJS addon)
- lint/format switched from eslint to oxlint + oxfmt; devDeps reduced
- version bumped to 3.0.0
- version back to the published 2.0.0: the migration commit had pre-set
  3.0.0, which would make 'npm version major' skip to 4.0.0
- uuid -> crypto.randomUUID for the pg client application name
- farmhash -> one-shot node:crypto hash truncated to 64 bits (16 hex
  chars) in signature(): same key space as fingerprint64 without the
  native addon; note signatures change value, so mixed-version
  instances sharing an execution lock disagree until fully rolled out
- runtime deps are now just pg and pg-format
- lint warnings resolved: bare catch for unused bindings, side-effect
  spies without dead bindings, underscore-prefixed unused mock param,
  mock logger printing via console.info
pg ^8.22.0, @types/pg ^8.20.0, @types/node ^24.13.3, oxlint 1.73.0,
oxfmt 0.58.0. @types/node stays on the 24.x line: types newer than the
supported runtime floor would let unavailable APIs compile.
Runs test-lcov (with comment-line stripping) and renders the report
with genhtml into coverage/html.
Reconnect (was broken with the real driver):
- pg clients are single-use; reconnect now recreates the client, rewires
  handlers and re-creates channel locks against the new connection
- a single connection loss fires both 'error' and 'end': reconnect
  arming is now guarded, so two client recreations cannot race
- close() cancels a pending reconnect timer (it would resurrect the
  instance after destroy)

Exactly-once execution locks (duplicate processing was possible):
- processed messages leave their unique lock row in place as a marker;
  deleting it right after processing let a slower competing listener
  re-insert and reprocess the same message (caught by the integration
  suite: first=[0] second=[1,2,3,4,0])
- markers expire by TTL (UNIQUE_LOCK_TTL, 1h), cleanup rides along the
  next unique-lock acquisition round-trip; unique lock table gains a
  created_at column (auto-recreated when the column is missing)
- release() never deletes a row the instance does not hold

Resource ownership:
- destroy() is instance-scoped: it no longer sweeps the process-global
  lock registry killing locks of other PgPubSub instances
- unlistenAll()/release() dispose local lock resources (acquire timers,
  notification listeners) - they leaked before and kept the event loop
  alive; PgIpLock.destroy() no longer corrupts the instance registry
  when called twice (splice(-1) removed the wrong lock)
- notification handlers guard against racing destroy() and no longer
  create locks for messages they are about to skip

API-visible changes:
- signal handling is opt-in: enableGracefulShutdown() or
  handleSignals: true (importing the package no longer installs
  process-wide SIGINT/SIGTERM/SIGABRT handlers that call process.exit)
- 'error' events carry the actual error; without an 'error' listener
  errors go to the logger instead of crashing the process; appName and
  DDL bootstrap failures are logged loudly instead of swallowed
- notify() rejects payloads above the 8000-byte postgres limit with a
  clear RangeError instead of a server error

Testing:
- new test:integration script runs flow tests against a real postgres
  (PG_TEST_DSN): pub/sub delivery, single-listener exclusivity and
  handover, server-side termination -> reconnect -> delivery, competing
  execution-lock listeners exactly-once, destroy isolation and payload
  limit; CI gains a postgres-service integration job (Node 22/24)
- unit specs updated for instance-scoped destroy and client recreation
  (mock behavior now patched on the prototype)
- timers/events imports now use the node: prefix
- test mock registration no longer uses import * as: the pg mock forwards
  a { Client } object (the only runtime export consumed from pg), and the
  constants mock namespace is captured with a dynamic import (its module
  re-exports the real constants via export *, so enumerating members by
  hand would silently drop future additions)
The CI integration run flaked on the exactly-once exec-lock test: with a
cold database, listen() in execution-lock mode does not create a lock, so
the schema bootstrap ran lazily inside per-message handlers. A burst of
concurrent handlers across fresh connections raced the DDL - including a
destructive DROP TABLE on a column-mismatch check - and an INSERT could
hit 'relation does not exist', so a message was skipped by every listener
and lost.

- schema bootstrap is now single-flight per connection: concurrent locks
  on the same client await one shared bootstrap (WeakMap keyed by client,
  so a recreated client on reconnect bootstraps afresh) instead of each
  running the ddl
- bootstrap retries transient concurrent-catalog failures so the
  idempotent IF NOT EXISTS ddl converges between competing connections;
  a persistent failure (e.g. missing ddl privileges) is logged and
  rethrown, and the exec-lock handler now skips the message via emitError
  instead of raising an unhandled rejection
- lock table creation is non-destructive: the DROP TABLE migration blocks
  are gone. The dedicated unique schema only needs an idempotent
  ALTER TABLE ADD COLUMN IF NOT EXISTS created_at; the channel schema
  never holds a foreign shape (separate _unique suffix), so a plain
  CREATE TABLE IF NOT EXISTS is safe under concurrency

Verified: exactly-once holds across 3 cold-start listeners for 50
concurrently-published messages (no loss, no duplicates), and the
integration suite passes 5x against a freshly-dropped schema.
The published tarball was shipping the oxlint/oxfmt configs and the
coverage post-processing script; exclude scripts/ and the tool configs
so it contains only LICENSE, README, package.json and the compiled
output, matching the other @imqueue packages.
@SerhiyGreench SerhiyGreench requested a review from Mikhus July 10, 2026 18:25

@Mikhus Mikhus left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks amazing

@Mikhus Mikhus merged commit 54d486c into master Jul 10, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants