Step 7.1: add pg-boss job queue

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 01:34:21 -04:00
parent 0044e355f1
commit 9847a7ae18
16 changed files with 295 additions and 37 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,10 @@ 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).

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

@ -5,6 +5,7 @@ import type { ServerConfig } from "./config";
import { createDatabase, type Database } from "./db/client";
import { registerErrorHandler } from "./http/errors";
import { requirePlayerId } from "./http/session";
import { createPgBossQueue, type JobQueue } from "./jobs/queue";
import type { AppContext } from "./routes/context";
import { registerBoardRoutes } from "./routes/boards";
import { registerDispatchRoutes } from "./routes/dispatches";
@ -15,6 +16,8 @@ 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 +25,14 @@ 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 jobs =
deps.jobs ??
createPgBossQueue(config.databaseUrl, { workers: config.workersEnabled, jobs: [] });
const ctx: AppContext = {
db,
auth,
clock: deps.clock ?? systemClock,
jobs,
options: {
requirePlayer: (request) => requirePlayerId(request, auth, db),
validateResponses: config.env !== "production",
@ -36,7 +43,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).

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,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,13 @@ 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;
jobs: JobQueue;
options: RouteOptions;
}

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";
@ -35,7 +36,14 @@ 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 };
current = {
app: buildApp(testConfig(), {
database: database(),
clock: clock.read,
jobs: createRecordingQueue(),
}),
clock,
};
});
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

@ -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.
- [ ] Step 7.2 — Arrival resolution job
- [ ] Step 7.3 — Expired contract cleanup

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: