@dannyfuf/persistence
v0.1.0
Published
Active Record-inspired persistence layer over Knex for PostgreSQL.
Maintainers
Readme
Persistence
Persistence is a small Active Record-inspired TypeScript layer over Knex for PostgreSQL. The current MVP runtime includes generated schema consumption, hydrated reads, lifecycle-aware saves, explicit transactions, and commit callbacks.
Installation
npm install @dannyfuf/persistence knex pg zod
pnpm add @dannyfuf/persistence knex pg zod
bun add @dannyfuf/persistence knex pg zodDevelopment Setup
pnpm install
docker compose up -d postgres
pnpm lint
pnpm typecheck
pnpm test
pnpm test:integration
pnpm schema:check
pnpm buildIf Docker is provided by OrbStack and the shim is not on your shell PATH, add
~/.orbstack/bin before running Docker commands.
Configuration
Create a project-level persistence.config.mjs:
export default {
knex: {
client: "pg",
connection: process.env.DATABASE_URL,
},
schema: {
outputPath: "src/generated/persistenceSchema.ts",
databaseSchemas: ["public"],
},
};The knex field can also be a path to a module that exports a Knex config.
Schema Commands
Run migrations through Persistence to update the database and regenerate the schema artifact in one step:
pnpm cli migrate:latestThe command runs knex.migrate.latest() from your Persistence config and only
generates schemas after migrations succeed. If migrations fail, the generated
schema file is left untouched.
You can also run schema generation commands directly for CI and debugging. The migrated PostgreSQL database is still the source of truth.
pnpm cli schema:generate
pnpm cli schema:checkschema:generate writes the generated artifact. schema:check regenerates it
in memory and exits non-zero when the checked-in file is missing or stale.
See docs/schema-generation.md for the generated file contract and PostgreSQL support notes. See docs/mvp-boundaries.md for the deliberate MVP limits.
Verification
The local CI equivalent is:
pnpm verifypnpm verify runs lint, type-checking, unit tests, PostgreSQL integration and
e2e tests, schema check, and build. The repository-level pnpm schema:check
script prepares the PostgreSQL fixture schema before checking
test/fixtures/generated/phase1-schema.ts.
Models And Reads
Configure the runtime with your Knex connection and generated schema module:
import { Model, defineModel, type ModelInstance } from "@dannyfuf/persistence";
import { knex } from "./db.js";
import { persistenceSchema } from "./generated/persistenceSchema.js";
Model.configure({ connection: knex, schema: persistenceSchema });
class UserRecord extends Model<"users"> {
static tableName = "users" as const;
}
export const User = defineModel(UserRecord);
export type User = ModelInstance<UserRecord>;Read APIs hydrate rows into model instances with direct database-column accessors:
const user = await User().find(1);
const activeUsers = await User().where({ active: true }).orderBy("email").all();Nullable reads return null; findOrThrow, findByOrThrow, and
firstOrThrow throw RecordNotFoundError.
Direct bulk operations execute SQL without lifecycle behavior:
await User().where({ active: false }).update({ active: true });
await User().where({ id }).delete();Bulk update validates the generated update shape, but skips model validation,
callbacks, commit callbacks, hydration, and automatic updated_at touches.
See docs/models-and-queries.md for callable
repositories, build, custom repository methods, transaction-bound
repositories, and advanced query() examples.
See docs/api.md for the supported public exports.
Saves And Transactions
Use create, assign, save, and assignAndSave for lifecycle-aware writes:
const user = await User().create({
email: "[email protected]",
external_id: "00000000-0000-4000-8000-000000000001",
});
await user.assignAndSave({ name: "Alex" });Use Model.transaction and pass tx explicitly to repositories that should
participate in the same transaction:
await Model.transaction(async (tx) => {
const user = await User(tx).findOrThrow(1);
await user.assignAndSave({ active: false });
});See docs/lifecycle.md for validation, timestamps, dirty tracking, and callback order. See docs/transactions.md for transaction-bound instances, nested transaction squashing, and commit callback failure behavior.
Callbacks
Callbacks are static methods decorated on the record class:
import { BeforeSave, AfterCommit } from "@dannyfuf/persistence";
class UserRecord extends Model<"users"> {
static tableName = "users" as const;
@BeforeSave
static normalizeEmail(user: User) {
user.email = user.email.toLowerCase();
}
@AfterCommit
static publishChange(user: User) {
console.log("committed", user.id);
}
}Example
See examples/basic for a minimal PostgreSQL project with a Knex migration, Persistence config, generated-schema command, model definition, queries, lifecycle saves, transactions, and direct bulk operations.
