@aria-framework/db-worker
v0.7.0
Published
Aria App Framework — db-worker module. Async RPC bridge over synchronous better-sqlite3: a worker-thread dispatcher (runWorker), a main-thread client with per-call timeouts and crash handling (DbClient), and a SQLite layer with WAL pragmas + a numbered-mi
Readme
@aria-framework/db-worker
Aria App Framework — db-worker module. The async RPC bridge over synchronous
better-sqlite3: all SQLite work runs in a worker thread so a query can never
block the main (Express) event loop, and routes get a plain
await db.invoke('Model', 'method', ...args).
Main thread Worker thread
DbClient.invoke('Ticket','create',…) ─► runWorker dispatch → models.Ticket.create(…)
result ◄───────────────── database.getDb() runs the SQLPlain CommonJS, no build step. better-sqlite3 is a peer dependency.
What the app keeps (three thin files)
Node spawns a worker from a file path, and the model registry + caches are app-specific — so the app keeps thin wrappers and the package owns the ~350 lines of lifecycle/RPC/migration machinery.
1. Worker entry (lib/db-worker.js) — boot + registry:
const path = require('path');
const { runWorker, database } = require('@aria-framework/db-worker');
runWorker({
init(workerData) {
database.init(workerData.dbPath, { logger });
database.runMigrations(path.join(__dirname, '..', 'migrations'));
// app-specific extras (e.g. wire field encryption from workerData.fieldsKey)
return {
Ticket: require('../models/Ticket'),
User: require('../models/User')
// ... every model reachable via invoke()
};
}
});2. Client singleton (lib/db-client.js) — subclass for app caches:
const path = require('path');
const { DbClient } = require('@aria-framework/db-worker');
class AppDbClient extends DbClient {
// add synchronous caches here (values hot paths read per-request)
}
module.exports = new AppDbClient({
workerPath: path.join(__dirname, 'db-worker.js'),
logger
});3. Database wrapper (lib/database.js) — so models keep requiring
../lib/database and the migrations dir is configured once:
const path = require('path');
const { database } = require('@aria-framework/db-worker');
module.exports = {
init: (dbPath) => database.init(dbPath, { logger }),
runMigrations: () => database.runMigrations(path.join(__dirname, '..', 'migrations')),
getDb: database.getDb,
close: database.close
};Boot: await dbClient.init({ dbPath, ...anythingTheWorkerNeeds }) — resolves
once the worker has opened the DB and run migrations.
API
DbClient (main thread)
new DbClient({ workerPath, logger?, callTimeoutMs? = 30000, slowMs? = 500, queueWarnMs? = 1000, onWorkerDeath? })slowMs: any invoke whose worker-side EXECUTION takes at least this long is warn-logged —Slow DB call (612ms exec, 1840ms total): Ticket.queue— attributing slowness to the query that was slow, not to calls queued behind it. Pass0to disable.queueWarnMs: the saturation signal — a call that executed fast but waited in the queue at least this long logsSlow DB queue (988ms wait, 5ms exec): Model.method. Catches a worker drowning in individually-fast queries. Pass0to disable.onWorkerDeath(info): fired AT MOST ONCE on a non-deliberate post-boot worker death (never onclose(), never for boot failures — those rejectinit()). Recommended policy:process.exitCode = 1+ trigger your graceful shutdown so a supervisor restarts the service.
init(workerData)→ Promise; rejects on worker boot failure (init-error)invoke(model, method, ...args)→ Promise. Args/results must be structured-clone serializable. Per-call timeout; on worker crash/exit ALL in-flight calls reject (nothing hangs). Custom error props set by models (code,status, entity ids…) survive the thread boundary.close()— rejects in-flight calls, terminates the worker quietly.
runWorker({ init }) (worker entry)
init(workerData) does all boot and returns the model registry. Any model the
app calls via invoke MUST be in the registry ("Unknown model" otherwise).
Model methods may be sync or async (thenables are awaited). BigInts in results
(better-sqlite3 lastInsertRowid) are converted to Number.
database (worker side, singleton per thread)
init(dbPath, { logger?, onOpen? })— creates the directory, opens the DB, then applies pragmasWAL / synchronous=NORMAL / foreign_keys=ON / busy_timeout=5000and creates themigrationstracking table.onOpen(db)runs FIRST, before any pragma or table access — this is the SQLCipher hook: an encrypted DB needspragma keybefore anything touches it.runMigrations(migrationsDir)— applies*.sqlsorted by filename, each in a transaction with its tracking-row insert; already-applied files skipped.getDb()/close()
Changelog
- 0.4.2 — sanitizeForTransfer passes
Map/Setthrough untouched (like Date/Buffer/typed arrays — structured clone handles them natively; the generic object branch would silently mangle them to{}). Latent-bug guard from an Acc101 review: no current consumer returns one across the boundary, but the first future model method that does now works instead of delivering empty data with no error signal. - 0.4.1 — third-review fixes (all in the 0.4.0 additions). (1) Boot-crash suppression latched: a hard boot crash emits 'error' AND 'exit'; the exit handler no longer fires the death policy after the error handler consumed the boot state. (2) init() resets the death latch and closing flag, so the respawn pattern works (worker #2's death notifies again) and close()+re-init doesn't inherit stale flags. (3) Queue warnings coalesce: first occurrence logs, then at most one summary line per 10s with the suppressed count — saturation no longer floods the log it's reporting into; wording clarifies the wait includes IPC/event-loop overhead, not pure queue time. (4) Sanitizer memoizes shared references (obj → sanitized node): linear time on diamond-shaped graphs AND sharing is preserved through structured clone; true cycles still throw via a separate ancestor set.
- 0.4.0 — second-review fixes. (1) sanitizeForTransfer tracks the ancestor
PATH (unwind delete), so shared/diamond references in results are legal
again — only true cycles throw; the error path is also guarded so an
unserializable custom error prop degrades to message+stack instead of
hanging the call. (2) Death notification hardened: at-most-once latch
('error' + 'exit' both fire for one crash),
_closingguard on the error handler (a teardown-race error no longer turns a clean shutdown into a crash exit), and boot failures reject init() instead of firing the policy. (3)queueWarnMssaturation warning — fast-exec calls with long queue waits now log, closing the observability hole the exec-only gate opened. (4) execMs uses a monotonic clock (perf_hooks), immune to NTP steps. - 0.3.0 — three review-driven fixes. (1)
onWorkerDeathpolicy hook: called after a non-deliberate worker death; without a policy the process keeps running with every invoke() rejecting — a silent outage supervisors can't see. Recommended app policy: log +process.exit(1). (2) Slow-call timing moved into the worker: the threshold now applies to execution time (execMsshipped in each response), so calls queued behind a slow query no longer log under their own names; the message showsexecandtotal. (3) Sanitizer hardened: Date/Buffer/typed arrays pass through untouched (structured clone handles them; recursing mangled them), and circular result graphs throw a clear error instead of overflowing the stack. - 0.2.0 — slow-call detection:
DbClientwarn-logs any invoke at or overslowMs(default 500ms, 0 disables) with duration +Model.method. Turns performance work measurement-driven — add the index when a real query names itself, not speculatively. - 0.1.0 — first release. Extracted from Support101/Acc101
lib/{database,db-client,db-worker}.js. Changes vs the app originals: model registry + boot moved to the app'sinit()callback; migrations dir is an argument;DbClientis a class (apps subclass + instantiate); newonOpenhook absorbs Acc101's SQLCipher-key variant.
