Add pg-boss background job queue with session expiry
Add @trails-cool/jobs package wrapping pg-boss for durable background job execution using the existing PostgreSQL database. Wire up planner session expiry as the first scheduled job (hourly, 7-day TTL) — this was previously dead code that never ran. Add placeholder worker to journal for future Komoot import and federation jobs. Infrastructure: grant grafana_reader access to pgboss schema, add job queue health panels to Service Health dashboard, add failed job alert. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fc80e66ad4
commit
32c5fbde8f
25 changed files with 614 additions and 6 deletions
|
|
@ -20,6 +20,7 @@
|
|||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@trails-cool/api": "workspace:*",
|
||||
"@trails-cool/db": "workspace:*",
|
||||
"@trails-cool/jobs": "workspace:*",
|
||||
"@trails-cool/gpx": "workspace:*",
|
||||
"@trails-cool/i18n": "workspace:*",
|
||||
"@trails-cool/map": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createReadStream, statSync } from "node:fs";
|
|||
import { join, extname, resolve } from "node:path";
|
||||
import { logger } from "./app/lib/logger.server.ts";
|
||||
import { httpRequestDuration, registry } from "./app/lib/metrics.server.ts";
|
||||
import { createBoss, startWorker } from "@trails-cool/jobs";
|
||||
import postgres from "postgres";
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
|
|
@ -98,4 +99,9 @@ server.listen(port, async () => {
|
|||
// Seed first-party OAuth2 clients
|
||||
const { seedOAuthClient } = await import("./app/lib/oauth.server.ts");
|
||||
await seedOAuthClient("trails-cool-mobile", "trailscool://auth/callback", true);
|
||||
|
||||
// Start background job worker (no jobs yet — placeholder for Komoot import and federation)
|
||||
const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails");
|
||||
await startWorker(boss, []);
|
||||
logger.info("Background job worker started");
|
||||
});
|
||||
|
|
|
|||
30
apps/planner/app/jobs/expire-sessions.test.ts
Normal file
30
apps/planner/app/jobs/expire-sessions.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("../lib/sessions.ts", () => ({
|
||||
expireSessions: vi.fn().mockResolvedValue(5),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/logger.server.ts", () => ({
|
||||
logger: { info: vi.fn() },
|
||||
}));
|
||||
|
||||
describe("expireSessionsJob", () => {
|
||||
it("calls expireSessions with 7-day TTL and returns count", async () => {
|
||||
const { expireSessionsJob } = await import("./expire-sessions.ts");
|
||||
const { expireSessions } = await import("../lib/sessions.ts");
|
||||
|
||||
const result = await expireSessionsJob.handler({ id: "test", name: "expire-sessions", data: {} } as never);
|
||||
|
||||
expect(expireSessions).toHaveBeenCalledWith(7);
|
||||
expect(result).toEqual({ expiredCount: 5 });
|
||||
});
|
||||
|
||||
it("has correct job configuration", async () => {
|
||||
const { expireSessionsJob } = await import("./expire-sessions.ts");
|
||||
|
||||
expect(expireSessionsJob.name).toBe("expire-sessions");
|
||||
expect(expireSessionsJob.cron).toBe("0 * * * *");
|
||||
expect(expireSessionsJob.retryLimit).toBe(2);
|
||||
expect(expireSessionsJob.expireInSeconds).toBe(60);
|
||||
});
|
||||
});
|
||||
15
apps/planner/app/jobs/expire-sessions.ts
Normal file
15
apps/planner/app/jobs/expire-sessions.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { expireSessions } from "../lib/sessions.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
export const expireSessionsJob: JobDefinition = {
|
||||
name: "expire-sessions",
|
||||
cron: "0 * * * *",
|
||||
retryLimit: 2,
|
||||
expireInSeconds: 60,
|
||||
async handler() {
|
||||
const count = await expireSessions(7);
|
||||
logger.info({ count }, "expired stale planner sessions");
|
||||
return { expiredCount: count };
|
||||
},
|
||||
};
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
"@sentry/node": "catalog:",
|
||||
"@sentry/react": "catalog:",
|
||||
"@trails-cool/db": "workspace:*",
|
||||
"@trails-cool/jobs": "workspace:*",
|
||||
"@trails-cool/gpx": "workspace:*",
|
||||
"@trails-cool/i18n": "workspace:*",
|
||||
"@trails-cool/map": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
|
|||
import { createReadStream, statSync } from "node:fs";
|
||||
import { join, extname, resolve } from "node:path";
|
||||
import { setupYjsWebSocket } from "./app/lib/yjs-server.ts";
|
||||
import { createBoss, startWorker } from "@trails-cool/jobs";
|
||||
import { expireSessionsJob } from "./app/jobs/expire-sessions.ts";
|
||||
import postgres from "postgres";
|
||||
|
||||
const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development");
|
||||
|
|
@ -113,7 +115,11 @@ const server = createServer((req, res) => {
|
|||
|
||||
setupYjsWebSocket(server);
|
||||
|
||||
server.listen(port, () => {
|
||||
server.listen(port, async () => {
|
||||
logger.info({ port }, "Planner server listening");
|
||||
logger.info({ port, path: "/sync/:sessionId" }, "Yjs WebSocket available");
|
||||
|
||||
const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails");
|
||||
await startWorker(boss, [expireSessionsJob]);
|
||||
logger.info("Background job worker started");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -479,6 +479,69 @@
|
|||
"legendFormat": "in-flight"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Job Queue \u2014 Status",
|
||||
"description": "Background job counts by state from pg-boss. Queries the pgboss.job table via the PostgreSQL datasource.",
|
||||
"type": "table",
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 72
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "postgres"
|
||||
},
|
||||
"rawSql": "SELECT state, count(*) AS count FROM pgboss.job GROUP BY state ORDER BY count DESC",
|
||||
"format": "table"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Job Queue \u2014 Failed Jobs (last 24h)",
|
||||
"description": "Recently failed background jobs with error details.",
|
||||
"type": "table",
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 72
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "postgres"
|
||||
},
|
||||
"rawSql": "SELECT name, state, createdon, completedon, output::text AS error FROM pgboss.job WHERE state = 'failed' AND completedon > now() - interval '24 hours' ORDER BY completedon DESC LIMIT 20",
|
||||
"format": "table"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Job Queue \u2014 Completed/hour",
|
||||
"description": "Background jobs completed per hour over the last 24 hours.",
|
||||
"type": "timeseries",
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 80
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "postgres"
|
||||
},
|
||||
"rawSql": "SELECT date_trunc('hour', completedon) AS time, name, count(*) AS completed FROM pgboss.job WHERE state = 'completed' AND completedon > now() - interval '24 hours' GROUP BY 1, 2 ORDER BY 1",
|
||||
"format": "time_series"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,6 +152,36 @@ groups:
|
|||
annotations:
|
||||
summary: "Crash signature detected in application logs"
|
||||
|
||||
- uid: background-job-failures
|
||||
title: Background job failures
|
||||
condition: C
|
||||
noDataState: OK
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange: { from: 3600, to: 0 }
|
||||
datasourceUid: postgres
|
||||
model:
|
||||
rawSql: "SELECT count(*) AS failed FROM pgboss.job WHERE state = 'failed' AND completedon > now() - interval '1 hour'"
|
||||
format: table
|
||||
- refId: B
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
type: reduce
|
||||
expression: A
|
||||
reducer: last
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
type: threshold
|
||||
expression: B
|
||||
conditions:
|
||||
- evaluator: { params: [0], type: gt }
|
||||
operator: { type: and }
|
||||
reducer: { type: last }
|
||||
for: 0s
|
||||
annotations:
|
||||
summary: "Background jobs have failed in the last hour — check Grafana Service Health dashboard"
|
||||
|
||||
- uid: caddy-502-rate
|
||||
title: Caddy 502 errors detected
|
||||
condition: B
|
||||
|
|
|
|||
|
|
@ -31,5 +31,10 @@ BEGIN
|
|||
GRANT SELECT ON ALL TABLES IN SCHEMA journal TO grafana_reader;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA journal GRANT SELECT ON TABLES TO grafana_reader;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'pgboss') THEN
|
||||
GRANT USAGE ON SCHEMA pgboss TO grafana_reader;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA pgboss TO grafana_reader;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA pgboss GRANT SELECT ON TABLES TO grafana_reader;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
|
|
|||
2
openspec/changes/pg-boss-background-jobs/.openspec.yaml
Normal file
2
openspec/changes/pg-boss-background-jobs/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-13
|
||||
50
openspec/changes/pg-boss-background-jobs/design.md
Normal file
50
openspec/changes/pg-boss-background-jobs/design.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
## Context
|
||||
|
||||
Background work in trails.cool currently has no execution framework. The planner has an `expireSessions` function that is never called. The Komoot import feature (#223) implements ad-hoc background processing with no retries or failure visibility. Both apps already connect to PostgreSQL, making a Postgres-backed job queue the natural choice — no Redis or additional infrastructure needed.
|
||||
|
||||
pg-boss is a mature Node.js job queue that stores jobs as PostgreSQL rows, supports cron scheduling, automatic retries with exponential backoff, and exposes job state through queryable tables. Since Grafana already has a PostgreSQL datasource, observability comes for free.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Provide a durable job execution framework for both apps using the existing PostgreSQL database
|
||||
- Wire up planner session expiry as the first scheduled job
|
||||
- Make job queue health visible in Grafana
|
||||
- Establish patterns for adding future jobs (Komoot import, ActivityPub federation, email sending)
|
||||
|
||||
**Non-Goals:**
|
||||
- Refactoring the Komoot import to use pg-boss (separate change, after #223 merges)
|
||||
- Multi-worker scaling or horizontal partitioning — single worker per app is sufficient at current scale
|
||||
- Custom job queue UI — Grafana dashboards are sufficient
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. pg-boss over Graphile Worker
|
||||
**Choice**: pg-boss
|
||||
**Rationale**: Built-in cron scheduling, richer job metadata (completion timestamps, retry counts, output storage), and dead letter queue. Graphile Worker is lighter but lacks cron and has less queryable state for Grafana dashboards.
|
||||
**Alternative considered**: Graphile Worker — faster raw throughput, but we need scheduling and observability more than throughput.
|
||||
|
||||
### 2. Shared package `@trails-cool/jobs`
|
||||
**Choice**: New shared package at `packages/jobs/` that wraps pg-boss initialization and exports job registration helpers.
|
||||
**Rationale**: Both planner and journal need background jobs. A shared package avoids duplicating pg-boss setup, ensures consistent configuration (retry policies, monitoring interval), and provides a single place to register job types.
|
||||
**Alternative considered**: Inline setup in each app's `server.ts` — simpler initially but leads to divergent configuration.
|
||||
|
||||
### 3. Worker lifecycle in server process
|
||||
**Choice**: Start the pg-boss worker inside each app's `server.ts` after the HTTP server is listening.
|
||||
**Rationale**: Keeps deployment simple — no separate worker process or container. pg-boss workers are lightweight (polling loop). The planner and journal already run as long-lived Node.js processes.
|
||||
**Alternative considered**: Separate worker container — better isolation but doubles container count and adds deployment complexity for minimal benefit at current scale.
|
||||
|
||||
### 4. Schema isolation
|
||||
**Choice**: Let pg-boss use its default `pgboss` schema in the `trails` database.
|
||||
**Rationale**: pg-boss auto-creates and migrates its own schema. Keeping it separate from `planner` and `journal` schemas avoids any collision. Grant `grafana_reader` SELECT access for dashboards.
|
||||
|
||||
### 5. Session expiry job
|
||||
**Choice**: Cron job running hourly that calls the existing `expireSessions(7)` function.
|
||||
**Rationale**: 7-day TTL with hourly cleanup is generous enough that no active session gets reaped, and frequent enough that stale sessions don't accumulate. The existing function already handles cleanup correctly (deletes DB rows + removes Yjs docs from memory).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **pg-boss schema migrations on upgrade**: pg-boss manages its own schema and runs migrations on startup. → Mitigation: Pin pg-boss version, test upgrades in dev before deploying.
|
||||
- **Worker blocks event loop**: A misbehaving job handler could block the HTTP server. → Mitigation: Jobs should be I/O-bound (DB queries), not CPU-bound. Add a timeout to job handlers.
|
||||
- **Single worker per app**: No parallelism for heavy workloads. → Mitigation: Sufficient for current scale. Can add concurrency options per queue or separate workers later if needed.
|
||||
- **Database load from polling**: pg-boss polls for jobs. → Mitigation: Default polling interval (2s) is fine for our scale. The queries are indexed and lightweight.
|
||||
29
openspec/changes/pg-boss-background-jobs/proposal.md
Normal file
29
openspec/changes/pg-boss-background-jobs/proposal.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
## Why
|
||||
|
||||
Background work (session expiry, Komoot activity imports) currently has no durable execution mechanism. Session cleanup exists as dead code (`expireSessions`) that is never called, and the Komoot import in #223 rolls its own ad-hoc background processing without retries or failure handling. Adding pg-boss gives us a PostgreSQL-backed job queue with scheduling, retries, and observability through our existing Grafana stack — no new infrastructure required.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add `pg-boss` as a dependency to both the Planner and Journal apps
|
||||
- Create a shared background worker setup in `@trails-cool/db` (or a new `@trails-cool/jobs` package) that initializes pg-boss with the existing `DATABASE_URL`
|
||||
- Wire up **planner session expiry** as the first recurring job (hourly cron, 7-day TTL)
|
||||
- Wire up **Komoot import processing** as a durable job with retries (replaces ad-hoc background processing in #223)
|
||||
- Grant `grafana_reader` SELECT on the `pgboss` schema for dashboard visibility
|
||||
- Add a Grafana dashboard panel for job queue health (queue depth, failed jobs, processing duration)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `background-jobs`: pg-boss job queue setup, worker lifecycle, job registration, cron scheduling, retry policies, and Grafana observability
|
||||
|
||||
### Modified Capabilities
|
||||
- `planner-session`: Session expiry is now handled by a scheduled pg-boss job instead of being uncalled dead code
|
||||
- `infrastructure`: PostgreSQL init script grants `grafana_reader` access to the `pgboss` schema; Grafana gets a job queue health panel
|
||||
|
||||
## Impact
|
||||
|
||||
- **Dependencies**: Adds `pg-boss` npm package
|
||||
- **Database**: pg-boss auto-creates its schema (`pgboss`) in PostgreSQL on first run
|
||||
- **Apps**: Both planner and journal server entry points start a pg-boss worker
|
||||
- **Monitoring**: New Grafana panel on Service Health dashboard for job queue metrics
|
||||
- **Komoot import (#223)**: Will be refactored to use pg-boss instead of ad-hoc background processing
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Job queue initialization
|
||||
The `@trails-cool/jobs` package SHALL initialize a pg-boss instance using the app's `DATABASE_URL` and export a function to start the worker.
|
||||
|
||||
#### Scenario: Worker starts with server
|
||||
- **WHEN** the planner or journal server starts
|
||||
- **THEN** pg-boss connects to PostgreSQL, creates/migrates the `pgboss` schema if needed, and begins polling for jobs
|
||||
|
||||
#### Scenario: Worker stops on shutdown
|
||||
- **WHEN** the server process receives SIGTERM
|
||||
- **THEN** pg-boss completes any in-progress jobs and stops gracefully before the process exits
|
||||
|
||||
### Requirement: Cron job scheduling
|
||||
The system SHALL support registering recurring jobs with cron expressions.
|
||||
|
||||
#### Scenario: Register a cron job
|
||||
- **WHEN** a job is registered with a cron expression (e.g., `0 * * * *` for hourly)
|
||||
- **THEN** pg-boss creates a schedule that enqueues the job at the specified interval
|
||||
|
||||
#### Scenario: Cron job survives restart
|
||||
- **WHEN** the server process restarts
|
||||
- **THEN** existing cron schedules persist and continue firing without re-registration conflicts
|
||||
|
||||
### Requirement: Job retry policy
|
||||
Jobs SHALL support configurable retry policies with exponential backoff.
|
||||
|
||||
#### Scenario: Transient failure retry
|
||||
- **WHEN** a job handler throws an error
|
||||
- **THEN** pg-boss retries the job up to the configured retry limit with exponential backoff
|
||||
|
||||
#### Scenario: Permanent failure
|
||||
- **WHEN** a job exhausts all retries
|
||||
- **THEN** the job moves to the `failed` state and remains queryable for debugging
|
||||
|
||||
### Requirement: Job handler timeout
|
||||
Each job handler SHALL have a configurable execution timeout.
|
||||
|
||||
#### Scenario: Job exceeds timeout
|
||||
- **WHEN** a job handler does not complete within its timeout
|
||||
- **THEN** pg-boss marks the job as failed with a timeout error
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Grafana database access
|
||||
The `grafana_reader` PostgreSQL role SHALL have SELECT access to the `pgboss` schema for job queue observability.
|
||||
|
||||
#### Scenario: Grant access on deploy
|
||||
- **WHEN** the infrastructure deploy runs
|
||||
- **THEN** `grafana_reader` is granted `USAGE` on the `pgboss` schema and `SELECT` on all tables in it
|
||||
|
||||
### Requirement: Monitoring stack
|
||||
The Grafana Service Health dashboard SHALL include a job queue health panel.
|
||||
|
||||
#### Scenario: Job queue panel displays metrics
|
||||
- **WHEN** a user views the Service Health dashboard
|
||||
- **THEN** they see a panel showing job queue depth, completed jobs per hour, and failed jobs
|
||||
- **AND** failed jobs are highlighted for investigation
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Session expiry
|
||||
Open sessions with no activity for 7 days SHALL be automatically deleted by a scheduled background job.
|
||||
|
||||
#### Scenario: Stale session cleanup
|
||||
- **WHEN** the hourly `expire-sessions` cron job runs
|
||||
- **THEN** all sessions with `last_activity` older than 7 days are deleted from the database
|
||||
- **AND** their Yjs documents are removed from memory
|
||||
|
||||
#### Scenario: Active session preserved
|
||||
- **WHEN** the `expire-sessions` job runs
|
||||
- **THEN** sessions with `last_activity` within the last 7 days are NOT deleted
|
||||
|
||||
#### Scenario: Cleanup is observable
|
||||
- **WHEN** the `expire-sessions` job completes
|
||||
- **THEN** the job output includes the count of expired sessions
|
||||
- **AND** the result is visible in the Grafana job queue dashboard
|
||||
37
openspec/changes/pg-boss-background-jobs/tasks.md
Normal file
37
openspec/changes/pg-boss-background-jobs/tasks.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
## 1. Package Setup
|
||||
|
||||
- [x] 1.1 Create `packages/jobs/` package with `package.json`, `tsconfig.json`, and `pg-boss` dependency
|
||||
- [x] 1.2 Add `@trails-cool/jobs` to planner and journal app dependencies in `pnpm-workspace.yaml`
|
||||
- [x] 1.3 Run `pnpm install` and verify workspace resolution
|
||||
|
||||
## 2. Core Job Queue Module
|
||||
|
||||
- [x] 2.1 Create `packages/jobs/src/boss.ts` — initialize pg-boss with `DATABASE_URL`, export `createBoss()` factory
|
||||
- [x] 2.2 Create `packages/jobs/src/worker.ts` — export `startWorker(boss, jobs)` that registers job handlers and starts processing
|
||||
- [x] 2.3 Create `packages/jobs/src/types.ts` — export `JobDefinition` type (name, handler, cron?, retryLimit?, expireInSeconds?)
|
||||
- [x] 2.4 Add graceful shutdown: listen for SIGTERM, call `boss.stop()` to complete in-progress jobs before exit
|
||||
- [x] 2.5 Export public API from `packages/jobs/src/index.ts`
|
||||
|
||||
## 3. Planner Session Expiry Job
|
||||
|
||||
- [x] 3.1 Create `apps/planner/app/jobs/expire-sessions.ts` — job handler that calls `expireSessions(7)` and returns the count
|
||||
- [x] 3.2 Register the job in planner's `server.ts` with cron `0 * * * *` (hourly), retryLimit 2, expireInSeconds 60
|
||||
- [ ] 3.3 Verify job appears in `pgboss.schedule` table after planner starts (manual verification after dev stack is running)
|
||||
- [x] 3.4 Write a test for the expire-sessions handler
|
||||
|
||||
## 4. Journal Worker Setup
|
||||
|
||||
- [x] 4.1 Add pg-boss worker startup to journal's `server.ts` (no jobs yet — placeholder for Komoot import and future federation jobs)
|
||||
- [x] 4.2 Add graceful shutdown handling (handled by startWorker via SIGTERM/SIGINT listeners)
|
||||
|
||||
## 5. Infrastructure & Observability
|
||||
|
||||
- [x] 5.1 Add `GRANT USAGE ON SCHEMA pgboss TO grafana_reader` and `GRANT SELECT ON ALL TABLES IN SCHEMA pgboss TO grafana_reader` to `infrastructure/postgres/init-grafana-user.sql`
|
||||
- [x] 5.2 Add a "Job Queue Health" panel to the Service Health Grafana dashboard — queue depth (`SELECT state, count(*) FROM pgboss.job GROUP BY state`), failed jobs, and completed jobs/hour
|
||||
- [x] 5.3 Add a Grafana alert for failed background jobs (`SELECT count(*) FROM pgboss.job WHERE state = 'failed' AND completedon > now() - interval '1 hour'`)
|
||||
|
||||
## 6. Testing & Verification
|
||||
|
||||
- [x] 6.1 Run `pnpm typecheck` — all packages pass
|
||||
- [x] 6.2 Run `pnpm test` — new and existing tests pass (63 planner tests, including 2 new expire-sessions tests)
|
||||
- [ ] 6.3 Start dev stack with `pnpm dev:full`, verify pg-boss tables are created and expire-sessions schedule is registered (manual verification)
|
||||
21
packages/jobs/package.json
Normal file
21
packages/jobs/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "@trails-cool/jobs",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"pg-boss": "^10.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:"
|
||||
}
|
||||
}
|
||||
5
packages/jobs/src/boss.ts
Normal file
5
packages/jobs/src/boss.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import PgBoss from "pg-boss";
|
||||
|
||||
export function createBoss(connectionString: string): PgBoss {
|
||||
return new PgBoss({ connectionString });
|
||||
}
|
||||
3
packages/jobs/src/index.ts
Normal file
3
packages/jobs/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { createBoss } from "./boss.ts";
|
||||
export { startWorker } from "./worker.ts";
|
||||
export type { JobDefinition } from "./types.ts";
|
||||
9
packages/jobs/src/types.ts
Normal file
9
packages/jobs/src/types.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import type PgBoss from "pg-boss";
|
||||
|
||||
export interface JobDefinition<T = unknown> {
|
||||
name: string;
|
||||
handler: (jobs: PgBoss.Job<T>[]) => Promise<unknown>;
|
||||
cron?: string;
|
||||
retryLimit?: number;
|
||||
expireInSeconds?: number;
|
||||
}
|
||||
79
packages/jobs/src/worker.test.ts
Normal file
79
packages/jobs/src/worker.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { startWorker } from "./worker.ts";
|
||||
import type { JobDefinition } from "./types.ts";
|
||||
|
||||
function createMockBoss() {
|
||||
return {
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
schedule: vi.fn().mockResolvedValue(undefined),
|
||||
work: vi.fn().mockResolvedValue("worker-id"),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("startWorker", () => {
|
||||
it("starts the boss instance", async () => {
|
||||
const boss = createMockBoss();
|
||||
await startWorker(boss as never, []);
|
||||
expect(boss.start).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers job handlers", async () => {
|
||||
const boss = createMockBoss();
|
||||
const handler = vi.fn();
|
||||
const jobs: JobDefinition[] = [{ name: "test-job", handler }];
|
||||
|
||||
await startWorker(boss as never, jobs);
|
||||
|
||||
expect(boss.work).toHaveBeenCalledWith("test-job", handler);
|
||||
});
|
||||
|
||||
it("schedules cron jobs with options", async () => {
|
||||
const boss = createMockBoss();
|
||||
const handler = vi.fn();
|
||||
const jobs: JobDefinition[] = [
|
||||
{
|
||||
name: "cron-job",
|
||||
handler,
|
||||
cron: "0 * * * *",
|
||||
retryLimit: 3,
|
||||
expireInSeconds: 120,
|
||||
},
|
||||
];
|
||||
|
||||
await startWorker(boss as never, jobs);
|
||||
|
||||
expect(boss.schedule).toHaveBeenCalledWith("cron-job", "0 * * * *", undefined, {
|
||||
retryLimit: 3,
|
||||
expireInSeconds: 120,
|
||||
});
|
||||
expect(boss.work).toHaveBeenCalledWith("cron-job", handler);
|
||||
});
|
||||
|
||||
it("does not schedule non-cron jobs", async () => {
|
||||
const boss = createMockBoss();
|
||||
const jobs: JobDefinition[] = [{ name: "one-shot", handler: vi.fn() }];
|
||||
|
||||
await startWorker(boss as never, jobs);
|
||||
|
||||
expect(boss.schedule).not.toHaveBeenCalled();
|
||||
expect(boss.work).toHaveBeenCalledWith("one-shot", jobs[0]!.handler);
|
||||
});
|
||||
|
||||
it("registers multiple jobs", async () => {
|
||||
const boss = createMockBoss();
|
||||
const jobs: JobDefinition[] = [
|
||||
{ name: "job-a", handler: vi.fn() },
|
||||
{ name: "job-b", handler: vi.fn(), cron: "*/5 * * * *" },
|
||||
];
|
||||
|
||||
await startWorker(boss as never, jobs);
|
||||
|
||||
expect(boss.work).toHaveBeenCalledTimes(2);
|
||||
expect(boss.schedule).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
28
packages/jobs/src/worker.ts
Normal file
28
packages/jobs/src/worker.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type PgBoss from "pg-boss";
|
||||
import type { JobDefinition } from "./types.ts";
|
||||
|
||||
export async function startWorker(
|
||||
boss: PgBoss,
|
||||
jobs: JobDefinition[],
|
||||
): Promise<void> {
|
||||
await boss.start();
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.cron) {
|
||||
await boss.schedule(job.name, job.cron, undefined, {
|
||||
retryLimit: job.retryLimit,
|
||||
expireInSeconds: job.expireInSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
await boss.work(job.name, job.handler);
|
||||
}
|
||||
|
||||
const onShutdown = async () => {
|
||||
await boss.stop({ graceful: true, timeout: 10_000 });
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGTERM", onShutdown);
|
||||
process.on("SIGINT", onShutdown);
|
||||
}
|
||||
9
packages/jobs/tsconfig.json
Normal file
9
packages/jobs/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
packages/jobs/vitest.config.ts
Normal file
1
packages/jobs/vitest.config.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../../vitest.shared.ts";
|
||||
113
pnpm-lock.yaml
generated
113
pnpm-lock.yaml
generated
|
|
@ -121,7 +121,7 @@ importers:
|
|||
version: 0.31.10
|
||||
drizzle-orm:
|
||||
specifier: 'catalog:'
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(postgres@3.4.9)
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(pg@8.20.0)(postgres@3.4.9)
|
||||
drizzle-postgis:
|
||||
specifier: 'catalog:'
|
||||
version: 1.1.1
|
||||
|
|
@ -221,6 +221,9 @@ importers:
|
|||
'@trails-cool/i18n':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/i18n
|
||||
'@trails-cool/jobs':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/jobs
|
||||
'@trails-cool/map':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/map
|
||||
|
|
@ -232,7 +235,7 @@ importers:
|
|||
version: link:../../packages/ui
|
||||
drizzle-orm:
|
||||
specifier: 'catalog:'
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(postgres@3.4.9)
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(pg@8.20.0)(postgres@3.4.9)
|
||||
isbot:
|
||||
specifier: ^5.1.37
|
||||
version: 5.1.37
|
||||
|
|
@ -430,6 +433,9 @@ importers:
|
|||
'@trails-cool/i18n':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/i18n
|
||||
'@trails-cool/jobs':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/jobs
|
||||
'@trails-cool/map':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/map
|
||||
|
|
@ -447,7 +453,7 @@ importers:
|
|||
version: 6.0.2
|
||||
drizzle-orm:
|
||||
specifier: 'catalog:'
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(postgres@3.4.9)
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(pg@8.20.0)(postgres@3.4.9)
|
||||
isbot:
|
||||
specifier: ^5.1.37
|
||||
version: 5.1.37
|
||||
|
|
@ -529,7 +535,7 @@ importers:
|
|||
dependencies:
|
||||
drizzle-orm:
|
||||
specifier: 'catalog:'
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(postgres@3.4.9)
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(pg@8.20.0)(postgres@3.4.9)
|
||||
postgres:
|
||||
specifier: 'catalog:'
|
||||
version: 3.4.9
|
||||
|
|
@ -563,6 +569,16 @@ importers:
|
|||
specifier: '>=17'
|
||||
version: 17.0.2(i18next@26.0.4(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@5.9.3)
|
||||
|
||||
packages/jobs:
|
||||
dependencies:
|
||||
pg-boss:
|
||||
specifier: ^10.3.1
|
||||
version: 10.4.2
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 22.19.17
|
||||
|
||||
packages/map:
|
||||
dependencies:
|
||||
'@trails-cool/map-core':
|
||||
|
|
@ -4147,6 +4163,10 @@ packages:
|
|||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
cron-parser@4.9.0:
|
||||
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -5630,6 +5650,10 @@ packages:
|
|||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
luxon@3.7.2:
|
||||
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
lz-string@1.5.0:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
|
|
@ -5992,10 +6016,25 @@ packages:
|
|||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pg-boss@10.4.2:
|
||||
resolution: {integrity: sha512-AttEWOtSzn53av8OnCMWEanwRBvjkZCE1y5nLrZnwvkkMnlZ5XpWDpZ7sKI/BYjvi2OVieMX37arD2ACgJ750w==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
pg-cloudflare@1.3.0:
|
||||
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
|
||||
|
||||
pg-connection-string@2.12.0:
|
||||
resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==}
|
||||
|
||||
pg-int8@1.0.1:
|
||||
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
pg-pool@3.13.0:
|
||||
resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==}
|
||||
peerDependencies:
|
||||
pg: '>=8.0'
|
||||
|
||||
pg-protocol@1.13.0:
|
||||
resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==}
|
||||
|
||||
|
|
@ -6003,6 +6042,18 @@ packages:
|
|||
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
pg@8.20.0:
|
||||
resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==}
|
||||
engines: {node: '>= 16.0.0'}
|
||||
peerDependencies:
|
||||
pg-native: '>=3.0.1'
|
||||
peerDependenciesMeta:
|
||||
pg-native:
|
||||
optional: true
|
||||
|
||||
pgpass@1.0.5:
|
||||
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
|
|
@ -6473,6 +6524,10 @@ packages:
|
|||
resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
serialize-error@8.1.0:
|
||||
resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
serve-static@1.16.3:
|
||||
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
|
@ -6840,6 +6895,10 @@ packages:
|
|||
resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
type-fest@0.20.2:
|
||||
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
type-fest@0.21.3:
|
||||
resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -11327,6 +11386,10 @@ snapshots:
|
|||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
cron-parser@4.9.0:
|
||||
dependencies:
|
||||
luxon: 3.7.2
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
|
|
@ -11461,11 +11524,12 @@ snapshots:
|
|||
esbuild: 0.25.12
|
||||
tsx: 4.21.0
|
||||
|
||||
drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(postgres@3.4.9):
|
||||
drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(expo-sqlite@55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(pg@8.20.0)(postgres@3.4.9):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@types/pg': 8.15.6
|
||||
expo-sqlite: 55.0.15(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)
|
||||
pg: 8.20.0
|
||||
postgres: 3.4.9
|
||||
|
||||
drizzle-postgis@1.1.1:
|
||||
|
|
@ -13054,6 +13118,8 @@ snapshots:
|
|||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
luxon@3.7.2: {}
|
||||
|
||||
lz-string@1.5.0: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
|
|
@ -13482,8 +13548,25 @@ snapshots:
|
|||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pg-boss@10.4.2:
|
||||
dependencies:
|
||||
cron-parser: 4.9.0
|
||||
pg: 8.20.0
|
||||
serialize-error: 8.1.0
|
||||
transitivePeerDependencies:
|
||||
- pg-native
|
||||
|
||||
pg-cloudflare@1.3.0:
|
||||
optional: true
|
||||
|
||||
pg-connection-string@2.12.0: {}
|
||||
|
||||
pg-int8@1.0.1: {}
|
||||
|
||||
pg-pool@3.13.0(pg@8.20.0):
|
||||
dependencies:
|
||||
pg: 8.20.0
|
||||
|
||||
pg-protocol@1.13.0: {}
|
||||
|
||||
pg-types@2.2.0:
|
||||
|
|
@ -13494,6 +13577,20 @@ snapshots:
|
|||
postgres-date: 1.0.7
|
||||
postgres-interval: 1.2.0
|
||||
|
||||
pg@8.20.0:
|
||||
dependencies:
|
||||
pg-connection-string: 2.12.0
|
||||
pg-pool: 3.13.0(pg@8.20.0)
|
||||
pg-protocol: 1.13.0
|
||||
pg-types: 2.2.0
|
||||
pgpass: 1.0.5
|
||||
optionalDependencies:
|
||||
pg-cloudflare: 1.3.0
|
||||
|
||||
pgpass@1.0.5:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.2: {}
|
||||
|
|
@ -14032,6 +14129,10 @@ snapshots:
|
|||
|
||||
serialize-error@2.1.0: {}
|
||||
|
||||
serialize-error@8.1.0:
|
||||
dependencies:
|
||||
type-fest: 0.20.2
|
||||
|
||||
serve-static@1.16.3:
|
||||
dependencies:
|
||||
encodeurl: 2.0.0
|
||||
|
|
@ -14380,6 +14481,8 @@ snapshots:
|
|||
|
||||
type-detect@4.0.8: {}
|
||||
|
||||
type-fest@0.20.2: {}
|
||||
|
||||
type-fest@0.21.3: {}
|
||||
|
||||
type-fest@0.7.1: {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue