Compare commits

...

3 Commits

Author SHA1 Message Date
fireshaper e58e99c156 Step 7.3: add expired contract cleanup job
CI / check (push) Successful in 1m35s Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 01:41:00 -04:00
fireshaper 11f3bb1897 Step 7.2: add arrival resolution job and test clock offset
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 01:37:55 -04:00
fireshaper 9847a7ae18 Step 7.1: add pg-boss job queue
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 01:34:21 -04:00
24 changed files with 671 additions and 44 deletions

View File

@ -11,3 +11,6 @@ BETTER_AUTH_URL=http://localhost:3000
# HTTP port the API server listens on
PORT=3000
# Run background job workers (arrival resolution, cleanup) in the API process
WORKERS_ENABLED=true

View File

@ -340,3 +340,23 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **No new mechanism was needed:** since Step 6.1 every authenticated endpoint runs in `withPlayer`, a transaction that first takes `SELECT ... FOR UPDATE` on the player's company row. Requests for one player run one after another; different players never block each other.
- **Race tests go beyond the plan's double purchase:** eight simultaneous purchases with money for three, the same contract dispatched twice at once, and the same bay leased twice at once. With the row lock removed, three of the four fail, which confirms the tests exercise the lock. The two-purchase race can pass by luck without the lock, which is why the eight-purchase version exists.
## Step 7.1
- **Jobs sit behind a small `JobQueue` interface** (`start`, `stop`, `send`). Production uses pg-boss (v12) in the same Postgres database, in its own `pgboss` schema; most tests pass a recording fake, so they don't start pg-boss, and call job handlers directly (decision 0.5.18). Only the queue test uses real pg-boss.
- **`WORKERS_ENABLED` (default `true`)** controls whether this process runs workers and cron schedules. Queues are always created and pg-boss always starts, so an API-only process can still enqueue arrival jobs. Tests set it to `false`.
- **The queue starts in Fastify's `onReady` hook and stops (gracefully) in `onClose`,** before the database pool closes.
- **The server listens on `0.0.0.0` (IPv4).** Clients that resolve `localhost` to IPv6 `::1` first (PowerShell, Node 17+ `fetch`) cannot reach it by that name; use `127.0.0.1`. To revisit when the client dev proxy is set up (Phase 8).
## Step 7.2
- **One job per dispatch,** `resolve-arrivals` with `{ playerId, dispatchId }`, scheduled for the arrival time. The handler resolves everything due for that player under the player lock, the same code as lazy resolution, so it is a no-op when a request got there first. A job that fires early resolves nothing; the next request or the next due job picks the dispatch up.
- **Jobs are sent after the dispatch transaction commits.** A failed send is logged and the dispatch still succeeds, because lazy resolution on read is the safety net.
- **The app clock is `base clock + offset`** (`createOffsetClock`). The offset is always 0 except through `POST /api/test/clock-offset` (`{ offsetMs }`), which is registered only when `NODE_ENV` is not `production` and is not in the route catalog. Job timers use the base (real) clock: `startAfter = arrivalAt - offset`.
- **The job's handler reads time from the same clock as requests,** so tests run handlers directly with a controlled clock (decision 0.5.18).
## Step 7.3
- **Retention window: 24 board periods** (one day at the default one-hour period), as the server constant `BOARD_RETENTION_PERIODS`. It is operational housekeeping, not game tuning, so it is not in GameConfig.
- **Cleanup deletes boards and accepted-contract rows whose period is older than the window**, in one transaction. Dispatches keep their own contract snapshot, so in-flight and resolved dispatches are unaffected. A contract from a deleted board can no longer be found, so dispatching it returns `unknown-contract` instead of `contract-expired`.
- **The job runs hourly on the hour (UTC cron `0 * * * *`)** through pg-boss's scheduler, only in processes with workers enabled.

View File

@ -20,6 +20,7 @@
"drizzle-orm": "^0.45.3",
"fastify": "^5.12.5",
"pg": "^8.23.1",
"pg-boss": "^12.36.0",
"zod": "^4.6.5"
},
"devDependencies": {

View File

@ -1,20 +1,25 @@
import Fastify, { type FastifyInstance } from "fastify";
import { createAuth } from "./auth";
import { type Clock, systemClock } from "./clock";
import { type Clock, createOffsetClock, systemClock } from "./clock";
import type { ServerConfig } from "./config";
import { createDatabase, type Database } from "./db/client";
import { registerErrorHandler } from "./http/errors";
import { requirePlayerId } from "./http/session";
import type { AppContext } from "./routes/context";
import { appJobs } from "./jobs/definitions";
import { createPgBossQueue, type JobQueue } from "./jobs/queue";
import { registerBoardRoutes } from "./routes/boards";
import type { AppContext } from "./routes/context";
import { registerDispatchRoutes } from "./routes/dispatches";
import { registerFleetRoutes } from "./routes/fleet";
import { registerPlayerRoutes } from "./routes/player";
import { registerTestClockRoute } from "./routes/test-clock";
export interface AppDeps {
clock?: Clock;
/** An existing database (tests). Otherwise the app opens one and closes it on shutdown. */
database?: Database;
/** Background job queue. Defaults to pg-boss on the app's database. */
jobs?: JobQueue;
}
/** Builds the Fastify app without listening, so tests can start it on any port. */
@ -22,10 +27,20 @@ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInsta
const database = deps.database ?? createDatabase(config.databaseUrl);
const { db } = database;
const auth = createAuth(db, { secret: config.authSecret, baseUrl: config.authUrl });
const clock = createOffsetClock(deps.clock ?? systemClock);
const jobs =
deps.jobs ??
createPgBossQueue(config.databaseUrl, {
workers: config.workersEnabled,
jobs: appJobs({ db, clock: clock.now }),
});
const ctx: AppContext = {
db,
auth,
clock: deps.clock ?? systemClock,
clock: clock.now,
// pg-boss timers run on the real clock, not the (possibly offset) game clock.
toBaseTime: clock.toBase,
jobs,
options: {
requirePlayer: (request) => requirePlayerId(request, auth, db),
validateResponses: config.env !== "production",
@ -36,7 +51,12 @@ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInsta
// Structured JSON logs (pino); silenced in tests unless LOG_LEVEL says otherwise.
logger: { level: config.logLevel },
});
if (!deps.database) app.addHook("onClose", () => database.close());
// The queue starts with the app and stops before the database closes.
app.addHook("onReady", () => jobs.start());
app.addHook("onClose", async () => {
await jobs.stop();
if (!deps.database) await database.close();
});
registerErrorHandler(app);
// Under /api so it is reachable through the same-domain proxy (decision 0.5.20).
@ -45,6 +65,7 @@ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInsta
registerBoardRoutes(app, ctx);
registerDispatchRoutes(app, ctx);
registerFleetRoutes(app, ctx);
if (config.env !== "production") registerTestClockRoute(app, clock);
return app;
}

View File

@ -4,3 +4,27 @@ import type { RealTime } from "@deadhead/sim";
export type Clock = () => RealTime;
export const systemClock: Clock = () => Date.now();
/**
* A clock that runs ahead of (or behind) its base clock by an adjustable offset.
* The test-only clock-offset endpoint moves it; production never does (Step 7.2).
*/
export interface OffsetClock {
now: Clock;
offsetMs(): number;
setOffset(ms: number): void;
/** Converts a time on this clock to the base clock, e.g. for pg-boss timers. */
toBase(time: RealTime): RealTime;
}
export function createOffsetClock(base: Clock): OffsetClock {
let offset = 0;
return {
now: () => base() + offset,
offsetMs: () => offset,
setOffset: (ms) => {
offset = ms;
},
toBase: (time) => time - offset,
};
}

View File

@ -9,6 +9,8 @@ const envSchema = z.object({
TEST_DATABASE_URL: z.url().optional(),
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.url(),
/** Run background job workers in this process (Step 7.1). */
WORKERS_ENABLED: z.enum(["true", "false"]).default("true"),
});
export interface ServerConfig {
@ -19,6 +21,7 @@ export interface ServerConfig {
testDatabaseUrl: string | undefined;
authSecret: string;
authUrl: string;
workersEnabled: boolean;
}
/** Validates the environment, throwing one error that lists every problem. */
@ -37,5 +40,6 @@ export function loadConfig(env: Record<string, string | undefined>): ServerConfi
testDatabaseUrl: e.TEST_DATABASE_URL,
authSecret: e.BETTER_AUTH_SECRET,
authUrl: e.BETTER_AUTH_URL,
workersEnabled: e.WORKERS_ENABLED === "true",
};
}

View File

@ -0,0 +1,34 @@
import { content } from "@deadhead/content";
import { boardPeriodIndex, type RealTime } from "@deadhead/sim";
import { lt } from "drizzle-orm";
import type { Db } from "../db/client";
import { acceptedContracts, contractBoards } from "../db/schema";
/**
* How many past board periods to keep. Older boards are never shown, and their
* contracts are expired; dispatches keep their own contract snapshot.
*/
export const BOARD_RETENTION_PERIODS = 24;
/**
* Deletes stored contract boards and accepted-contract records from periods
* older than the retention window (Step 7.3). Returns how many rows went.
*/
export async function cleanupExpiredContracts(
db: Db,
now: RealTime,
retentionPeriods = BOARD_RETENTION_PERIODS,
): Promise<{ boards: number; acceptedContracts: number }> {
const cutoff = boardPeriodIndex(now, content.config) - retentionPeriods;
return db.transaction(async (tx) => {
const boards = await tx
.delete(contractBoards)
.where(lt(contractBoards.periodIndex, cutoff))
.returning({ id: contractBoards.id });
const accepted = await tx
.delete(acceptedContracts)
.where(lt(acceptedContracts.periodIndex, cutoff))
.returning({ id: acceptedContracts.contractId });
return { boards: boards.length, acceptedContracts: accepted.length };
});
}

View File

