Compare commits
3 Commits
0044e355f1
...
e58e99c156
| Author | SHA1 | Date |
|---|---|---|
|
|
e58e99c156 | |
|
|
11f3bb1897 | |
|
|
9847a7ae18 |
|
|
@ -11,3 +11,6 @@ BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
# HTTP port the API server listens on
|
# HTTP port the API server listens on
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
# Run background job workers (arrival resolution, cleanup) in the API process
|
||||||
|
WORKERS_ENABLED=true
|
||||||
|
|
|
||||||
20
DECISIONS.md
20
DECISIONS.md
|
|
@ -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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"drizzle-orm": "^0.45.3",
|
"drizzle-orm": "^0.45.3",
|
||||||
"fastify": "^5.12.5",
|
"fastify": "^5.12.5",
|
||||||
"pg": "^8.23.1",
|
"pg": "^8.23.1",
|
||||||
|
"pg-boss": "^12.36.0",
|
||||||
"zod": "^4.6.5"
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,25 @@
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { createAuth } from "./auth";
|
import { createAuth } from "./auth";
|
||||||
import { type Clock, systemClock } from "./clock";
|
import { type Clock, createOffsetClock, systemClock } from "./clock";
|
||||||
import type { ServerConfig } from "./config";
|
import type { ServerConfig } from "./config";
|
||||||
import { createDatabase, type Database } from "./db/client";
|
import { createDatabase, type Database } from "./db/client";
|
||||||
import { registerErrorHandler } from "./http/errors";
|
import { registerErrorHandler } from "./http/errors";
|
||||||
import { requirePlayerId } from "./http/session";
|
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 { registerBoardRoutes } from "./routes/boards";
|
||||||
|
import type { AppContext } from "./routes/context";
|
||||||
import { registerDispatchRoutes } from "./routes/dispatches";
|
import { registerDispatchRoutes } from "./routes/dispatches";
|
||||||
import { registerFleetRoutes } from "./routes/fleet";
|
import { registerFleetRoutes } from "./routes/fleet";
|
||||||
import { registerPlayerRoutes } from "./routes/player";
|
import { registerPlayerRoutes } from "./routes/player";
|
||||||
|
import { registerTestClockRoute } from "./routes/test-clock";
|
||||||
|
|
||||||
export interface AppDeps {
|
export interface AppDeps {
|
||||||
clock?: Clock;
|
clock?: Clock;
|
||||||
/** An existing database (tests). Otherwise the app opens one and closes it on shutdown. */
|
/** An existing database (tests). Otherwise the app opens one and closes it on shutdown. */
|
||||||
database?: Database;
|
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. */
|
/** 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 database = deps.database ?? createDatabase(config.databaseUrl);
|
||||||
const { db } = database;
|
const { db } = database;
|
||||||
const auth = createAuth(db, { secret: config.authSecret, baseUrl: config.authUrl });
|
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 = {
|
const ctx: AppContext = {
|
||||||
db,
|
db,
|
||||||
auth,
|
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: {
|
options: {
|
||||||
requirePlayer: (request) => requirePlayerId(request, auth, db),
|
requirePlayer: (request) => requirePlayerId(request, auth, db),
|
||||||
validateResponses: config.env !== "production",
|
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.
|
// Structured JSON logs (pino); silenced in tests unless LOG_LEVEL says otherwise.
|
||||||
logger: { level: config.logLevel },
|
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);
|
registerErrorHandler(app);
|
||||||
|
|
||||||
// Under /api so it is reachable through the same-domain proxy (decision 0.5.20).
|
// 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);
|
registerBoardRoutes(app, ctx);
|
||||||
registerDispatchRoutes(app, ctx);
|
registerDispatchRoutes(app, ctx);
|
||||||
registerFleetRoutes(app, ctx);
|
registerFleetRoutes(app, ctx);
|
||||||
|
if (config.env !== "production") registerTestClockRoute(app, clock);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,27 @@ import type { RealTime } from "@deadhead/sim";
|
||||||
export type Clock = () => RealTime;
|
export type Clock = () => RealTime;
|
||||||
|
|
||||||
export const systemClock: Clock = () => Date.now();
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ const envSchema = z.object({
|
||||||
TEST_DATABASE_URL: z.url().optional(),
|
TEST_DATABASE_URL: z.url().optional(),
|
||||||
BETTER_AUTH_SECRET: z.string().min(32),
|
BETTER_AUTH_SECRET: z.string().min(32),
|
||||||
BETTER_AUTH_URL: z.url(),
|
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 {
|
export interface ServerConfig {
|
||||||
|
|
@ -19,6 +21,7 @@ export interface ServerConfig {
|
||||||
testDatabaseUrl: string | undefined;
|
testDatabaseUrl: string | undefined;
|
||||||
authSecret: string;
|
authSecret: string;
|
||||||
authUrl: string;
|
authUrl: string;
|
||||||
|
workersEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validates the environment, throwing one error that lists every problem. */
|
/** 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,
|
testDatabaseUrl: e.TEST_DATABASE_URL,
|
||||||
authSecret: e.BETTER_AUTH_SECRET,
|
authSecret: e.BETTER_AUTH_SECRET,
|
||||||
authUrl: e.BETTER_AUTH_URL,
|
authUrl: e.BETTER_AUTH_URL,
|
||||||
|
workersEnabled: e.WORKERS_ENABLED === "true",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -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}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -2,11 +2,15 @@ import type { Auth } from "../auth";
|
||||||
import type { Clock } from "../clock";
|
import type { Clock } from "../clock";
|
||||||
import type { Db } from "../db/client";
|
import type { Db } from "../db/client";
|
||||||
import type { RouteOptions } from "../http/route";
|
import type { RouteOptions } from "../http/route";
|
||||||
|
import type { JobQueue } from "../jobs/queue";
|
||||||
|
|
||||||
/** What every route module needs. */
|
/** What every route module needs. */
|
||||||
export interface AppContext {
|
export interface AppContext {
|
||||||
db: Db;
|
db: Db;
|
||||||
auth: Auth;
|
auth: Auth;
|
||||||
clock: Clock;
|
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;
|
options: RouteOptions;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@ import { routes } from "@deadhead/schema";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { dispatchContract, dispatchReposition, dispatchTutorial } from "../game/dispatch";
|
import { dispatchContract, dispatchReposition, dispatchTutorial } from "../game/dispatch";
|
||||||
import { registerRoute } from "../http/route";
|
import { registerRoute } from "../http/route";
|
||||||
|
import { RESOLVE_ARRIVALS_JOB } from "../jobs/definitions";
|
||||||
import type { AppContext } from "./context";
|
import type { AppContext } from "./context";
|
||||||
import { withPlayer } from "./with-player";
|
import { withPlayer } from "./with-player";
|
||||||
|
|
||||||
/** Contract, tutorial and reposition dispatches. */
|
/** Contract, tutorial and reposition dispatches. */
|
||||||
export function registerDispatchRoutes(app: FastifyInstance, ctx: AppContext): void {
|
export function registerDispatchRoutes(app: FastifyInstance, ctx: AppContext): void {
|
||||||
registerRoute(app, routes.createDispatch, ctx.options, async (input, { playerId }) =>
|
registerRoute(app, routes.createDispatch, ctx.options, async (input, { playerId, request }) => {
|
||||||
withPlayer(ctx, playerId, async (tx, now) => {
|
const result = await withPlayer(ctx, playerId, async (tx, now) => {
|
||||||
switch (input.kind) {
|
switch (input.kind) {
|
||||||
case "contract":
|
case "contract":
|
||||||
return { dispatch: await dispatchContract(tx, playerId, input, now) };
|
return { dispatch: await dispatchContract(tx, playerId, input, now) };
|
||||||
|
|
@ -17,6 +18,19 @@ export function registerDispatchRoutes(app: FastifyInstance, ctx: AppContext): v
|
||||||
case "reposition":
|
case "reposition":
|
||||||
return { dispatch: await dispatchReposition(tx, playerId, input, now) };
|
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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 * * * *");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { afterEach, beforeEach } from "vitest";
|
import { afterEach, beforeEach } from "vitest";
|
||||||
import { buildApp } from "../src/app";
|
import { buildApp } from "../src/app";
|
||||||
|
import { createRecordingQueue } from "../src/jobs/queue";
|
||||||
import type { Database, Db } from "../src/db/client";
|
import type { Database, Db } from "../src/db/client";
|
||||||
import { contractBoards, dispatches, pilots, players, ships } from "../src/db/schema";
|
import { contractBoards, dispatches, pilots, players, ships } from "../src/db/schema";
|
||||||
import { TEST_NOW } from "./db";
|
import { TEST_NOW } from "./db";
|
||||||
|
|
@ -28,6 +29,8 @@ export class TestClock {
|
||||||
export interface TestApp {
|
export interface TestApp {
|
||||||
app: ReturnType<typeof buildApp>;
|
app: ReturnType<typeof buildApp>;
|
||||||
clock: TestClock;
|
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. */
|
/** 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;
|
let current: TestApp | undefined;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const clock = new TestClock();
|
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());
|
afterEach(() => current?.app.close());
|
||||||
return () => current!;
|
return () => current!;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
} from "@deadhead/schema";
|
} from "@deadhead/schema";
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { buildApp } from "../src/app";
|
import { buildApp } from "../src/app";
|
||||||
|
import { createRecordingQueue } from "../src/jobs/queue";
|
||||||
import { useTestDatabase } from "./db";
|
import { useTestDatabase } from "./db";
|
||||||
import { testConfig } from "./helpers";
|
import { testConfig } from "./helpers";
|
||||||
|
|
||||||
|
|
@ -20,7 +21,7 @@ function cookieFrom(setCookie: string | string[] | undefined): string {
|
||||||
describe("guest accounts", () => {
|
describe("guest accounts", () => {
|
||||||
let app: ReturnType<typeof buildApp>;
|
let app: ReturnType<typeof buildApp>;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
app = buildApp(testConfig(), { database: database() });
|
app = buildApp(testConfig(), { database: database(), jobs: createRecordingQueue() });
|
||||||
return () => app.close();
|
return () => app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { buildApp } from "../src/app";
|
import { buildApp } from "../src/app";
|
||||||
|
import { createRecordingQueue } from "../src/jobs/queue";
|
||||||
import { loadConfig } from "../src/config";
|
import { loadConfig } from "../src/config";
|
||||||
import { testConfig } from "./helpers";
|
import { testConfig } from "./helpers";
|
||||||
|
|
||||||
describe("server skeleton", () => {
|
describe("server skeleton", () => {
|
||||||
const app = buildApp(testConfig());
|
const app = buildApp(testConfig(), { jobs: createRecordingQueue() });
|
||||||
let baseUrl = "";
|
let baseUrl = "";
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export function testConfig(overrides: Record<string, string> = {}): ServerConfig
|
||||||
TEST_DATABASE_URL: testDatabaseUrl,
|
TEST_DATABASE_URL: testDatabaseUrl,
|
||||||
BETTER_AUTH_SECRET: "test-secret-that-is-at-least-32-characters-long",
|
BETTER_AUTH_SECRET: "test-secret-that-is-at-least-32-characters-long",
|
||||||
BETTER_AUTH_URL: "http://localhost:3000",
|
BETTER_AUTH_URL: "http://localhost:3000",
|
||||||
|
WORKERS_ENABLED: "false",
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -3,10 +3,11 @@ import { serverTimeSchema } from "@deadhead/schema";
|
||||||
import { realToGame } from "@deadhead/sim";
|
import { realToGame } from "@deadhead/sim";
|
||||||
import { afterAll, describe, expect, it } from "vitest";
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
import { buildApp } from "../src/app";
|
import { buildApp } from "../src/app";
|
||||||
|
import { createRecordingQueue } from "../src/jobs/queue";
|
||||||
import { testConfig } from "./helpers";
|
import { testConfig } from "./helpers";
|
||||||
|
|
||||||
describe("server time endpoint", () => {
|
describe("server time endpoint", () => {
|
||||||
const app = buildApp(testConfig());
|
const app = buildApp(testConfig(), { jobs: createRecordingQueue() });
|
||||||
afterAll(() => app.close());
|
afterAll(() => app.close());
|
||||||
|
|
||||||
it("returns the current real time and the matching game time", async () => {
|
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 () => {
|
it("reads time from the injected clock", async () => {
|
||||||
const fixed = Date.parse("2100-06-01T12:00:00Z");
|
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();
|
const body = (await fixedApp.inject({ method: "GET", url: "/api/time" })).json();
|
||||||
expect(body).toEqual({ realTime: fixed, gameTime: realToGame(fixed, content.config) });
|
expect(body).toEqual({ realTime: fixed, gameTime: realToGame(fixed, content.config) });
|
||||||
await fixedApp.close();
|
await fixedApp.close();
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { errorResponseSchema, routes } from "@deadhead/schema";
|
||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { buildApp } from "../src/app";
|
import { buildApp } from "../src/app";
|
||||||
|
import { createRecordingQueue } from "../src/jobs/queue";
|
||||||
import { registerErrorHandler } from "../src/http/errors";
|
import { registerErrorHandler } from "../src/http/errors";
|
||||||
import { registerRoute } from "../src/http/route";
|
import { registerRoute } from "../src/http/route";
|
||||||
import { useTestDatabase } from "./db";
|
import { useTestDatabase } from "./db";
|
||||||
|
|
@ -12,7 +13,7 @@ const database = useTestDatabase();
|
||||||
describe("request validation from the route catalog", () => {
|
describe("request validation from the route catalog", () => {
|
||||||
let app: ReturnType<typeof buildApp>;
|
let app: ReturnType<typeof buildApp>;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
app = buildApp(testConfig(), { database: database() });
|
app = buildApp(testConfig(), { database: database(), jobs: createRecordingQueue() });
|
||||||
});
|
});
|
||||||
afterEach(() => app.close());
|
afterEach(() => app.close());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -393,3 +393,14 @@ Handlers throw `ApiError(status, code, message)` for expected failures. Unknown
|
||||||
| `names.ts` | Seeded ship and pilot names |
|
| `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`.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.
|
- [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.
|
||||||
|
|
|
||||||
145
pnpm-lock.yaml
145
pnpm-lock.yaml
|
|
@ -37,7 +37,7 @@ importers:
|
||||||
version: 8.71.0(eslint@10.12.0)(typescript@6.0.3)
|
version: 8.71.0(eslint@10.12.0)(typescript@6.0.3)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^5.0.3
|
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:
|
apps/server:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -52,16 +52,19 @@ importers:
|
||||||
version: link:../../packages/sim
|
version: link:../../packages/sim
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.7.7
|
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:
|
drizzle-orm:
|
||||||
specifier: ^0.45.3
|
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:
|
fastify:
|
||||||
specifier: ^5.12.5
|
specifier: ^5.12.5
|
||||||
version: 5.12.5
|
version: 5.12.5
|
||||||
pg:
|
pg:
|
||||||
specifier: ^8.23.1
|
specifier: ^8.23.1
|
||||||
version: 8.23.1
|
version: 8.23.1
|
||||||
|
pg-boss:
|
||||||
|
specifier: ^12.36.0
|
||||||
|
version: 12.36.0(@opentelemetry/api@1.9.1)
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.6.5
|
specifier: ^4.6.5
|
||||||
version: 4.6.5
|
version: 4.6.5
|
||||||
|
|
@ -769,6 +772,10 @@ packages:
|
||||||
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
|
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
|
||||||
engines: {node: '>= 20.19.0'}
|
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':
|
'@opentelemetry/semantic-conventions@1.43.0':
|
||||||
resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
|
resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
@ -1125,6 +1132,10 @@ packages:
|
||||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
cron-parser@5.10.1:
|
||||||
|
resolution: {integrity: sha512-pKRrRagItwk9rGIStcUyMxFX34x56zoX3KDf9HJ4ttwkXIK1qxK63EvXvEDmlxI9AdPJ+Y7TEKRftRlW5eSS7A==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -1547,6 +1558,10 @@ packages:
|
||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
luxon@3.7.2:
|
||||||
|
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
magic-string@1.4.2:
|
magic-string@1.4.2:
|
||||||
resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==}
|
resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==}
|
||||||
|
|
||||||
|
|
@ -1572,6 +1587,10 @@ packages:
|
||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
|
|
||||||
|
non-error@0.1.0:
|
||||||
|
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
obug@2.2.1:
|
obug@2.2.1:
|
||||||
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
|
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
|
|
@ -1600,6 +1619,13 @@ packages:
|
||||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||||
engines: {node: '>=8'}
|
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:
|
pg-cloudflare@1.4.1:
|
||||||
resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==}
|
resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==}
|
||||||
|
|
||||||
|
|
@ -1726,6 +1752,9 @@ packages:
|
||||||
rou3@0.9.2:
|
rou3@0.9.2:
|
||||||
resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==}
|
resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==}
|
||||||
|
|
||||||
|
rrule-temporal@2.2.8:
|
||||||
|
resolution: {integrity: sha512-+wWUe0J02oMh1UWKIt1RTu2H0Cz+gP11O/iweK0MN2UP6JLGeepTUkbSBa90Hxq0W+ywSxM9IlJNouIUdMOwhg==}
|
||||||
|
|
||||||
safe-regex2@5.1.1:
|
safe-regex2@5.1.1:
|
||||||
resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==}
|
resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
@ -1742,6 +1771,10 @@ packages:
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
serialize-error@13.0.2:
|
||||||
|
resolution: {integrity: sha512-G+YKwp9I6IvWMe5RlwqxdmTPIcq/G8HK/sU51gdV1XAJznYkRSTB0ph+zB9AfSxG0fx6xrqYA6i9HeIcHFTXow==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
set-cookie-parser@2.7.2:
|
set-cookie-parser@2.7.2:
|
||||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||||
|
|
||||||
|
|
@ -1777,6 +1810,13 @@ packages:
|
||||||
std-env@4.3.0:
|
std-env@4.3.0:
|
||||||
resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}
|
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:
|
thread-stream@4.2.0:
|
||||||
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
|
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
@ -1816,6 +1856,10 @@ packages:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
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:
|
typescript-eslint@8.71.0:
|
||||||
resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==}
|
resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
@ -1960,7 +2004,7 @@ snapshots:
|
||||||
|
|
||||||
'@bcoe/v8-coverage@1.0.2': {}
|
'@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:
|
dependencies:
|
||||||
'@better-auth/utils': 0.4.2
|
'@better-auth/utils': 0.4.2
|
||||||
'@better-fetch/fetch': 1.3.2
|
'@better-fetch/fetch': 1.3.2
|
||||||
|
|
@ -1971,39 +2015,41 @@ snapshots:
|
||||||
kysely: 0.29.6
|
kysely: 0.29.6
|
||||||
nanostores: 1.5.4
|
nanostores: 1.5.4
|
||||||
zod: 4.6.5
|
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:
|
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/utils': 0.4.2
|
||||||
optionalDependencies:
|
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:
|
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/utils': 0.4.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
kysely: 0.29.6
|
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:
|
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/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:
|
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/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:
|
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/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:
|
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/utils': 0.4.2
|
||||||
'@better-fetch/fetch': 1.3.2
|
'@better-fetch/fetch': 1.3.2
|
||||||
|
|
||||||
|
|
@ -2357,6 +2403,8 @@ snapshots:
|
||||||
|
|
||||||
'@noble/hashes@2.4.0': {}
|
'@noble/hashes@2.4.0': {}
|
||||||
|
|
||||||
|
'@opentelemetry/api@1.9.1': {}
|
||||||
|
|
||||||
'@opentelemetry/semantic-conventions@1.43.0': {}
|
'@opentelemetry/semantic-conventions@1.43.0': {}
|
||||||
|
|
||||||
'@oxc-project/types@0.152.0': {}
|
'@oxc-project/types@0.152.0': {}
|
||||||
|
|
@ -2536,7 +2584,7 @@ snapshots:
|
||||||
obug: 2.2.1
|
obug: 2.2.1
|
||||||
std-env: 4.3.0
|
std-env: 4.3.0
|
||||||
tinyrainbow: 3.2.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': {}
|
'@vitest/istanbul-lib-coverage@1.0.2': {}
|
||||||
|
|
||||||
|
|
@ -2600,15 +2648,15 @@ snapshots:
|
||||||
|
|
||||||
balanced-match@4.0.4: {}
|
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:
|
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/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))
|
||||||
'@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)
|
||||||
'@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)
|
||||||
'@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)
|
||||||
'@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)
|
||||||
'@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)
|
||||||
'@better-auth/utils': 0.4.2
|
'@better-auth/utils': 0.4.2
|
||||||
'@better-fetch/fetch': 1.3.2
|
'@better-fetch/fetch': 1.3.2
|
||||||
'@noble/ciphers': 2.4.0
|
'@noble/ciphers': 2.4.0
|
||||||
|
|
@ -2621,9 +2669,9 @@ snapshots:
|
||||||
zod: 4.6.5
|
zod: 4.6.5
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
drizzle-kit: 0.31.11
|
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
|
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:
|
transitivePeerDependencies:
|
||||||
- '@opentelemetry/api'
|
- '@opentelemetry/api'
|
||||||
|
|
||||||
|
|
@ -2654,6 +2702,10 @@ snapshots:
|
||||||
|
|
||||||
cookie@1.1.1: {}
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
|
cron-parser@5.10.1:
|
||||||
|
dependencies:
|
||||||
|
luxon: 3.7.2
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
|
|
@ -2679,8 +2731,9 @@ snapshots:
|
||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
tsx: 4.23.15
|
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:
|
optionalDependencies:
|
||||||
|
'@opentelemetry/api': 1.9.1
|
||||||
'@types/pg': 8.23.1
|
'@types/pg': 8.23.1
|
||||||
kysely: 0.29.6
|
kysely: 0.29.6
|
||||||
pg: 8.23.1
|
pg: 8.23.1
|
||||||
|
|
@ -3041,6 +3094,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
|
|
||||||
|
luxon@3.7.2: {}
|
||||||
|
|
||||||
magic-string@1.4.2:
|
magic-string@1.4.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.6.0
|
'@jridgewell/sourcemap-codec': 1.6.0
|
||||||
|
|
@ -3063,6 +3118,8 @@ snapshots:
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
|
non-error@0.1.0: {}
|
||||||
|
|
||||||
obug@2.2.1: {}
|
obug@2.2.1: {}
|
||||||
|
|
||||||
on-exit-leak-free@2.1.2: {}
|
on-exit-leak-free@2.1.2: {}
|
||||||
|
|
@ -3088,6 +3145,16 @@ snapshots:
|
||||||
|
|
||||||
path-key@3.1.1: {}
|
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:
|
pg-cloudflare@1.4.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3214,6 +3281,10 @@ snapshots:
|
||||||
|
|
||||||
rou3@0.9.2: {}
|
rou3@0.9.2: {}
|
||||||
|
|
||||||
|
rrule-temporal@2.2.8:
|
||||||
|
dependencies:
|
||||||
|
temporal-spec: 1.0.1
|
||||||
|
|
||||||
safe-regex2@5.1.1:
|
safe-regex2@5.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ret: 0.5.0
|
ret: 0.5.0
|
||||||
|
|
@ -3224,6 +3295,11 @@ snapshots:
|
||||||
|
|
||||||
semver@7.8.5: {}
|
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@2.7.2: {}
|
||||||
|
|
||||||
set-cookie-parser@3.1.2: {}
|
set-cookie-parser@3.1.2: {}
|
||||||
|
|
@ -3251,6 +3327,10 @@ snapshots:
|
||||||
|
|
||||||
std-env@4.3.0: {}
|
std-env@4.3.0: {}
|
||||||
|
|
||||||
|
tagged-tag@1.0.0: {}
|
||||||
|
|
||||||
|
temporal-spec@1.0.1: {}
|
||||||
|
|
||||||
thread-stream@4.2.0:
|
thread-stream@4.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
real-require: 1.0.0
|
real-require: 1.0.0
|
||||||
|
|
@ -3282,6 +3362,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
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):
|
typescript-eslint@8.71.0(eslint@10.12.0)(typescript@6.0.3):
|
||||||
dependencies:
|
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)
|
'@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
|
fsevents: 2.3.3
|
||||||
tsx: 4.23.15
|
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:
|
dependencies:
|
||||||
'@types/chai': 5.2.3
|
'@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))
|
'@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)
|
vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(tsx@4.23.15)
|
||||||
why-is-node-running: 3.2.1
|
why-is-node-running: 3.2.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@opentelemetry/api': 1.9.1
|
||||||
'@types/node': 24.19.1
|
'@types/node': 24.19.1
|
||||||
'@vitest/coverage-v8': 5.0.3(vitest@5.0.3)
|
'@vitest/coverage-v8': 5.0.3(vitest@5.0.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue