Rules of Fragno
Three rules for Fragno DB transactions, hooks, and IDs
These rules apply when you use @fragno-dev/db.
1. One handlerTx() per route
Each route should execute a single handlerTx() call. If you need multiple operations, compose
them inside one transaction (or via .withServiceCalls(...)), instead of starting extra
handlerTx() calls.
Exception: it can be acceptable to run two handlerTx() calls when you deliberately split a
flow into retrieve → async work → mutate, so the async work happens outside the transaction.
Keep this rare and explicit.
handlerTx() is two-phase: schedule all reads in one .retrieve(...), then schedule all
writes in one .mutate(...), then .execute().
If you need related data, use .join(...) and multiple .find... calls inside the same
.retrieve(...) instead of starting extra handlerTx() calls.
const result = await this.handlerTx()
.retrieve(({ forSchema }) =>
forSchema(mySchema)
.findFirst("order", (b) =>
b.whereIndex("primary", (eb) => eb("id", "=", orderId)).join((j) => j.customer()),
)
.find("order_item", (b) =>
b.whereIndex("idx_order_item_orderId", (eb) => eb("orderId", "=", orderId)),
),
)
.mutate(({ forSchema, retrieveResult: [order, items] }) => {
if (!order) return { ok: false as const };
const uow = forSchema(mySchema);
uow.update("order", order.id, (b) => b.set({ status: "processing" }));
return { ok: true as const, items };
})
.execute();See Transactions for the full builder pattern.
2. Webhook routes are thin; durable hooks do the work
Webhook routes should validate, trigger a durable hook, and return. Heavy or async work belongs
in a defineHook(...) implementation, where you can use this.handlerTx() and
this.idempotencyKey.
defineRoute({
method: "POST",
path: "/webhook",
handler: async function ({ input }, { json }) {
const payload = await input.valid();
await this.handlerTx()
.mutate(({ uow }) => {
uow.triggerHook("onWebhookReceived", payload);
})
.execute();
return json({ ok: true });
},
});
export const fragmentDef = defineFragment<Config>("example")
.extend(withDatabase(mySchema))
.provideHooks(({ defineHook, config }) => ({
onWebhookReceived: defineHook(async function (payload: WebhookPayload) {
await this.handlerTx()
.retrieve(({ forSchema }) =>
forSchema(mySchema).findFirst("event", (b) =>
b.whereIndex("primary", (eb) => eb("id", "=", payload.eventId)),
),
)
.mutate(({ forSchema, retrieveResult: [event] }) => {
if (!event) {
forSchema(mySchema).create("event", { id: payload.eventId, receivedAt: new Date() });
}
})
.execute();
await config.onWebhookReceived?.({ idempotencyKey: this.idempotencyKey, payload });
}),
}))
.build();See Durable Hooks for the execution model and retry behavior.
3. idColumn() is your app-facing ID
idColumn() is the primary identifier for your records. It can store upstream IDs, but it
doesn't have to — you can also let it auto-generate (CUID) values. Fragno automatically adds
_internalId and _version under the hood, so you do not need extra columns to mirror either.
References can point at idColumn() values. referenceColumn() accepts ID strings (or FragnoId
objects) and the query layer resolves them for you.
import { column, idColumn, referenceColumn, schema } from "@fragno-dev/db/schema";
export const mySchema = schema("example", (s) =>
s
.addTable("user", (t) => t.addColumn("id", idColumn()).addColumn("name", column("string")))
.addTable("note", (t) =>
t
.addColumn("id", idColumn())
.addColumn("userId", referenceColumn())
.addColumn("content", column("string")),
)
.addReference("author", {
type: "one",
from: { table: "note", column: "userId" },
to: { table: "user", column: "id" },
}),
);
await this.handlerTx()
.mutate(({ forSchema }) => {
const uow = forSchema(mySchema);
uow.create("user", { id: "user_123", name: "Ada" }); // upstream ID is OK
uow.create("user", { name: "Lin" }); // auto-generated CUID is OK
uow.create("note", {
id: "note_456",
userId: "user_123",
content: "Hello from an app-facing ID",
});
})
.execute();See Defining Schemas for
more on idColumn() and referenceColumn().