@ -0,0 +1,42 @@
import type { Clock } from "../clock";
import type { Db } from "../db/client";
import { withPlayerLock } from "../game/lock";
import { resolveArrivedDispatches } from "../game/resolve";
import { cleanupExpiredContracts } from "./cleanup";
import type { JobDefinition } from "./queue";
export const RESOLVE_ARRIVALS_JOB = "resolve-arrivals";
export const CLEANUP_CONTRACTS_JOB = "cleanup-expired-contracts";
export interface JobContext {
db: Db;
clock: Clock;
}
/**
* Resolves a player's arrived dispatches, exactly like lazy resolution on read
* (Step 7.2). Scheduled for each dispatch's arrival; does nothing if a request
* already resolved it.
*/
export async function resolveArrivalsJob(ctx: JobContext, data: Record<string, unknown>) {
const playerId = data.playerId;
if (typeof playerId !== "string") throw new Error("resolve-arrivals job needs a playerId");
await withPlayerLock(ctx.db, playerId, (tx) =>
resolveArrivedDispatches(tx, playerId, ctx.clock()),
);
}
/** Every background job the app runs. */
export function appJobs(ctx: JobContext): JobDefinition[] {
return [
{ name: RESOLVE_ARRIVALS_JOB, handler: (data) => resolveArrivalsJob(ctx, data) },
{
name: CLEANUP_CONTRACTS_JOB,
// Hourly, on the hour (UTC).
cron: "0 * * * *",
handler: async () => {
await cleanupExpiredContracts(ctx.db, ctx.clock());
},
},
];
}

View File

@ -0,0 +1,72 @@
import { PgBoss } from "pg-boss";
export type JobHandler = (data: Record<string, unknown>) => Promise<void>;
export interface JobDefinition {
name: string;
handler: JobHandler;
/** Cron expression for a recurring job (UTC). */
cron?: string;
}
/** Background work. The real one is pg-boss; tests can pass a recording fake. */
export interface JobQueue {
start(): Promise<void>;
stop(): Promise<void>;
/** Enqueues a job, optionally not before `startAfter` (Unix ms). */
send(name: string, data: Record<string, unknown>, startAfter?: number): Promise<string | null>;
}
/**
* pg-boss in the same Postgres database (its own `pgboss` schema). Queues are
* always created so the app can enqueue; workers and cron schedules run only
* when `workers` is true (Step 7.1).
*/
export function createPgBossQueue(
databaseUrl: string,
options: { workers: boolean; jobs: JobDefinition[]; onError?: (error: Error) => void },
): JobQueue & { boss: PgBoss } {
const boss = new PgBoss({ connectionString: databaseUrl, schedule: options.workers });
if (options.onError) boss.on("error", options.onError);
return {
boss,
async start() {
await boss.start();
for (const job of options.jobs) {
await boss.createQueue(job.name);
if (!options.workers) continue;
await boss.work<Record<string, unknown>>(job.name, async (batch) => {
for (const item of batch) await job.handler(item.data);
});
if (job.cron) await boss.schedule(job.name, job.cron);
}
},
async stop() {
await boss.stop({ graceful: true });
},
send(name, data, startAfter) {
return boss.send(
name,
data,
startAfter === undefined ? {} : { startAfter: new Date(startAfter) },
);
},
};
}
/** A queue that only records what was sent; for tests that call handlers directly. */
export function createRecordingQueue(): JobQueue & {
sent: { name: string; data: Record<string, unknown>; startAfter?: number }[];
} {
const sent: { name: string; data: Record<string, unknown>; startAfter?: number }[] = [];
return {
sent,
async start() {},
async stop() {},
async send(name, data, startAfter) {
sent.push({ name, data, ...(startAfter === undefined ? {} : { startAfter }) });
return `recorded-${sent.length}`;
},
};
}

View File

@ -2,11 +2,15 @@ import type { Auth } from "../auth";
import type { Clock } from "../clock";
import type { Db } from "../db/client";
import type { RouteOptions } from "../http/route";
import type { JobQueue } from "../jobs/queue";
/** What every route module needs. */
export interface AppContext {
db: Db;
auth: Auth;
clock: Clock;
/** Converts an app-clock time to the real clock pg-boss uses (they differ only in tests). */
toBaseTime: (time: number) => number;
jobs: JobQueue;
options: RouteOptions;
}

View File

@ -2,13 +2,14 @@ import { routes } from "@deadhead/schema";
import type { FastifyInstance } from "fastify";
import { dispatchContract, dispatchReposition, dispatchTutorial } from "../game/dispatch";
import { registerRoute } from "../http/route";
import { RESOLVE_ARRIVALS_JOB } from "../jobs/definitions";
import type { AppContext } from "./context";
import { withPlayer } from "./with-player";
/** Contract, tutorial and reposition dispatches. */
export function registerDispatchRoutes(app: FastifyInstance, ctx: AppContext): void {
registerRoute(app, routes.createDispatch, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => {
registerRoute(app, routes.createDispatch, ctx.options, async (input, { playerId, request }) => {
const result = await withPlayer(ctx, playerId, async (tx, now) => {
switch (input.kind) {
case "contract":
return { dispatch: await dispatchContract(tx, playerId, input, now) };
@ -17,6 +18,19 @@ export function registerDispatchRoutes(app: FastifyInstance, ctx: AppContext): v
case "reposition":
return { dispatch: await dispatchReposition(tx, playerId, input, now) };
}
}),
);
});
// Resolve at arrival even if the player never comes back (Step 7.2). Sent after
// the dispatch commits; if sending fails, lazy resolution on read still covers it.
try {
await ctx.jobs.send(
RESOLVE_ARRIVALS_JOB,
{ playerId, dispatchId: result.dispatch.id },
ctx.toBaseTime(result.dispatch.arrivalAt),
);
} catch (error) {
request.log.error({ err: error }, "Could not schedule the arrival job");
}
return result;
});
}

View File

@ -0,0 +1,20 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { OffsetClock } from "../clock";
import { ApiError } from "../http/errors";
const bodySchema = z.object({ offsetMs: z.number().int() }).strict();
/**
* Test-only: shifts the server clock so E2E tests can fast-forward to arrivals
* (decision 0.5.18). Never registered in production; not part of the catalog.
*/
export function registerTestClockRoute(app: FastifyInstance, clock: OffsetClock): void {
app.post("/api/test/clock-offset", async (request) => {
const parsed = bodySchema.safeParse(request.body ?? {});
if (!parsed.success)
throw new ApiError(400, "validation-error", "Expected { offsetMs: integer }.");
clock.setOffset(parsed.data.offsetMs);
return { offsetMs: clock.offsetMs(), realTime: clock.now() };
});
}

View File

@ -0,0 +1,120 @@
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { companies, dispatches, ships } from "../src/db/schema";
import { RESOLVE_ARRIVALS_JOB, resolveArrivalsJob } from "../src/jobs/definitions";
import { createRecordingQueue } from "../src/jobs/queue";
import { makeOwner, setCredits, useTestDatabase } from "./db";
import { call, getState, putBoard, signUp, testContract, useTestApp } from "./game";
import { testConfig } from "./helpers";
const database = useTestDatabase();
const testApp = useTestApp(database);
/** An owner who has just dispatched a 2,000-credit Earth→Moon contract. */
async function dispatched() {
const { app } = testApp();
const { db } = database();
const guest = await signUp(app);
const shipId = await makeOwner(db, guest.playerId, guest.avatarId);
await setCredits(db, guest.playerId, 10_000);
const contract = testContract();
await putBoard(db, guest.playerId, [contract]);
const response = await call(app, guest, "POST", "/api/dispatches", {
kind: "contract",
contractId: contract.id,
shipId,
pilotId: guest.avatarId,
});
const dispatch = response.json().dispatch as { id: string; arrivalAt: number; fuelCost: number };
return { guest, shipId, dispatch };
}
const credits = async (playerId: string) =>
(await database().db.select().from(companies).where(eq(companies.playerId, playerId)))[0]!
.credits;
describe("arrival resolution job", () => {
it("is scheduled for the dispatch's arrival time", async () => {
const { jobs } = testApp();
const { guest, dispatch } = await dispatched();
expect(jobs.sent).toEqual([
{
name: RESOLVE_ARRIVALS_JOB,
data: { playerId: guest.playerId, dispatchId: dispatch.id },
startAfter: dispatch.arrivalAt,
},
]);
});
it("resolves the dispatch at arrival without any player state fetch", async () => {
const { clock, jobs } = testApp();
const { db } = database();
const { guest, shipId, dispatch } = await dispatched();
const jobCtx = { db, clock: clock.read };
// Too early: nothing happens.
clock.now = dispatch.arrivalAt - 1;
await resolveArrivalsJob(jobCtx, jobs.sent[0]!.data);
expect((await db.select().from(dispatches))[0]!.status).toBe("in_flight");
clock.now = dispatch.arrivalAt;
await resolveArrivalsJob(jobCtx, jobs.sent[0]!.data);
const [row] = await db.select().from(dispatches).where(eq(dispatches.id, dispatch.id));
expect(row!.status).toBe("resolved");
expect(await credits(guest.playerId)).toBe(10_000 - dispatch.fuelCost + 2000);
const [ship] = await db.select().from(ships).where(eq(ships.id, shipId));
expect(ship).toMatchObject({ status: "idle", locationId: "moon" });
});
it("does nothing if lazy resolution already happened", async () => {
const { app, clock, jobs } = testApp();
const { db } = database();
const { guest, dispatch } = await dispatched();
clock.now = dispatch.arrivalAt + 1000;
const state = await getState(app, guest);
await resolveArrivalsJob({ db, clock: clock.read }, jobs.sent[0]!.data);
expect(await credits(guest.playerId)).toBe(state.company.credits);
expect((await getState(app, guest)).summaries).toHaveLength(1);
});
});
describe("test-only clock offset endpoint", () => {
it("shifts the server clock outside production", async () => {
const { app, clock } = testApp();
const response = await app.inject({
method: "POST",
url: "/api/test/clock-offset",
payload: { offsetMs: 3_600_000 },
});
expect(response.statusCode).toBe(200);
const time = (await app.inject({ method: "GET", url: "/api/time" })).json();
expect(time.realTime).toBe(clock.now + 3_600_000);
});
it("schedules arrival jobs on the real clock while an offset is set", async () => {
const { app, jobs } = testApp();
await app.inject({
method: "POST",
url: "/api/test/clock-offset",
payload: { offsetMs: 60_000 },
});
const { dispatch } = await dispatched();
expect(jobs.sent[0]!.startAfter).toBe(dispatch.arrivalAt - 60_000);
});
it("does not exist in production", async () => {
const app = buildApp(testConfig({ NODE_ENV: "production" }), {
database: database(),
jobs: createRecordingQueue(),
});
const response = await app.inject({
method: "POST",
url: "/api/test/clock-offset",
payload: { offsetMs: 1 },
});
expect(response.statusCode).toBe(404);
await app.close();
});
});

View File

@ -0,0 +1,80 @@
import { content } from "@deadhead/content";
import { boardPeriodIndex, boardPeriodMs } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { acceptedContracts, contractBoards } from "../src/db/schema";
import { BOARD_RETENTION_PERIODS, cleanupExpiredContracts } from "../src/jobs/cleanup";
import { appJobs, CLEANUP_CONTRACTS_JOB } from "../src/jobs/definitions";
import { makeOwner, TEST_NOW, useTestDatabase } from "./db";
import { insertDispatch, putBoard, signUp, testContract, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("expired contract cleanup", () => {
it("deletes old boards and accepted contracts and keeps current ones", async () => {
const { db } = database();
const guest = await signUp(testApp().app);
const shipId = await makeOwner(db, guest.playerId, guest.avatarId);
const period = boardPeriodMs(content.config);
const current = boardPeriodIndex(TEST_NOW, content.config);
const old = current - BOARD_RETENTION_PERIODS - 1;
// A board from long ago and one from the current period.
await putBoard(db, guest.playerId, [testContract()], TEST_NOW - (current - old) * period);
await putBoard(db, guest.playerId, [testContract()], TEST_NOW);
// An accepted contract from each period, both pointing at real dispatches.
const record = await insertDispatch(db, {
playerId: guest.playerId,
shipId,
pilotId: guest.avatarId,
departAt: TEST_NOW,
});
await db.insert(acceptedContracts).values([
{
playerId: guest.playerId,
contractId: "old",
periodIndex: old,
dispatchId: record.id,
acceptedAt: TEST_NOW,
},
{
playerId: guest.playerId,
contractId: "new",
periodIndex: current,
dispatchId: record.id,
acceptedAt: TEST_NOW,
},
]);
expect(await cleanupExpiredContracts(db, TEST_NOW)).toEqual({
boards: 1,
acceptedContracts: 1,
});
expect((await db.select().from(contractBoards)).map((b) => b.periodIndex)).toEqual([current]);
expect((await db.select().from(acceptedContracts)).map((a) => a.contractId)).toEqual(["new"]);
});
it("keeps boards inside the retention window", async () => {
const { db } = database();
const guest = await signUp(testApp().app);
const period = boardPeriodMs(content.config);
await putBoard(
db,
guest.playerId,
[testContract()],
TEST_NOW - BOARD_RETENTION_PERIODS * period,
);
expect(await cleanupExpiredContracts(db, TEST_NOW)).toEqual({
boards: 0,
acceptedContracts: 0,
});
});
it("runs as an hourly job", () => {
const job = appJobs({ db: database().db, clock: () => TEST_NOW }).find(
(j) => j.name === CLEANUP_CONTRACTS_JOB,
);
expect(job?.cron).toBe("0 * * * *");
});
});

View File

@ -11,6 +11,7 @@ import {
import { eq } from "drizzle-orm";
import { afterEach, beforeEach } from "vitest";
import { buildApp } from "../src/app";
import { createRecordingQueue } from "../src/jobs/queue";
import type { Database, Db } from "../src/db/client";
import { contractBoards, dispatches, pilots, players, ships } from "../src/db/schema";
import { TEST_NOW } from "./db";
@ -28,6 +29,8 @@ export class TestClock {
export interface TestApp {
app: ReturnType<typeof buildApp>;
clock: TestClock;
/** Jobs the app scheduled; tests run their handlers directly. */
jobs: ReturnType<typeof createRecordingQueue>;
}
/** A fresh app per test on the shared test database, with a controllable clock. */
@ -35,7 +38,12 @@ export function useTestApp(database: () => Database): () => TestApp {
let current: TestApp | undefined;
beforeEach(() => {
const clock = new TestClock();
current = { app: buildApp(testConfig(), { database: database(), clock: clock.read }), clock };
const jobs = createRecordingQueue();
current = {
app: buildApp(testConfig(), { database: database(), clock: clock.read, jobs }),
clock,
jobs,
};
});
afterEach(() => current?.app.close());
return () => current!;

View File

@ -6,6 +6,7 @@ import {
} from "@deadhead/schema";
import { beforeEach, describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { createRecordingQueue } from "../src/jobs/queue";
import { useTestDatabase } from "./db";
import { testConfig } from "./helpers";
@ -20,7 +21,7 @@ function cookieFrom(setCookie: string | string[] | undefined): string {
describe("guest accounts", () => {
let app: ReturnType<typeof buildApp>;
beforeEach(() => {
app = buildApp(testConfig(), { database: database() });
app = buildApp(testConfig(), { database: database(), jobs: createRecordingQueue() });
return () => app.close();
});

View File

@ -1,10 +1,11 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { createRecordingQueue } from "../src/jobs/queue";
import { loadConfig } from "../src/config";
import { testConfig } from "./helpers";
describe("server skeleton", () => {
const app = buildApp(testConfig());
const app = buildApp(testConfig(), { jobs: createRecordingQueue() });
let baseUrl = "";
beforeAll(async () => {

View File

@ -12,6 +12,7 @@ export function testConfig(overrides: Record<string, string> = {}): ServerConfig
TEST_DATABASE_URL: testDatabaseUrl,
BETTER_AUTH_SECRET: "test-secret-that-is-at-least-32-characters-long",
BETTER_AUTH_URL: "http://localhost:3000",
WORKERS_ENABLED: "false",
...overrides,
});
}

View File

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { createPgBossQueue } from "../src/jobs/queue";
import { testConfig } from "./helpers";
/** Polls `check` until it returns true or the timeout passes. */
async function waitFor(check: () => Promise<boolean> | boolean, timeoutMs = 20_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error("Timed out waiting");
}
describe("pg-boss job queue", () => {
it("runs an enqueued no-op job to completion", async () => {
const handled: unknown[] = [];
const queue = createPgBossQueue(testConfig().databaseUrl, {
workers: true,
jobs: [{ name: "test-noop", handler: async (data) => void handled.push(data) }],
});
await queue.start();
try {
const id = await queue.send("test-noop", { hello: "world" });
expect(id).not.toBeNull();
await waitFor(() => handled.length > 0);
expect(handled).toEqual([{ hello: "world" }]);
await waitFor(
async () => (await queue.boss.getJobById("test-noop", id!))?.state === "completed",
);
} finally {
await queue.stop();
}
}, 30_000);
it("only enqueues when workers are disabled", async () => {
const handled: unknown[] = [];
const queue = createPgBossQueue(testConfig().databaseUrl, {
workers: false,
jobs: [{ name: "test-idle", handler: async (data) => void handled.push(data) }],
});
await queue.start();
try {
const id = await queue.send("test-idle", {});
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(handled).toEqual([]);
expect((await queue.boss.getJobById("test-idle", id!))?.state).toBe("created");
} finally {
await queue.stop();
}
}, 30_000);
});

View File

@ -3,10 +3,11 @@ import { serverTimeSchema } from "@deadhead/schema";
import { realToGame } from "@deadhead/sim";
import { afterAll, describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { createRecordingQueue } from "../src/jobs/queue";
import { testConfig } from "./helpers";
describe("server time endpoint", () => {
const app = buildApp(testConfig());
const app = buildApp(testConfig(), { jobs: createRecordingQueue() });
afterAll(() => app.close());
it("returns the current real time and the matching game time", async () => {
@ -19,7 +20,7 @@ describe("server time endpoint", () => {
it("reads time from the injected clock", async () => {
const fixed = Date.parse("2100-06-01T12:00:00Z");
const fixedApp = buildApp(testConfig(), { clock: () => fixed });
const fixedApp = buildApp(testConfig(), { clock: () => fixed, jobs: createRecordingQueue() });
const body = (await fixedApp.inject({ method: "GET", url: "/api/time" })).json();
expect(body).toEqual({ realTime: fixed, gameTime: realToGame(fixed, content.config) });
await fixedApp.close();

View File

@ -2,6 +2,7 @@ import { errorResponseSchema, routes } from "@deadhead/schema";
import Fastify from "fastify";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { createRecordingQueue } from "../src/jobs/queue";
import { registerErrorHandler } from "../src/http/errors";
import { registerRoute } from "../src/http/route";
import { useTestDatabase } from "./db";
@ -12,7 +13,7 @@ const database = useTestDatabase();
describe("request validation from the route catalog", () => {
let app: ReturnType<typeof buildApp>;
beforeEach(() => {
app = buildApp(testConfig(), { database: database() });
app = buildApp(testConfig(), { database: database(), jobs: createRecordingQueue() });
});
afterEach(() => app.close());

View File

@ -393,3 +393,14 @@ Handlers throw `ApiError(status, code, message)` for expected failures. Unknown
| `names.ts` | Seeded ship and pilot names |
Routes (`apps/server/src/routes`) wrap every authenticated action in `withPlayer`: lock, resolve due arrivals, run the action, then `maybeEndTutorial`. Game-rule failures are `409` with their error code; unknown things are `404`.
## Background jobs (`apps/server/src/jobs`)
pg-boss (`queue.ts`) in the app's Postgres database (schema `pgboss`), behind a `JobQueue` interface; tests use a recording fake and call handlers directly. Queues are always created; workers and cron schedules run only when `WORKERS_ENABLED=true`.
| Job | Trigger | Does |
|---|---|---|
| `resolve-arrivals` | Sent after each dispatch commits, `startAfter` = arrival (on the real clock) | `resolveArrivedDispatches` under the player lock; no-op if a request already resolved it |
| `cleanup-expired-contracts` | Cron `0 * * * *` | Deletes boards and accepted contracts older than 24 periods |
The app clock is `base clock + offset`; the offset is set only by the test-only `POST /api/test/clock-offset`, which is not registered in production.

View File

@ -68,3 +68,9 @@
- [x] **Step 6.12 — Spend skill points.** `POST /api/pilots/:pilotId/skills` (`spendSkillPoint`): +1 skill level, +1 point spent; `409 no-skill-points`, `409 skill-maxed`, `404 unknown-skill`.
- [x] **Step 6.13 — Acknowledge summaries.** `POST /api/summaries/acknowledge` (`src/game/summaries.ts`) sets `acknowledged_at` on the player's resolved, unacknowledged dispatches among the given ids and returns how many; resolved effects are untouched.
- [x] **Step 6.14 — Concurrency and money safety.** Every authenticated endpoint runs in `withPlayer` (transaction + company row lock). Race tests: two and eight simultaneous purchases, a contract dispatched twice at once, a bay leased twice at once; three of them fail when the lock is removed.
## Phase 7 — Background Workers
- [x] **Step 7.1 — pg-boss setup.** `src/jobs/queue.ts`: `JobQueue` interface, pg-boss implementation (queues always created; workers and cron schedules only when `WORKERS_ENABLED=true`), and a recording fake for tests. The app starts the queue on ready and stops it on close. Test runs a no-op job to completion with real pg-boss.
- [x] **Step 7.2 — Arrival resolution job.** Every dispatch (contract, tutorial, reposition) schedules a `resolve-arrivals` job at its arrival (`src/jobs/definitions.ts`), which runs the same locked resolution as lazy reads and is a no-op if already resolved. Clock is now base clock + offset; test-only `POST /api/test/clock-offset` (not registered in production) moves it.
- [x] **Step 7.3 — Expired contract cleanup.** Hourly `cleanup-expired-contracts` job (`src/jobs/cleanup.ts`) deletes stored boards and accepted-contract records more than 24 board periods old; current ones stay.

View File

@ -37,7 +37,7 @@ importers:
version: 8.71.0(eslint@10.12.0)(typescript@6.0.3)
vitest:
specifier: ^5.0.3
version: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.15))
version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.15))
apps/server:
dependencies:
@ -52,16 +52,19 @@ importers:
version: link:../../packages/sim
better-auth:
specifier: ^1.7.7
version: 1.7.7(drizzle-kit@0.31.11)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1))(pg@8.23.1)(vitest@5.0.3)
version: 1.7.7(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1))(pg@8.23.1)(vitest@5.0.3)
drizzle-orm:
specifier: ^0.45.3
version: 0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1)
version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1)
fastify:
specifier: ^5.12.5
version: 5.12.5
pg:
specifier: ^8.23.1
version: 8.23.1
pg-boss:
specifier: ^12.36.0
version: 12.36.0(@opentelemetry/api@1.9.1)
zod:
specifier: ^4.6.5
version: 4.6.5
@ -769,6 +772,10 @@ packages:
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
engines: {node: '>= 20.19.0'}
'@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
'@opentelemetry/semantic-conventions@1.43.0':
resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
engines: {node: '>=14'}
@ -1125,6 +1132,10 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cron-parser@5.10.1:
resolution: {integrity: sha512-pKRrRagItwk9rGIStcUyMxFX34x56zoX3KDf9HJ4ttwkXIK1qxK63EvXvEDmlxI9AdPJ+Y7TEKRftRlW5eSS7A==}
engines: {node: '>=18'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -1547,6 +1558,10 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
magic-string@1.4.2:
resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==}
@ -1572,6 +1587,10 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
non-error@0.1.0:
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
engines: {node: '>=20'}
obug@2.2.1:
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
engines: {node: '>=12.20.0'}
@ -1600,6 +1619,13 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
pg-boss@12.36.0:
resolution: {integrity: sha512-67Jj4xNuv8mkShTzfmsFtJeVa2ChXyrknjlw/WKZCIMuzRTwyHOUGjl2mf5Snrhmb0xglvFbkfAt5+hLtXukag==}
engines: {node: '>=22.12.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.9.0
pg-cloudflare@1.4.1:
resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==}
@ -1726,6 +1752,9 @@ packages:
rou3@0.9.2:
resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==}
rrule-temporal@2.2.8:
resolution: {integrity: sha512-+wWUe0J02oMh1UWKIt1RTu2H0Cz+gP11O/iweK0MN2UP6JLGeepTUkbSBa90Hxq0W+ywSxM9IlJNouIUdMOwhg==}
safe-regex2@5.1.1:
resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==}
hasBin: true
@ -1742,6 +1771,10 @@ packages:
engines: {node: '>=10'}
hasBin: true
serialize-error@13.0.2:
resolution: {integrity: sha512-G+YKwp9I6IvWMe5RlwqxdmTPIcq/G8HK/sU51gdV1XAJznYkRSTB0ph+zB9AfSxG0fx6xrqYA6i9HeIcHFTXow==}
engines: {node: '>=20'}
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
@ -1777,6 +1810,13 @@ packages:
std-env@4.3.0:
resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
temporal-spec@1.0.1:
resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==}
thread-stream@4.2.0:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'}
@ -1816,6 +1856,10 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
type-fest@5.10.0:
resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==}
engines: {node: '>=20'}
typescript-eslint@8.71.0:
resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -1960,7 +2004,7 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)':
'@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)':
dependencies:
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.2
@ -1971,39 +2015,41 @@ snapshots:
kysely: 0.29.6
nanostores: 1.5.4
zod: 4.6.5
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@better-auth/drizzle-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1))':
'@better-auth/drizzle-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1))':
dependencies:
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/utils': 0.4.2
optionalDependencies:
drizzle-orm: 0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1)
drizzle-orm: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1)
'@better-auth/kysely-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(kysely@0.29.6)':
'@better-auth/kysely-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(kysely@0.29.6)':
dependencies:
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/utils': 0.4.2
optionalDependencies:
kysely: 0.29.6
'@better-auth/memory-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)':
'@better-auth/memory-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)':
dependencies:
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/utils': 0.4.2
'@better-auth/mongo-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)':
'@better-auth/mongo-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)':
dependencies:
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/utils': 0.4.2
'@better-auth/prisma-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)':
'@better-auth/prisma-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)':
dependencies:
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/utils': 0.4.2
'@better-auth/telemetry@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)':
'@better-auth/telemetry@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)':
dependencies:
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.2
@ -2357,6 +2403,8 @@ snapshots:
'@noble/hashes@2.4.0': {}
'@opentelemetry/api@1.9.1': {}
'@opentelemetry/semantic-conventions@1.43.0': {}
'@oxc-project/types@0.152.0': {}
@ -2536,7 +2584,7 @@ snapshots:
obug: 2.2.1
std-env: 4.3.0
tinyrainbow: 3.2.0
vitest: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.15))
vitest: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.15))
'@vitest/istanbul-lib-coverage@1.0.2': {}
@ -2600,15 +2648,15 @@ snapshots:
balanced-match@4.0.4: {}
better-auth@1.7.7(drizzle-kit@0.31.11)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1))(pg@8.23.1)(vitest@5.0.3):
better-auth@1.7.7(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1))(pg@8.23.1)(vitest@5.0.3):
dependencies:
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1))
'@better-auth/kysely-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(kysely@0.29.6)
'@better-auth/memory-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)
'@better-auth/mongo-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)
'@better-auth/prisma-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)
'@better-auth/telemetry': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)
'@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)
'@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1))
'@better-auth/kysely-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(kysely@0.29.6)
'@better-auth/memory-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)
'@better-auth/mongo-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)
'@better-auth/prisma-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)
'@better-auth/telemetry': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.2
'@noble/ciphers': 2.4.0
@ -2621,9 +2669,9 @@ snapshots:
zod: 4.6.5
optionalDependencies:
drizzle-kit: 0.31.11
drizzle-orm: 0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1)
drizzle-orm: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1)
pg: 8.23.1
vitest: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.15))
vitest: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.15))
transitivePeerDependencies:
- '@opentelemetry/api'
@ -2654,6 +2702,10 @@ snapshots:
cookie@1.1.1: {}
cron-parser@5.10.1:
dependencies:
luxon: 3.7.2
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@ -2679,8 +2731,9 @@ snapshots:
esbuild: 0.25.12
tsx: 4.23.15
drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1):
drizzle-orm@0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.1):
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/pg': 8.23.1
kysely: 0.29.6
pg: 8.23.1
@ -3041,6 +3094,8 @@ snapshots:
dependencies:
p-locate: 5.0.0
luxon@3.7.2: {}
magic-string@1.4.2:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
@ -3063,6 +3118,8 @@ snapshots:
natural-compare@1.4.0: {}
non-error@0.1.0: {}
obug@2.2.1: {}
on-exit-leak-free@2.1.2: {}
@ -3088,6 +3145,16 @@ snapshots:
path-key@3.1.1: {}
pg-boss@12.36.0(@opentelemetry/api@1.9.1):
dependencies:
'@opentelemetry/api': 1.9.1
cron-parser: 5.10.1
pg: 8.23.1
rrule-temporal: 2.2.8
serialize-error: 13.0.2
transitivePeerDependencies:
- pg-native
pg-cloudflare@1.4.1:
optional: true
@ -3214,6 +3281,10 @@ snapshots:
rou3@0.9.2: {}
rrule-temporal@2.2.8:
dependencies:
temporal-spec: 1.0.1
safe-regex2@5.1.1:
dependencies:
ret: 0.5.0
@ -3224,6 +3295,11 @@ snapshots:
semver@7.8.5: {}
serialize-error@13.0.2:
dependencies:
non-error: 0.1.0
type-fest: 5.10.0
set-cookie-parser@2.7.2: {}
set-cookie-parser@3.1.2: {}
@ -3251,6 +3327,10 @@ snapshots:
std-env@4.3.0: {}
tagged-tag@1.0.0: {}
temporal-spec@1.0.1: {}
thread-stream@4.2.0:
dependencies:
real-require: 1.0.0
@ -3282,6 +3362,10 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
type-fest@5.10.0:
dependencies:
tagged-tag: 1.0.0
typescript-eslint@8.71.0(eslint@10.12.0)(typescript@6.0.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.12.0)(typescript@6.0.3))(eslint@10.12.0)(typescript@6.0.3)
@ -3314,7 +3398,7 @@ snapshots:
fsevents: 2.3.3
tsx: 4.23.15
vitest@5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.15)):
vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.15)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.3(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.15))
@ -3331,6 +3415,7 @@ snapshots:
vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.15)
why-is-node-running: 3.2.1
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 24.19.1
'@vitest/coverage-v8': 5.0.3(vitest@5.0.3)
transitivePeerDependencies: