packages/wrap/src/registry.ts, database.ts, transaction.ts, events.ts.

WrapRegistry — app-wide typing via declaration merging

export interface WrapRegistry {}

Empty by default, the same pattern as Hono’s own ContextVariableMap. An app augments it once (typically in src/factory/web.factory.ts or a dedicated types file) to get full typing everywhere without threading generics through every controller/service/repository:

import type * as schemas from "@/db";

declare module "@donilite/wrap" {
  interface WrapRegistry {
    schema: typeof schemas;      // tables + relations → typed populate (`with`)
    variables: Variables;         // extra Hono context variables beyond `identity`
    roles: UserRoles;             // access-control roles used by @Can / guard()
    identity: MySessionShape;     // AuthController.authenticate()'s return shape
  }
}

Every field is optional — augment only what the app needs. Derived types, all exported from this module:

type RegisteredSchema = WrapRegistry["schema"];                       // falls back to {}
type AppSchema = ExtractTablesWithRelations<RegisteredSchema>;         // relational view, powers populate typing
type AuthIdentity = WrapRegistry["identity"];                          // falls back to Record<string, unknown> — no mandated `role`
type AppVariables = WrapRegistry["variables"] & { identity: AuthIdentity }; // `identity` is always present regardless of augmentation
type AppRoles = WrapRegistry["roles"];                                 // falls back to `string`

AppVariables is the Variables type parameter every Hono<{ Variables: AppVariables }> in the framework uses — Wrap, RouterController, middleware signatures. AuthIdentity deliberately has no mandated role field (see Auth guide — no forced RBAC); it’s Record<string, unknown> until augmented.

Database (database.ts)

type WrapDatabase = PgDatabase<PgQueryResultHKT, RegisteredSchema>;

interface DatabaseOptions {
  connectionString: string;
  schema: RegisteredSchema;   // e.g. `import * as schemas from "@/db"` — required for populate
  poolSize?: number;
  logger?: boolean | DrizzleLogger; // true → Wrap's own debug logger via the shared Logger
}

function initializeDatabase(options: DatabaseOptions): WrapDatabase;
function getDatabase(): WrapDatabase;      // throws if initializeDatabase() was never called
function setDatabase(instance: WrapDatabase): void; // inject a pre-built instance (testing / custom drivers)
function resetDatabase(): void;             // unregister without closing anything (testing)
function closeDatabase(): Promise<void>;    // drains the connection pool

initializeDatabase() must run once at bootstrap, before any controller/repository is instantiated — in the generated template, src/bootstrap.ts does this and is imported first by src/index.ts. Calling it again after the first successful call is a no-op (returns the already-initialized instance) rather than creating a second pool. WrapDatabase is driver-agnostic in type — node-postgres in real apps, PGlite in tests via createTestDatabase() using setDatabase().

Transactions (transaction.ts)

function currentTransaction(): WrapDatabase | undefined;
async function withTransaction<T>(fn: () => Promise<T>): Promise<T>;

withTransaction(fn) runs fn inside a real database transaction, propagated implicitly to every repository call made anywhere inside fn’s async scope via AsyncLocalStorage — no tx parameter to thread through call signatures. BaseRepository’s db getter checks currentTransaction() ?? getDatabase() on every access, so it picks up the ambient transaction automatically.

await withTransaction(async () => {
  const user = await userRepository.create(dto);
  await profileRepository.create(ProfileDTO.from({ userId: user.id }));
  // a throw anywhere in here rolls back both creates
});

Nesting: a withTransaction() call made while already inside another one joins the ambient transaction rather than opening a second, nested one — there’s a single commit point at the outermost call. Entity events: anything emitted via emitEntityEvent inside the scope is buffered and only dispatched after the surrounding transaction actually commits — a rollback means the events never fire. BaseRepository.applyBatch() is built entirely on this primitive for its atomicity guarantee.

deferUntilCommit(effect) (marked @internal) is the buffering primitive emitEntityEvent uses — returns false (and does not buffer) when called outside any transaction, so the caller knows to run the effect immediately instead.

Entity events (events.ts)

type EntityEventType = "created" | "updated" | "deleted";

interface EntityEvent<T = unknown> {
  type: EntityEventType;
  table: string;              // SQL table name
  data: T;                     // created/updated: the row(s); deleted: `{ ids }`
}

type EntityEventHandler = (event: EntityEvent) => void | Promise<void>;

function onEntityEvent(source: string | EntityLike, handler: EntityEventHandler): () => void; // returns an unsubscribe function
function emitEntityEvent(event: EntityEvent): void;

Emitted by BaseRepository after every write (create/update/delete/deleteMultiple, and indirectly by applyBatch). source can be an entity class, a raw table name string, or "*" for every entity:

onEntityEvent(Example, (event) => console.log(event.type, event.data));
onEntityEvent("*", (event) => audit(event)); // fires for every entity in the app

Powers two built-in consumers — cache invalidation (a repository’s own cache scope self-invalidates on any write to that entity) and realtime publication (realtime.bindEntityEvents() republishes every event to its matching WebSocket topic) — and is open to app-level handlers for anything else (audit logging, domain events, notifications). A handler that throws or rejects is caught and logged (logger.warn(...)) — one failing handler never breaks another handler or the write that triggered the event.


This site uses Just the Docs, a documentation theme for Jekyll.