npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@aria-framework/db-worker

v0.27.2

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 SQL

Plain 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. Pass 0 to disable.
    • queueWarnMs: the saturation signal — a call that executed fast but waited in the queue at least this long logs Slow DB queue (988ms wait, 5ms exec): Model.method. Catches a worker drowning in individually-fast queries. Pass 0 to disable.
    • onWorkerDeath(info): fired AT MOST ONCE on a non-deliberate post-boot worker death (never on close(), never for boot failures — those reject init()). 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 pragmas WAL / synchronous=NORMAL / foreign_keys=ON / busy_timeout=5000 and creates the migrations tracking table. onOpen(db) runs FIRST, before any pragma or table access — this is the SQLCipher hook: an encrypted DB needs pragma key before anything touches it.
  • runMigrations(migrationsDir, { connectionString } = {}) — applies *.sql sorted by filename, each in a transaction with its tracking-row insert; already-applied files skipped. A file can opt out — see below. connectionString runs the migrations as a different role — see next section.
  • getDb() / close()

Migrations as a separate owner role (0.20.0, Postgres)

In Postgres the role that creates a table owns it, and an owner bypasses ENABLE ROW LEVEL SECURITY. So an application that runs its own migrations owns every table it migrated, and a tenant boundary built on RLS then rests entirely on FORCE being set on every table, for ever — including the ones tomorrow's migration adds.

await database.initDriver({ kind: 'postgres', connectionString: process.env.DATABASE_URL });
await database.runMigrations(dir, { connectionString: process.env.MIGRATION_DATABASE_URL });

With connectionString, migrations run on a separate, short-lived connection as that role, and the application's own driver is never touched. That connection is destroyed afterwards, not returned to a pool: a pooled privileged connection that a later application query could draw would bypass every partner boundary silently — no error, no symptom, wrong rows.

The migrations ledger is created on that connection too. Created by the app role it would be one app-owned table in an otherwise correctly-owned schema, and one is enough.

Afterwards the runner asks the application's driver who it actually is and throws if it holds BYPASSRLS. Everything above is intent; that check is what makes it a fact.

On SQLite there are no roles, so the option is ignored — and says so in the log rather than being silently dropped.

Provisioning the roles is the consuming app's job. aria-starter ships scripts/test-pg-provisioning.js, which asserts the application role owns nothing and holds no BYPASSRLS, that every RLS table is also FORCEd, and that the backup role can actually read.

Migrations that cannot run inside a transaction (0.8.0)

Some DDL is illegal inside a transaction. A migration declares that with a marker line, which must be exactly this, on its own line, in the file's leading comment block:

-- aria:no-transaction

Why it exists, measured on both engines:

| engine | statement | inside a transaction | |---|---|---| | SQLite | the table-rebuild recipe (PRAGMA foreign_keys = OFF) | the pragma is a no-op, so dropping a referenced table fails with FOREIGN KEY constraint failed | | PostgreSQL | CREATE INDEX CONCURRENTLY | ERROR: cannot run inside a transaction block |

The trade: such a file is not atomic. A failure part-way leaves an intermediate state and leaves the ledger row unwritten, so the next boot runs it again. A no-transaction migration must therefore be idempotent. This is the same trade Rails makes with disable_ddl_transaction! and Django with atomic = False.

Three things that are easy to get wrong:

  • On PostgreSQL the file must contain exactly ONE statement. A multi-statement send still runs in an implicit transaction, so CREATE INDEX CONCURRENTLY still refuses. A table and its concurrent index belong in two migration files. The runner rewrites that failure into this instruction rather than leaving the raw error.
  • A failed CREATE INDEX CONCURRENTLY leaves an INVALID index behind. The file is unledgered, so the next boot re-runs it and hits "relation already exists" — a wedged boot. IF NOT EXISTS is worse: it skips silently and the invalid, planner-ignored index stays forever. Use a separate marked migration that runs DROP INDEX IF EXISTS <name>; first.
  • On SQLite, close every BEGIN the file opens. The runner rolls back a transaction left open and fails loudly, rather than letting later migrations silently join it.

A line that looks like the marker but is not exactly it (a trailing explanation, different case, aria: no-transaction) is a hard error, not a silent fall-back to the wrapped path. The marker is only recognised in the leading comment block, so one appearing inside a string literal or a function body does not strip that file's transaction.

Connection pragmas (journal_mode, synchronous, foreign_keys, busy_timeout) are re-asserted after any unwrapped file, so a migration that turns foreign keys off cannot leave them off for the process.

Tenant context (0.11.0)

Postgres row-level security reads its scope from session settings that live only as long as a transaction. Something has to put them there on every database call, and that something must not be an app: two copies of a security boundary means one of them drifts and nobody notices which. So the mechanism is here, and every consumer gets the same one.

const { runWithTenant, currentTenant } = require('@aria-framework/db-worker');

// middleware, main thread
app.use((req, res, next) => {
  runWithTenant({ scope: req.user.scopeKind, partnerId: req.user.partnerId }, next);
});

Three hops, because AsyncLocalStorage does not cross a worker thread:

  1. the app runs a request inside runWithTenant({ scope, partnerId }, fn)
  2. DbClient.invoke reads the context and puts it in the message
  3. runWorker re-establishes it, and on a pooled driver opens a transaction whose first statement is
    SELECT set_config('app.scope', $1, true), set_config('app.partner_id', $2, true)

scope is platform (sees every tenant) or partner (sees exactly one). A partner scope without a partnerId is refused — there is no safe default for "which tenant is this?", and a guess reads as working software until it returns somebody else's rows. A platform scope may carry no partner; it then cannot INSERT into a table whose partner_id defaults from the setting, which is correct — a platform user acting for a partner has to say which one.

On a pooled driver, EVERY invoke opens a transaction — reads included

This is not an oversight waiting to be optimised away. set_config(..., true) is transaction-local, and a pooled driver hands each transaction its own client: a statement run outside one lands on whichever client the pool happened to serve and is gone by the next. A read done that way carries no scope at all — which the strict policy turns into an error, but an unpredictable one, decided by which client came out of the pool. Making every invoke a transaction removes the question rather than answering it per call site. test/tenantContext.js holds it down.

On a serial driver (SQLite) there is no row-level security and nothing is set. The context is still established so a model can read it via currentTenant(), and no transaction is forced.

Migrations per engine (0.12.0)

A migration file may name the engine it is for. The runner picks; it never translates.

001_baseline.sql            runs on sqlite
001_baseline.postgres.sql   runs on postgres, and supersedes 001_baseline.sql there
002_records.sql             runs on both — no sibling, so it is dialect-neutral

The suffix is the driver kind, spelled exactly (sqlite, postgres). There is no pg alias on purpose: two names for one thing is how a migration ends up silently never running. A suffix outside that set (002_records.v2.sql) is just part of the name and runs everywhere.

Why the plain file keeps its name. The obvious symmetrical scheme — rename the baseline to 001_baseline.sqlite.sql — cannot be done. The ledger is keyed by FILENAME, and every database this framework has created has 001_baseline.sql in its migrations table. Renaming it makes the runner see an unapplied migration and re-run a baseline over live tables. The rule is additive for that reason and no other, and test/migrationDialect.js proves that adding a Postgres sibling does nothing to an already-migrated SQLite database.

This is selection, not a portable DDL layer. An app still writes its own schema per engine — the framework guarantees only which files ran and the transaction around each.

A baseline that covers earlier migrations (0.14.0)

A baseline written for a second engine already CONTAINS everything the earlier migrations did. It says so:

-- aria:covers-through 035

The runner then LEDGERS every file numbered at or below 035 instead of executing it. Without that, a fresh Postgres database runs 001_baseline.postgres.sql, builds the whole schema, and then hits 002_customer_team.sql — SQLite DDL — and fails having already changed the database.

A NUMBER, not a list: a list would have to be maintained in step with the directory, and the one thing worse than no marker is one that names files which have moved.

The marker is parsed BEFORE the file is applied, so a malformed one fails with the migration untouched. Like aria:no-transaction it is read from the HEADER only — a comment further down, or inside a multi-line string literal, is not a declaration about the file — and a near miss (aria: covers-through, aria:covers_through) is refused rather than ignored.

Date helpers on the dialect (0.15.0)

An app should say WHAT it wants from a date, not how one engine spells it.

| | sqlite | postgres | |---|---|---| | now() | datetime(now) | to_char(NOW() AT TIME ZONE UTC, …) | | nowMinusDays(n) | a retention cut | the same, as an INTERVAL | | nowPlusMinutes(n) | an expiry window; the SIGN comes from the number | INTERVAL | | startOf(month/year) | start of month | date_trunc on the UTC value | | dateGroup(col, month/week) | strftime | to_char | | daysBetween(a, b) | julianday difference | EXTRACT(EPOCH …) / 86400 |

daysBetween is RAW and unrounded on purpose: callers wrap it in AVG or SUM and multiply to the unit they want. A helper that rounded would not compose with either.

dateGroup and daysBetween cast to ::timestamp on Postgres — those columns are TEXT on both engines by SQLite heritage, and Postgres refuses date arithmetic on text.

ONE DIVERGENCE, ACCEPTED RATHER THAN HIDDEN: SQLite %W counts weeks from the first Monday, Postgres IW is the ISO week. They can disagree by one at a year boundary. The value is a GROUP BY key on a chart axis, and an ISO week is the more defensible thing to show a person.

partner_id → partners(id), and adopting it (0.24.0)

The frozen spec (ARIA-PARTNER-TENANCY §2) declares every scoped table's partner_id … REFERENCES partners(id). generateTenancyMigration did not emit that key before 0.24.0, so a row could outlive its partner or name one that never existed, and nothing refused it. It now emits, on Postgres, one fk_<table>_partner_id per scoped table, ON DELETE NO ACTION (the spec's bare REFERENCES, written out: a partner that still owns rows cannot be deleted). Nullable tables get it too — NULL still means "installation-wide". SQLite gets none.

An installation that already ran an older tenancy migration does not regenerate it (those files have run, and the ledger will not re-run them). It adds one new, Postgres-only file:

const { generatePartnerFkAdoption } = require('@aria-framework/db-worker');
const { files } = generatePartnerFkAdoption({ manifest, prefix: '041' });   // → 041_partner_fk.postgres.sql

Before changing anything it refuses, in one error each, while (a) any scoped row names a missing partner — listing every table with its count — or (b) a scoped table already has a partner key whose ON DELETE is anything but NO ACTION or RESTRICT (a CASCADE would delete a partner's data), naming table and constraint. Otherwise it adds each missing key NOT VALID and validates it in a separate statement. A key that already exists by shape — including one an app wrote inline, named <table>_partner_id_fkey — is kept, not duplicated. Running it again changes nothing.

The NOT VALID / VALIDATE split saves no lock time under this package's runner. The runner applies each file in ONE transaction, so the locks the ADDs take — on partners and on every scoped table — are held through every VALIDATE scan until commit, a large audit_log included. All the split buys is the option to run the file's statements apart by hand (steps 1–2, commit, then step 3) if a table is too large to hold those locks while it is scanned.

Run it as the migration role (BYPASSRLS). The count runs with row_security = off, which Postgres documents as raising — rather than filtering — for a role policies would apply to, so a role without BYPASSRLS gets an error, not a clean zero. test/tenancyPartnerFk.pg.js proves that only when ARIA_TEST_PG_NOBYPASS names such a role, and prints NOT RUN otherwise.

A scoped table added after adoption gets its key from the generator's ordinary output: every partner-key line generateTenancyMigration emits is one guarded, idempotent statement (refuse an unsafe existing key, keep a safe one, add ours only when there is none), so running those lines — or copying the new table's line into a hand-assembled migration — adds the new table's key and leaves every existing one untouched. A table created by hand instead should declare partner_id … REFERENCES partners(id) inline. Either way, an app check that reads pg_constraint for every table in scoped() is what keeps it true.

After adopting, a partner can no longer be deleted while it owns rows — test clean-ups that delete a partner first and its rows second will now fail, which is the point.

Composite keys are ON UPDATE CASCADE, and adopting it (0.25.0)

The frozen spec (ARIA-PARTNER-TENANCY §2) writes the child key as FOREIGN KEY (customer_id, partner_id) REFERENCES customers (id, partner_id) ON UPDATE CASCADE — "re-parenting a customer is one UPDATE; children follow atomically". Through 0.24.0 the generator emitted no ON UPDATE, so UPDATE customers SET partner_id was refused for any customer with a child row (and updating the children first fails the other way round). Every composite (column, partner_id) key now ends ON UPDATE CASCADE, beside its ON DELETE (whose SET NULL / SET DEFAULT keep their column list):

  • Every key, not a configured few — superseded in 0.26.0: ownership keys only; keys into the principal table are attribution and do not cascade (see the next section). The child's partner_id is a copy of its parent's; a copy that cannot follow freezes the parent's tenant once it has a child. CASCADE writes the parent's new value — the only one the key would accept — so a child can never land in a tenant its parent is not in. The statement must still satisfy every other composite key of every row it touched: a child whose second parent stays behind (a site pointing at another customer's contact) makes the whole move fail, and nothing moves.
  • Unlike SET DEFAULT, CASCADE reads no session state. ON UPDATE SET NULL / SET DEFAULT take no column list in Postgres, so either would write partner_id itself. A source key declaring one — or RESTRICT, which would silently be overridden — is refused at generate time, naming it.
  • Postgres documents that referential integrity bypasses row security, so the cascade should reach every child whatever the mover can see — one more reason the move must be platform-only. This package's tests run as a BYPASSRLS owner and do not exercise that.

An installation whose composite keys were generated before 0.25.0 adds one new, Postgres-only file; nothing applied is edited:

const { generateCompositeOnUpdateAdoption } = require('@aria-framework/db-worker');
const { files } = generateCompositeOnUpdateAdoption({ manifest, prefix: '044' }); // → 044_composite_on_update.postgres.sql

It reads the live catalogue at migration time and finds every composite key on a scoped table by shape (two columns, one of them partner_id referencing the parent's partner_id), whatever it is called. Before changing anything it refuses — naming every table and constraint — a key with ON UPDATE RESTRICT / SET NULL / SET DEFAULT, or an ON DELETE SET NULL / SET DEFAULT that writes partner_id (no column list): CASCADE is never added beside anything that can re-home a row. Then it re-adds each ON UPDATE NO ACTION key under the same name in one DROP CONSTRAINT x, ADD CONSTRAINT x — the definition is Postgres's own pg_get_constraintdef() with ON UPDATE CASCADE inserted, so ON DELETE (and its column list), MATCH, DEFERRABLE and NOT VALID survive — and finally raises if any key is still not CASCADE. A key already CASCADE is untouched: running it again changes nothing. Each re-add re-validates its key (a scan of the child under the ALTER's lock).

test/tenancyOnUpdate.pg.js (needs ARIA_TEST_PG) drives it: a two-level chain with a diamond (sites → contacts → customers and sites → customers, work items under sites) moved in one UPDATE; every way of moving a row into a tenant its parent is not in, refused with nothing changed; and the adoption on the old shape, its refusal, and its idempotence by constraint oid. As of the 0.25.0 commit it has not been run against a server — see the commit message.

Cascade is for ownership, not attribution (0.26.0)

0.25.0 over-reached. It cascaded every composite key, including keys into the principal table: api_keys.created_by, records.owner_id, notifications.user_id, the token tables. A user's partner is the user's identity, not a container their rows live in. Through 0.24.0, UPDATE users SET partner_id = 2 WHERE id = 50 was refused. With 0.25.0 it succeeded and carried partner 1's API keys and records into partner 2, because referential actions bypass RLS. An integration key that belonged to partner 1 then read partner 2's data.

The rule from 0.26.0:

  • Ownership keys cascade. A row whose parent is another tenant row follows that parent's tenant: re-parenting a customer is still one UPDATE.
  • Attribution keys do not. A key into the principal table (principalTable, default users), or one the manifest lists in NO_CASCADE: ['table.column'], carries no update action. Moving a user who owns or authored rows is refused, and the operator deals with those rows first. A NO_CASCADE entry that matches no composite key is refused, because a typo would leave the key cascading.

generateCompositeOnUpdateAdoption now converges in both directions. Ownership keys with no update action gain CASCADE (the pre-0.25 path, as before). Attribution keys that cascade lose it (the 0.25.0 repair). The proof step checks both directions, and a key already on the right side is untouched. An installation that ran 0.25.0's adoption regenerates it with 0.26.0 under a new prefix and runs it. It takes the same principalTable option as the generator, and reads the manifest's NO_CASCADE.

test/tenancyAttribution.pglite.js proves it on PostgreSQL, using PGlite (Postgres in WebAssembly, in-process, a devDependency). Unlike the .pg suites, it runs on every npm test:

  • a fresh 0.26 schema refuses the user move and still cascades a customer move;
  • a 0.25 install first reproduces the leak, then the adoption repairs it, idempotent by constraint oid;
  • a pre-0.25 install gains CASCADE on ownership keys only;
  • a NO_CASCADE typo is refused at migration time.

Against 0.25.0's code, 6 of its 10 checks fail.

Changelog

  • 0.27.2 — a FAILED MIGRATION REJECTS runMigrations again on the Postgres migration-connection path. Its finally did return after logging "Skipping the BYPASSRLS proof", and a return in a finally discards the exception in flight: runMigrations resolved, the app booted on a half-migrated schema, and the next migrations never ran (seen: a renumbered migration failing on every boot; the symptom was a 500 three migrations later). Now the migration's own error propagates and the migration connection is still closed. test/migrationFailureSurfaces.js (fake drivers, no server) fails against 0.27.1.

  • 0.27.1 — the UTC pin is WAITED FOR. 0.27.0 fired SET TIME ZONE 'UTC' from the pool's connect event without awaiting it, so the first query on every new connection was issued while the SET was still running: pg 8 prints "Calling client.query() when the client is already executing a query is deprecated" (seen on an app at startup), and pg 9 makes it a failed query. The SET now runs in pg-pool's awaited onConnect hook — installed on the pool's options, so pinPoolUtc covers a pool the app built too; an onConnect already there still runs, after the pin. A pg-pool without the hook falls back to the old connect path. Behaviour change: a SET that fails now fails that checkout (the query rejects, the client is discarded, the error is logged) instead of handing out an unpinned session. test/pinAwaited.pg.js (N concurrent queries on N fresh connections: no overlap, every connection UTC) and test/pinPoolUtc.js (wiring) — against 0.27.0, 4 of 15 and 5 of 6 fail.

  • 0.27.0 — "Time is UTC" (repo README): a Postgres session and a Date on the wire no longer depend on the server's, the role's, the connection string's or the process's timezone.

    • Every connection the driver opens runs SET TIME ZONE 'UTC' first — the app pool and the short-lived migration connection. 'now'::timestamp, CURRENT_DATE, date_trunc on a timestamptz and a timestamp compared with a timestamptz all resolve in the session zone; it is now always UTC. SET rather than options=-c TimeZone=UTC because a connection string's own options= silently overrides one passed beside it.
    • pinPoolUtc(pool) is exported for a pg Pool the app builds itself.
    • PROCESS-WIDE node-postgres changes (one parser table per process, like the int8 parser): a Date PARAMETER is sent as UTC (parseInputDatesAsUTC) where it was sent with the process's local offset — which a timestamp column discards, storing the host's wall clock; a timestamp column is READ as UTC where it was read as local; a date column is read as the string 'YYYY-MM-DD' where it was a Date at local midnight. An app that relied on a date arriving as a Date must parse it.
    • test/timezone.pg.js: four process zones, against a connection string that asks for America/Chicago, temp tables only. 26 of its 37 checks fail on 0.26.0.
  • 0.26.0 — composite keys into the principal table (and any in NO_CASCADE) no longer cascade; generateCompositeOnUpdateAdoption converges both ways and repairs a 0.25.0 install. stats.attributionKeys (generator) and stats.noCascade (adoption). PGlite devDependency; test/tenancyAttribution.pglite.js in npm test.

  • 0.25.0 — every composite tenancy key is ON UPDATE CASCADE, as the frozen spec's §2 always said; a source FK with ON UPDATE RESTRICT / SET NULL / SET DEFAULT is refused at generate time. generateCompositeOnUpdateAdoption for installations whose keys predate it (above). Regenerated output changes: every composite-key line gains ON UPDATE CASCADE.

  • 0.24.0 — partner_id → partners(id) from the generator, and generatePartnerFkAdoption (above). stats.partnerFks added to the generator's result. The generator's partner-key lines are guarded, idempotent statements; both paths refuse an existing partner key with an unsafe ON DELETE.

  • 0.15.0 — nowPlusMinutes, startOf, dateGroup, daysBetween (above).

  • 0.14.0 — -- aria:covers-through NNN (above).

  • 0.13.0 — the Postgres driver had the sibling savepoint defect too. Two nested transactions started together on one client destroyed each other, and the failed RELEASE aborted the transaction — so the outer COMMIT became a silent ROLLBACK and the caller was told it had succeeded having written nothing. Fixed the same way as SQLite: one API per nesting level, each owning its children's queue.

  • 0.12.0 — dialect-selected migration files (above).

  • 0.11.0 — the tenant-context contract above: runWithTenant() / currentTenant(), serialised across the worker boundary, applied as one set_config statement inside a per-invoke transaction on pooled drivers. Fail-closed when a pooled driver gets no context.

  • 0.10.0 — a transaction that merely OVERLAPS another is not nested inside it. Nesting is tracked with AsyncLocalStorage (the call stack) instead of a counter on the connection, and a top-level transaction queues behind any already open. Before this, the second of two overlapping transactions was rejected while its writes stayed committed, and a failed BEGIN leaked the depth counter for the life of the connection. Siblings queue too: two nested transactions started together opened two savepoints, and releasing the outer released the inner.

  • 0.8.0 — -- aria:no-transaction (above). Also: the SQLite path now rolls back a transaction an unwrapped file left open, and restores the connection pragmas afterwards — without that, one failing marked migration left foreign_keys = 0 and the next insert of an orphan row was accepted silently, for the life of the process.

  • 0.7.0 — dialect.ciEquals() for case-insensitive equality (ilike() is a pattern operator: role LIKE 'read_only' matched read-only). better-sqlite3 is now loaded lazily, so a Postgres-only app can load this package at all. Transaction depth moved onto the connection, so two drivers over one handle no longer both issue BEGIN. The pg Pool gets an 'error' listener; host/user/database options actually reach it; jsonb ?/?|/?& and @@ survive placeholder rewriting.

  • 0.6.1 — nested transactions via savepoints.

  • 0.6.0 — driver contract (SQLite + PostgreSQL, selected per app), dialectFor(), driver-aware migration runner.

  • 0.4.2 — sanitizeForTransfer passes Map/Set through 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), _closing guard 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) queueWarnMs saturation 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) onWorkerDeath policy 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 (execMs shipped in each response), so calls queued behind a slow query no longer log under their own names; the message shows exec and total. (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: DbClient warn-logs any invoke at or over slowMs (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's init() callback; migrations dir is an argument; DbClient is a class (apps subclass + instantiate); new onOpen hook absorbs Acc101's SQLCipher-key variant.