Add a complete-task endpoint
View as MarkdownThe task app can now store immutable completion events, but editing a task and creating its event through two CRUD requests would allow either write to succeed alone. This page adds one protected complete-task operation. You will define its shared contract, implement a row-scoped synchronous transaction, mount the route, and exercise every declared response. The operation enables reliable state transitions and audit history while generated task screens continue to handle ordinary record editing.
Add a protected complete-task action that updates the visible task and records its completion event in one transaction. Keep ordinary task edits on the generated CRUD API, expose the action through OpenAPI, and return useful typed conflicts.Give the transition one application boundary
Section titled “Give the transition one application boundary”Generated table APIs remain the right interface for changing one task title,
priority, due date, or status. Completion has a stronger invariant: the task
must become completed in the same commit that creates its history event. An
app-owned endpoint gives that transition one ability check, one transaction, and
one result shape.
The shared contract describes only the wire boundary. Create
packages/shared/src/contracts/complete-task.ts:
import { initContract } from "@sapporta/rest-core";import { z } from "zod";
const c = initContract();
const taskCompletionErrorSchema = z.object({ error: z.string(), code: z.enum(["TASK_NOT_FOUND", "TASK_ALREADY_COMPLETED"]),});
export const completeTaskContract = c.router({ completeTask: c.mutation({ method: "POST", path: "/tasks/:id/complete", summary: "Complete a task and record its completion event", pathParams: z.object({ id: z.coerce.number().int().positive() }), body: z.object({}), responses: { 200: z.object({ task_id: z.number().int(), event_id: z.number().int(), status: z.literal("completed"), }), 404: taskCompletionErrorSchema, 409: taskCompletionErrorSchema, }, }),});Re-export it from packages/shared/src/contracts/index.ts:
export { completeTaskContract } from "./complete-task.js";The shared package stays browser-safe: it contains Zod schemas and wire types,
not Hono contexts, Drizzle queries, or database handles. The contract path also
omits /api. The application mount adds that prefix at runtime.
Keep transaction rules in the domain module
Section titled “Keep transaction rules in the domain module”Create packages/api/modules/tasks/complete-task.ts. The module accepts the
database and authenticated request context. It does not know how the route was
mounted or how the browser called it.
import { eq } from "drizzle-orm";import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";import type { SapportaAuthContext } from "@sapporta/server";import { Temporal } from "@sapporta/shared/temporal";import { taskEvents, taskEventsTable } from "../../schema/task-events.js";import { tasks, tasksTable } from "../../schema/tasks.js";
type TaskWorkflowContext = { db: BetterSQLite3Database; auth: SapportaAuthContext; now?: () => Temporal.Instant;};
export abstract class TaskCompletionError extends Error { abstract readonly status: 404 | 409; abstract readonly code: "TASK_NOT_FOUND" | "TASK_ALREADY_COMPLETED";
toResponseBody() { return { error: this.message, code: this.code }; }}
export class TaskNotFoundError extends TaskCompletionError { readonly status = 404 as const; readonly code = "TASK_NOT_FOUND" as const;
constructor() { super("Task not found"); this.name = "TaskNotFoundError"; }}
export class TaskAlreadyCompletedError extends TaskCompletionError { readonly status = 409 as const; readonly code = "TASK_ALREADY_COMPLETED" as const;
constructor() { super("Task is already completed"); this.name = "TaskAlreadyCompletedError"; }}
export function completeTask(context: TaskWorkflowContext, taskId: number) { const { db, auth } = context; const now = context.now ?? (() => Temporal.Now.instant()); const taskAccess = auth.rowSecurity.forTable(tasks); const eventAccess = auth.rowSecurity.forTable(taskEvents);
return db.transaction((tx) => { const completedAt = now(); const task = tx .select({ id: tasksTable.id, status: tasksTable.status }) .from(tasksTable) .where(taskAccess.ownedRows(eq(tasksTable.id, taskId))) .get();
if (!task) throw new TaskNotFoundError(); if (task.status === "completed") { throw new TaskAlreadyCompletedError(); }
tx.update(tasksTable) .set({ status: "completed", updated_at: completedAt }) .where(taskAccess.ownedRows(eq(tasksTable.id, task.id))) .run();
const eventValues = eventAccess.insertValuesSync( tx, {}, { serverValues: { task_id: task.id, event_type: "completed", occurred_at: completedAt, }, }, );
const event = tx .insert(taskEventsTable) .values(eventValues as typeof taskEventsTable.$inferInsert) .returning({ id: taskEventsTable.id }) .get();
return { task_id: task.id, event_id: event.id, status: "completed" as const, }; });}ownedRows() composes task identity with request data authority in SQL. An
absent task and a task outside the active workspace therefore reach the same 404
branch. This avoids leaking whether an invisible row exists.
The event receives task_id, event_type, and occurred_at through trusted
serverValues; the client supplies none of those fields. insertValuesSync()
validates the server-authored reference, rejects ownership tampering, and stamps
the request’s workspace value. Each participating table has its own row-security
guard.
Sapporta’s default SQLite transaction callback is synchronous. Do not mark the
callback async, use the asynchronous insertValues(), or await email,
storage, or network work inside it. External effects run after the commit or
through a durable outbox. The clock uses Temporal.Instant, which also lets a
test inject a fixed time without using Date.
Adapt the workflow to HTTP once
Section titled “Adapt the workflow to HTTP once”Create packages/api/app/complete-task.ts. The route checks the feature ability
at the request edge, calls the domain function, and maps the workflow error
family once. Unexpected errors continue to the application’s default error
handler.
import { forbidUnless, TsRestApi, type SapportaEnv } from "@sapporta/server";import { completeTaskContract } from "task-app-shared";import { completeTask, TaskCompletionError,} from "../modules/tasks/complete-task.js";
const api = new TsRestApi<SapportaEnv>();
api.register( "completeTask", completeTaskContract.completeTask, ({ c, request }) => { try { const auth = c.get("auth"); forbidUnless(c, auth.ability.can("run", "task_completion"));
return { status: 200, body: completeTask({ db: c.get("db"), auth }, request.params.id), }; } catch (error) { if (error instanceof TaskCompletionError) { return { status: error.status, body: error.toResponseBody() }; } throw error; } },);
export default api;Authentication, the task_completion ability, and row visibility are three
separate boundaries. A signed-in member can be denied by the ability check even
when that member can edit tasks through generated CRUD. An authorized owner can
still complete only tasks visible through the request’s row authority.
Mount handlers and OpenAPI emitters
Section titled “Mount handlers and OpenAPI emitters”A route file under packages/api/app/ is inert until loadApp() mounts it.
app.route() adds its Hono handlers to the runtime tree. app.extend() uses
the public TsRestApi composition API to add the sub-app’s contract emitters to
the combined OpenAPI document.
Update packages/api/app.ts, preserving the generated sample routes and any
other app-owned mounts:
import type { ProjectDbConnection, SapportaEnv, TsRestApi,} from "@sapporta/server";import completeTaskApi from "./app/complete-task.js";import helloApi from "./app/hello.js";import publicApiSample from "./app/public-api-sample.js";import type { SapportaMailer } from "./mailer.js";
export interface LoadAppOptions { conn: ProjectDbConnection; mailer: SapportaMailer;}
function mountApi(app: TsRestApi<SapportaEnv>, api: TsRestApi<SapportaEnv>) { app.route("/", api); app.extend(api);}
export function loadApp(app: TsRestApi<SapportaEnv>, _options: LoadAppOptions) { mountApi(app, helloApi); mountApi(app, publicApiSample); mountApi(app, completeTaskApi);}The app passed to loadApp() is already mounted below /api. The contract’s
/tasks/:id/complete path therefore becomes POST /api/tasks/:id/complete.
Keep this protected route out of publicApiRoutes.
Discover and call the mounted endpoint
Section titled “Discover and call the mounted endpoint”Build and start the application before constructing a direct request:
pnpm buildpnpm devIn another terminal, inspect the mounted operation and then call it with an open, visible task:
pnpm exec sapporta endpoints show "POST /api/tasks/{id}/complete"pnpm exec sapporta api post /api/tasks/1/complete --body '{}'The first successful call returns the contract’s custom result:
{ "task_id": 1, "event_id": 4, "status": "completed"}Calling it again returns the declared conflict without inserting another event:
{ "error": "Task is already completed", "code": "TASK_ALREADY_COMPLETED"}Inspect both generated tables after the success:
pnpm exec sapporta rows get tasks 1pnpm exec sapporta rows list task_events --where '{"task_id":{"eq":1}}'The task has status: "completed" and exactly one related completion event. An
event-insert failure must leave the task open; add a focused workflow test that
injects that failure and proves the transaction rolls back.
Task completion now has one protected, discoverable transaction while ordinary task changes remain available through generated CRUD. The important constraints are server-authored scope and event fields, row predicates inside the transaction, a synchronous SQLite callback, and one route-edge mapping for typed workflow errors. Next, call the workflow from a typed client, then use the domain workflow guide and TsRestApi reference for larger transitions.