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 explicit joins such as .joinOne(...) / .joinMany(...) 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))
          .joinOne("customer", "customer", (customer) =>
            customer.onIndex("primary", (eb) => eb("id", "=", eb.parent("customerId"))),
          ),
      )
      .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({ table: "..." }) 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({ table: "user" }))
        .addColumn("content", column("string")),
    ),
);

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({ table: "..." }).