Save and retrieve orders
Add a local SQLite database, persist POST /orders, and read a saved order after restart.
The Orders route from the previous chapter only calculates a response.
Here you will store that result in SQLite and add GET /orders/:orderId. Stop bun run dev
before changing the database. Keep working in the same relkit-orders application.
1. Add the local database
Scaffold the supported Drizzle service. This installs the compatible packages and creates an
items placeholder. Replace that placeholder with the Orders schema below; do not keep both.
bun run relkit add database --orm drizzle --dialect sqliteThis also installs Drizzle Kit and registers the release-owned Drizzle declaration repair. If you
pass --no-install, complete bun install and bun run check before the next step. For a manual
alternative, follow database installation; do not run both setup paths.
Replace src/services/database/schema/index.ts with one Orders table. orderId is the unique key;
ownerId is empty until the next chapter adds sign-in. Existing anonymous demonstration orders
will not become anyone’s private orders automatically.
/** Declares the tutorial's SQLite order identity, quantity, price and optional ownership columns for native registration. */
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const orders = sqliteTable("orders", {
orderId: text().primaryKey(),
sku: text().notNull(),
quantity: integer().notNull(),
totalCents: integer().notNull(),
ownerId: text(),
});Use Bun’s SQLite driver in src/services/database/service.ts. Replace the scaffolded service with:
/** Lazily opens the configured SQLite database, registers tutorial tables and closes its owned client on disposal. */
import { Database } from "bun:sqlite";
import { defineDrizzleService } from "@relkit/drizzle";
import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema/index.js";
export default defineDrizzleService({
schema,
client: ({ env }) => drizzle({ client: new Database(env.DATABASE_PATH) }),
dispose: (database) => database.$client.close(),
});Replace src/platform/env.ts to keep the existing log-level setting and add the SQLite file path:
import { defineEnv, env } from "@relkit/app/config";
export default defineEnv({
LOG_LEVEL: env.literal("trace", "debug", "info", "warn", "error").default("info"),
DATABASE_PATH: env.string().default("./orders.sqlite"),
});Update tsconfig.json so TypeScript loads the context registry produced by relkit check.
Keep the starter’s @app/* alias; the complete config below adds the generated declaration
and includes the migration script.
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"baseUrl": ".",
"paths": { "@app/*": ["src/*"] },
"lib": ["ES2022"],
"types": ["bun"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"files": [".relkit/generated/context-registry.d.ts"],
"include": [
"src/**/*.ts",
"tests/**/*.ts",
"scripts/**/*.ts",
"drizzle.config.ts",
"relkit.config.ts"
],
"exclude": ["node_modules", ".relkit"]
}Add the SQLite path to the ignored .env file, preserving its existing settings. The same path will be loaded when you
restart the app or run the production build. Keep the database out of source control too.
printf '\nDATABASE_PATH=./orders.sqlite\n' >> .env
printf '\norders.sqlite\n' >> .gitignore2. Save an order
The existing createOrder function already validates input and calls priceOrder. Replace
src/services/orders/functions/create-order.function.ts so it inserts the calculated result into the
Orders table. The response now includes the original quantity as well as the price.
/** Prices validated order input and persists its selected fields through the tutorial's native database context. */
import { defineFunction } from "@relkit/app/functions";
import { z } from "@relkit/app/schema";
import priceOrder from "@app/services/orders/functions/price-order.function.js";
const order = z.object({
orderId: z.string().min(1),
sku: z.string().min(1),
quantity: z.number().int().positive(),
});
export default defineFunction({
input: order,
output: z.object({
orderId: z.string(),
sku: z.string(),
quantity: z.number().int().positive(),
totalCents: z.number().int().nonnegative(),
}),
handler: async (input, context) => {
const { totalCents } = await priceOrder.invoke({ quantity: input.quantity });
const saved = await context.database.orders.insert({
data: { ...input, totalCents, ownerId: null },
});
return {
orderId: saved.orderId,
sku: saved.sku,
quantity: saved.quantity,
totalCents: saved.totalCents,
};
},
});3. Add a read operation
From the application root, scaffold the read function:
bun run relkit add function get-order --service ordersThis creates src/services/orders/functions/get-order.function.ts and exposes getOrder through
the service. Replace its placeholder with the lookup below. It returns found: false when there
is no row, so callers receive no fabricated order. For manual setup, create the same file instead.
/** Reads the requested database order and returns validated selected fields or an explicit missing result. */
import { defineFunction } from "@relkit/app/functions";
import { z } from "@relkit/app/schema";
export default defineFunction({
input: z.object({ orderId: z.string().min(1) }),
output: z.object({
found: z.boolean(),
order: z
.object({
orderId: z.string(),
sku: z.string(),
quantity: z.number(),
totalCents: z.number(),
})
.optional(),
}),
handler: async ({ orderId }, context) => {
const row = await context.database.orders.findOne({ where: { orderId } });
return row === null
? { found: false }
: {
found: true,
order: {
orderId: row.orderId,
sku: row.sku,
quantity: row.quantity,
totalCents: row.totalCents,
},
};
},
});Replace src/services/orders/service.ts to expose that operation alongside the existing two functions:
/** Exposes tutorial creation, lookup and pricing functions through the immediate orders service boundary. */
import { defineService } from "@relkit/app/services";
import createOrder from "@app/services/orders/functions/create-order.function.js";
import getOrder from "@app/services/orders/functions/get-order.function.js";
import priceOrder from "@app/services/orders/functions/price-order.function.js";
export default defineService({ functions: { createOrder, getOrder, priceOrder } });Scaffold the read route after updating the function and service:
bun run relkit add route /orders/:orderId \
--mode service-route --service orders --map GET=getOrderThis creates src/routes/orders/[orderId]/route.ts. Its directory supplies the URL parameter;
the named GET export selects the public getOrder member. The generated shorthand and the
explicit mapping below select the same operation; use this source to match the tutorial:
/** Maps the order GET route to its service lookup member with inferred path input and validated response. */
import { defineServiceRoutes } from "@relkit/app/routes";
import orders from "@app/services/orders/service.js";
export const { GET } = defineServiceRoutes(orders, { GET: { member: "getOrder" } });4. Keep existing HTTP tests compatible
Application test fixtures load the app’s declared services, even when a test only calls /hello.
After adding the database, replace the starter’s generic createTestApplication import with
@relkit/testing/drizzle in every application fixture. Keep invokeFunction unit tests on the
generic helper when they inject their own dependencies.
Update the existing Hello HTTP test:
/** Keeps the starter's Hello HTTP coverage after the application selects a native Drizzle service. */
import { afterAll, expect, test } from "bun:test";
import { createTestApplication } from "@relkit/testing/drizzle";
import config from "../../relkit.config.js";
const testApp = await createTestApplication(config);
test("GET /hello", async () => {
const response = await testApp.http.get("/hello?name=Mustafa");
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ message: "Hello, Mustafa!" });
});
afterAll(() => testApp.close());5. Migrate, run, and check
Replace the scaffolded drizzle.config.ts with this configuration. It refuses an unset or
in-memory path so the migration cannot quietly target the wrong database.
/** Configures native tutorial schema migrations against grouped order tables and an explicitly selected SQLite file. */
import { defineConfig } from "drizzle-kit";
const path = process.env.DATABASE_PATH;
if (path === undefined || path.trim() === "" || path === ":memory:") {
throw new Error("Set DATABASE_PATH to the SQLite file you intend to migrate");
}
export default defineConfig({
schema: "./src/services/database/schema/index.ts",
dialect: "sqlite",
out: "./src/services/database/migrations",
dbCredentials: { url: path },
});Generate and review the initial SQL migration, then apply it and start the app:
Use installed RELKIT migration tooling, which selects the Bun-native SQLite driver.
Keep generated SQL and required metadata tracked beside the database service in
src/services/database/migrations; do not copy runner code into the application.
export DATABASE_PATH=./orders.sqlite
bun --bun drizzle-kit generate --name=initial
bun run relkit db migrate
bun run check
bun run typecheck
bun run devIn another terminal, submit a new order and read it back:
curl -i -X POST http://localhost:3000/orders \
-H 'content-type: application/json' \
-d '{"orderId":"saved-1","sku":"book","quantity":10}'
curl http://localhost:3000/orders/saved-1Expect HTTP 201 with totalCents: 900; the GET response has found: true and the same
order. Restart bun run dev and repeat the GET: the result remains. Use a new orderId for
another POST because the database enforces uniqueness.
This chapter’s API has no access control yet.