diff --git a/.env.example b/.env.example index 164e7d9..812419f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/DECISIONS.md b/DECISIONS.md index cc6aedf..2ac3a80 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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). diff --git a/apps/server/package.json b/apps/server/package.json index d224eb4..5a210ba 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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": { diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 53d7627..46b20e0 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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). diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 045e033..9778ba5 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -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): ServerConfi testDatabaseUrl: e.TEST_DATABASE_URL, authSecret: e.BETTER_AUTH_SECRET, authUrl: e.BETTER_AUTH_URL, + workersEnabled: e.WORKERS_ENABLED === "true", }; } diff --git a/apps/server/src/jobs/queue.ts b/apps/server/src/jobs/queue.ts new file mode 100644 index 0000000..7d651f0 --- /dev/null +++ b/apps/server/src/jobs/queue.ts @@ -0,0 +1,72 @@ +import { PgBoss } from "pg-boss"; + +export type JobHandler = (data: Record) => Promise; + +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; + stop(): Promise; + /** Enqueues a job, optionally not before `startAfter` (Unix ms). */ + send(name: string, data: Record, startAfter?: number): Promise; +} + +/** + * 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>(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; startAfter?: number }[]; +} { + const sent: { name: string; data: Record; startAfter?: number }[] = []; + return { + sent, + async start() {}, + async stop() {}, + async send(name, data, startAfter) { + sent.push({ name, data, ...(startAfter === undefined ? {} : { startAfter }) }); + return `recorded-${sent.length}`; + }, + }; +} diff --git a/apps/server/src/routes/context.ts b/apps/server/src/routes/context.ts index 5f38607..6648c0b 100644 --- a/apps/server/src/routes/context.ts +++ b/apps/server/src/routes/context.ts @@ -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; } diff --git a/apps/server/test/game.ts b/apps/server/test/game.ts index 7e55672..cfc2e33 100644 --- a/apps/server/test/game.ts +++ b/apps/server/test/game.ts @@ -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!; diff --git a/apps/server/test/guest.test.ts b/apps/server/test/guest.test.ts index 87c3cda..651dcb3 100644 --- a/apps/server/test/guest.test.ts +++ b/apps/server/test/guest.test.ts @@ -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; beforeEach(() => { - app = buildApp(testConfig(), { database: database() }); + app = buildApp(testConfig(), { database: database(), jobs: createRecordingQueue() }); return () => app.close(); }); diff --git a/apps/server/test/health.test.ts b/apps/server/test/health.test.ts index 3811725..45f18a8 100644 --- a/apps/server/test/health.test.ts +++ b/apps/server/test/health.test.ts @@ -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 () => { diff --git a/apps/server/test/helpers.ts b/apps/server/test/helpers.ts index 1b10ac4..0dfb0a8 100644 --- a/apps/server/test/helpers.ts +++ b/apps/server/test/helpers.ts @@ -12,6 +12,7 @@ export function testConfig(overrides: Record = {}): 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, }); } diff --git a/apps/server/test/jobs.test.ts b/apps/server/test/jobs.test.ts new file mode 100644 index 0000000..87e4145 --- /dev/null +++ b/apps/server/test/jobs.test.ts @@ -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, 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); +}); diff --git a/apps/server/test/time.test.ts b/apps/server/test/time.test.ts index fa4dc8d..02489d8 100644 --- a/apps/server/test/time.test.ts +++ b/apps/server/test/time.test.ts @@ -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(); diff --git a/apps/server/test/validation.test.ts b/apps/server/test/validation.test.ts index f3a11b8..1d53232 100644 --- a/apps/server/test/validation.test.ts +++ b/apps/server/test/validation.test.ts @@ -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; beforeEach(() => { - app = buildApp(testConfig(), { database: database() }); + app = buildApp(testConfig(), { database: database(), jobs: createRecordingQueue() }); }); afterEach(() => app.close()); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 7503399..02d876c 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c3a534..6820674 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: