@octabits-io/postgres
The seam the Postgres-native building blocks share: SqlExecutor —
query(text, params) resolving to { rows }, satisfied as-is by a pg
Pool or Client and a PGlite instance, and by postgres.js through
postgresJsExecutor. And the Postgres error classification: a thrown driver
error — bare, or wrapped by an ORM — turned into an OctDatabaseError value,
with isUniqueViolation for the question most stores ask. And isoTimestamp,
the timestamptz column that reads back as ISO-8601 in UTC, and
drizzleExecutor, a Drizzle database or transaction as the seam (./drizzle). No
driver import, no HTTP framework, and Drizzle only behind ./drizzle.
npm i @octabits-io/postgres # nothing else — bring the executor you already have
import postgres from 'postgres';
import { postgresJsExecutor } from '@octabits-io/postgres';
const db = postgresJsExecutor(postgres(process.env.DATABASE_URL)); // or a pg Pool / a PGlite, as-is
| Subpath | Deps | Contents |
|---|---|---|
. |
none | SqlExecutor, the one seam every store shares, and postgresJsExecutor for postgres.js; the error classification — withDbErrorHandling, handleTransactionError, TransactionRollbackError, extractPgError, isUniqueViolation, PG_ERROR_CODE_MAP, OctDatabaseError, PostgresErrorCode, ExtractedPgError; isoInstant, a timestamptz value as ISO-8601 in UTC |
./drizzle |
drizzle-orm (optional peer) |
isoTimestamp, the timestamptz column that reads back as ISO-8601 in UTC — what every platform column set declares; isoInstant; drizzleExecutor, a Drizzle database or transaction as a SqlExecutor |
The whole package type-checks with no Node typings and imports no driver. A boundary lint keeps it that way.
Moved on 2026-09-13
The dead-letter audit sink waited here until the queue had an offer package of its own. It has one now, so the sink lives beside the queue it audits:
| Was | Is |
|---|---|
@octabits-io/postgres/job-audit-store |
@octabits-io/queue (the record, the JobAuditStore contract and its scoping rules, the in-memory store) and @octabits-io/queue/postgres (createPostgresJobAuditStore, jobAuditDdl) |
./job-audit-store re-exported every name it had for one minor and was
removed at the next one, together with this package’s dependency on
@octabits-io/queue. Import the queue package directly.
Moved on 2026-09-10
A package is a capability a developer asks for by name; a subpath is what they already run. Three modules here were cut the other way — by dependency — and have gone to the packages whose capability they are. Each old subpath stayed as a named re-export shim for one minor and was removed on 2026-09-12: install the package and import it directly.
| Was | Is |
|---|---|
@octabits-io/postgres/idempotency |
@octabits-io/server/idempotency and …/idempotency/postgres — the Idempotency-Key header is an HTTP concern |
@octabits-io/postgres/backfill |
@octabits-io/backfill, the marker stores on …/backfill/postgres and …/backfill/drizzle — a contract, a plain-SQL store and a Drizzle adapter is a capability, not someone else’s subpath |
@octabits-io/postgres/rls |
@octabits-io/rls, with the Prisma runtime on @octabits-io/rls/prisma |
When not to use it
- Another database. The seam is shaped for Postgres drivers (
$1placeholders); a MySQL or SQLite client needs its own adapter, and none of the stores that take this seam speak another dialect. - A queue or its audit trail. That is
@octabits-io/queue. - An ORM seam. Each capability ships its Drizzle adapter on its own
./drizzlesubpath; this package’s./drizzleis one column type and the executor bridge, not a store.
A Drizzle transaction as the seam
Every plain-SQL store writes on the executor it is handed. On a Drizzle host,
drizzleExecutor(tx) is the transaction you are in, as that executor: the
store’s statement runs on the transaction’s connection and commits or rolls
back with your own writes.
import { drizzleExecutor } from '@octabits-io/postgres/drizzle';
await db.transaction(async (tx) => {
await tx.update(order).set({ status: 'shipped' }).where(eq(order.id, id));
await outbox.append(envelope, drizzleExecutor(tx));
});
$1-style placeholders become bound parameters, one per placeholder, an array
included; the result is { rows } whatever the driver returns.
Timestamps that read back as ISO-8601
Drizzle’s timestamp(name, { withTimezone: true, mode: 'string' }) hands a
timestamptz back in Postgres’s text form, in the session’s time zone
(2026-10-09 09:00:00+02) — which luxon’s fromISO and a browser’s Date
refuse. isoTimestamp is the same column read back as ISO-8601 in UTC
(2026-10-09T07:00:00.000Z); the type in the database does not change, so
swapping one for the other generates no migration:
import { sql } from 'drizzle-orm';
import { pgTable, uuid } from 'drizzle-orm/pg-core';
import { isoTimestamp } from '@octabits-io/postgres/drizzle';
export const note = pgTable('note', {
id: uuid().primaryKey().defaultRandom(),
createdAt: isoTimestamp('created_at').default(sql`now()`).notNull(),
doneAt: isoTimestamp('done_at'),
});
Write an ISO string (or sql`now()` ). Every platform column set —
activity, agent-ledger, events, pii, queue, server/idempotency —
declares its timestamps with it. isoInstant(value) is the conversion alone,
for a value read some other way: it takes the text form with any offset
(+02, +05:30, +00:53:28), an ISO string or a Date, cuts microseconds
to milliseconds, and hands infinity back as it was.
The executor
Every store takes db: SqlExecutor — query(text, params) resolving to
{ rows }. A pg Pool or Client and a PGlite instance satisfy it as-is.
postgres.js resolves to the rows directly, so postgresJsExecutor(sql) wraps
it:
import postgres from 'postgres';
import { postgresJsExecutor } from '@octabits-io/postgres';
const db = postgresJsExecutor(postgres(process.env.DATABASE_URL));
Because the host owns the executor, the host owns the connection semantics:
hand a store an executor that first set the request’s row-level-security
setting (@octabits-io/rls builds that statement), and
every row it reads or writes is that request’s.
The type is three lines of structure, so a package that needs one declares its
own copy rather than taking a dependency on this package — that is why
@octabits-io/server/idempotency/postgres, @octabits-io/backfill/postgres
and @octabits-io/data-lifecycle/postgres each have a SqlExecutor of their
own, identical in shape. This root is where the shape and the postgres.js adapter
live. The error classification below is different: it is behaviour, not
structure, and it is the part of this package worth importing.
Error classification
A Postgres driver throws; a store here returns a Result. The root turns one
into the other. Every driver — pg, PGlite, postgres.js — throws an error with
code set to the five-character SQLSTATE and, for a constraint violation,
the violated constraint’s name — pg and PGlite call the field constraint,
postgres.js constraint_name; both are read. An ORM wraps that error (Drizzle puts it on
.cause, and a service may wrap Drizzle’s), so extractPgError walks the
cause chain — ten levels at most, which also ends a cyclic chain — and
returns the first SQLSTATE it finds with its constraint and message.
| SQLSTATE | code |
|---|---|
23505 |
unique_violation |
23503 |
foreign_key_violation |
23502 |
not_null_violation |
23514 |
check_violation |
23P01 |
exclusion_violation |
42501 |
insufficient_privilege (row-level security refusals included) |
40001 |
serialization_failure |
40P01 |
deadlock_detected |
55P03 |
lock_not_available |
57014 |
query_canceled |
| any other SQLSTATE | unknown — still an OctDatabaseError, the SQLSTATE is in the message |
A Node system error is rethrown, not mapped. ECONNREFUSED, ETIMEDOUT
and their kind carry a code too, but only a code of the SQLSTATE shape (five
uppercase alphanumerics) counts. A refused connection says the database was
never reached, not that it refused the statement: that is an infrastructure
failure, and turning it into a database_error value would let a caller treat
an outage as a conflict. It propagates as the exception it is, to the retry,
the health check or the 500. The shape test is stricter than “five uppercase characters”: EPIPE, EPERM and their kin are five letters too, and no SQLSTATE class begins with E, so a leading E is read as the errno namespace.
import { err, ok, type Result } from '@octabits-io/result';
import {
handleTransactionError,
isUniqueViolation,
TransactionRollbackError,
withDbErrorHandling,
type OctDatabaseError,
} from '@octabits-io/postgres';
// A Result passes through; a Postgres error becomes err(OctDatabaseError); anything else rethrows.
async function rename(id: string, name: string): Promise<Result<void, OctDatabaseError>> {
return withDbErrorHandling(async () => {
await db.query('UPDATE item SET name = $1 WHERE id = $2', [name, id]);
return ok(undefined);
});
}
// The error's message keeps the diagnosis an ORM wrapper drops:
// '[23505 duplicate key value violates unique constraint "item_name_key"] Failed query: …'
// A typed error that must roll a transaction back survives the rollback.
try {
await db.transaction(async (tx) => {
const debited = await debit(tx, amount);
if (!debited.ok) throw new TransactionRollbackError(debited.error);
});
} catch (error) {
return handleTransactionError(error); // the typed error, a database_error, or a rethrow
}
// The one question most stores ask, with or without the constraint's name.
if (isUniqueViolation(error, 'account_email_key')) return err({ key: 'already_registered', message: '…' });
The names and their behaviour were copied from the house stack’s Drizzle error
mapper, so code written against it moves over by an import path;
isUniqueViolation is this package’s addition.
Stability
Pre-1.0: patches never break, minors say so in the changelog. SqlExecutor
is three lines, and it stays three lines.
From @octabits-io/postgres@0.8.1 in the platform repository.