diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 317ab6e..96c54cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,21 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: chrono_test + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + steps: - name: Perform source code checkout uses: actions/checkout@v4 @@ -54,3 +69,5 @@ jobs: - name: Test run: pnpm test if: github.ref != 'refs/heads/master' + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/chrono_test diff --git a/packages/chrono-core/tsdown.config.ts b/packages/chrono-core/tsdown.config.ts index 4b111b3..bc97c40 100644 --- a/packages/chrono-core/tsdown.config.ts +++ b/packages/chrono-core/tsdown.config.ts @@ -5,4 +5,5 @@ export default defineConfig({ format: ['esm', 'cjs'], outDir: './build', sourcemap: true, + hash: false, }); diff --git a/packages/chrono-memory-datastore/tsdown.config.ts b/packages/chrono-memory-datastore/tsdown.config.ts index 4b111b3..bc97c40 100644 --- a/packages/chrono-memory-datastore/tsdown.config.ts +++ b/packages/chrono-memory-datastore/tsdown.config.ts @@ -5,4 +5,5 @@ export default defineConfig({ format: ['esm', 'cjs'], outDir: './build', sourcemap: true, + hash: false, }); diff --git a/packages/chrono-mongo-datastore/tsdown.config.ts b/packages/chrono-mongo-datastore/tsdown.config.ts index 4b111b3..bc97c40 100644 --- a/packages/chrono-mongo-datastore/tsdown.config.ts +++ b/packages/chrono-mongo-datastore/tsdown.config.ts @@ -5,4 +5,5 @@ export default defineConfig({ format: ['esm', 'cjs'], outDir: './build', sourcemap: true, + hash: false, }); diff --git a/packages/chrono-postgres-datastore/package.json b/packages/chrono-postgres-datastore/package.json new file mode 100644 index 0000000..5da89ba --- /dev/null +++ b/packages/chrono-postgres-datastore/package.json @@ -0,0 +1,52 @@ +{ + "name": "@neofinancial/chrono-postgres-datastore", + "version": "0.5.2-alpha.2", + "description": "PostgreSQL datastore implementation for Chrono task scheduling system", + "private": false, + "publishConfig": { + "access": "public" + }, + "homepage": "https://github.com/neofinancial/chrono", + "repository": { + "type": "git", + "url": "https://github.com/neofinancial/chrono.git" + }, + "main": "./build/index.js", + "module": "./build/index.mjs", + "types": "./build/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./build/index.d.mts", + "default": "./build/index.mjs" + }, + "require": { + "types": "./build/index.d.ts", + "default": "./build/index.js" + } + } + }, + "keywords": [], + "author": "Neo Financial Engineering ", + "license": "MIT", + "files": [ + "build/**", + "README.md" + ], + "scripts": { + "clean": "rimraf ./build", + "build": "tsdown", + "typecheck": "tsc -p ./tsconfig.json --noEmit && tsc -p ./test/tsconfig.json --noEmit", + "test": "NODE_ENV=test TZ=UTC vitest run" + }, + "dependencies": { + "pg": "^8.13.1" + }, + "devDependencies": { + "@neofinancial/chrono": "workspace:*", + "@types/pg": "^8.11.0" + }, + "peerDependencies": { + "@neofinancial/chrono": ">=0.5.0" + } +} diff --git a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts new file mode 100644 index 0000000..3f03268 --- /dev/null +++ b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts @@ -0,0 +1,368 @@ +import { + type ClaimTaskInput, + type Datastore, + type DeleteInput, + type DeleteOptions, + type ScheduleInput, + type Task, + type TaskMappingBase, + TaskStatus, +} from '@neofinancial/chrono'; +import type { Pool, PoolClient } from 'pg'; +import { + CLAIM_QUERY, + CLEANUP_QUERY, + COMPLETE_QUERY, + DELETE_BY_ID_FORCE_QUERY, + DELETE_BY_ID_QUERY, + DELETE_BY_KEY_FORCE_QUERY, + DELETE_BY_KEY_QUERY, + FAIL_QUERY, + FIND_BY_IDEMPOTENCY_KEY_QUERY, + RETRY_QUERY, + SCHEDULE_QUERY, +} from './queries'; +import type { ChronoTaskRow } from './types'; + +const DEFAULT_TTL_SECONDS = 60 * 60 * 24 * 30; +const DEFAULT_CLEANUP_INTERVAL_SECONDS = 60; +const DEFAULT_CLEANUP_BATCH_SIZE = 100; +const DEFAULT_INITIALIZATION_TIMEOUT_MS = 10_000; + +export type ChronoPostgresDatastoreConfig = { + /** TTL (in seconds) for completed tasks. Tasks older than this are deleted during cleanup. */ + completedDocumentTTLSeconds?: number; + + /** How often (in seconds) to attempt cleanup. Runs opportunistically after claim() calls. */ + cleanupIntervalSeconds?: number; + + /** Max completed tasks to delete per cleanup run. */ + cleanupBatchSize?: number; + + /** Called when cleanup fails. Use this to report errors to Sentry, logging, etc. */ + onCleanupError?: (error: unknown) => void; + + /** + * Timeout (in milliseconds) for waiting for datastore initialization. + * If the datastore is not initialized within this time, operations will throw an error. + * Default: 10000ms (10 seconds) + */ + initializationTimeoutMs?: number; +}; + +export type PostgresDatastoreOptions = { + /** + * Optional PoolClient for participating in external transactions. + * When provided, all operations will use this client instead of acquiring from the pool. + */ + client?: PoolClient; +}; + +type ResolvedConfig = Required> & + Pick; + +export class ChronoPostgresDatastore + implements Datastore +{ + private config: ResolvedConfig; + private pool: Pool | undefined; + private poolResolvers: Array<(pool: Pool) => void> = []; + private lastCleanupTime: Date = new Date(0); + + constructor(config?: ChronoPostgresDatastoreConfig) { + this.config = { + completedDocumentTTLSeconds: config?.completedDocumentTTLSeconds ?? DEFAULT_TTL_SECONDS, + cleanupIntervalSeconds: config?.cleanupIntervalSeconds ?? DEFAULT_CLEANUP_INTERVAL_SECONDS, + cleanupBatchSize: config?.cleanupBatchSize ?? DEFAULT_CLEANUP_BATCH_SIZE, + onCleanupError: config?.onCleanupError, + initializationTimeoutMs: config?.initializationTimeoutMs ?? DEFAULT_INITIALIZATION_TIMEOUT_MS, + }; + } + + /** + * Initializes the datastore with a pg Pool. + * Must be called before any operations can be performed. + * + * @param pool - The pg Pool connected to PostgreSQL + */ + async initialize(pool: Pool): Promise { + if (this.pool) { + throw new Error('Pool already initialized'); + } + + this.pool = pool; + + // Resolve any pending operations waiting for the pool + const resolvers = this.poolResolvers.splice(0); + for (const resolve of resolvers) { + resolve(pool); + } + } + + /** + * Asynchronously gets the Pool. If not yet initialized, + * waits for initialize() to be called with a timeout. + * @throws Error if initialization times out + */ + private async getPool(): Promise { + if (this.pool) { + return this.pool; + } + + const initPromise = new Promise((resolve) => { + this.poolResolvers.push(resolve); + }); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject( + new Error( + `ChronoPostgresDatastore initialization timeout: datastore was not initialized within ${this.config.initializationTimeoutMs}ms. ` + + 'Ensure initialize() is called before performing operations.', + ), + ); + }, this.config.initializationTimeoutMs); + }); + + return Promise.race([initPromise, timeoutPromise]); + } + + /** + * Gets the client to use for operations. + * Uses the provided client from options if available, otherwise uses the pool. + * Waits for initialization if the pool is not yet available. + */ + private async getQueryable(options?: PostgresDatastoreOptions): Promise { + if (options?.client) { + return options.client; + } + + return this.getPool(); + } + + async schedule( + input: ScheduleInput, + ): Promise> { + const queryable = await this.getQueryable(input.datastoreOptions); + + const values = [ + String(input.kind), + TaskStatus.PENDING, + JSON.stringify(input.data), + input.priority ?? 0, + input.idempotencyKey ?? null, + input.when, + input.when, + 0, + ]; + + try { + const result = await queryable.query(SCHEDULE_QUERY, values); + const row = result.rows[0]; + if (!row) { + throw new Error('Failed to insert task: no row returned'); + } + return this.toTask(row); + } catch (error) { + return this.handleScheduleError(error, input.idempotencyKey, queryable); + } + } + + private async handleScheduleError( + error: unknown, + idempotencyKey: string | undefined, + queryable: Pool | PoolClient, + ): Promise> { + const isIdempotencyConflict = this.isUniqueViolation(error) && idempotencyKey; + if (!isIdempotencyConflict) { + throw error; + } + + const result = await queryable.query(FIND_BY_IDEMPOTENCY_KEY_QUERY, [idempotencyKey]); + const row = result.rows[0]; + + if (!row) { + throw new Error( + `Failed to find existing task with idempotency key ${idempotencyKey} despite unique constraint error`, + ); + } + + return this.toTask(row); + } + + async delete>( + key: DeleteInput, + options?: DeleteOptions, + ): Promise | undefined> { + const queryable = await this.getQueryable(); + + let query: string; + let values: unknown[]; + + if (typeof key === 'string') { + if (options?.force) { + query = DELETE_BY_ID_FORCE_QUERY; + values = [key]; + } else { + query = DELETE_BY_ID_QUERY; + values = [key, TaskStatus.PENDING]; + } + } else { + if (options?.force) { + query = DELETE_BY_KEY_FORCE_QUERY; + values = [String(key.kind), key.idempotencyKey]; + } else { + query = DELETE_BY_KEY_QUERY; + values = [String(key.kind), key.idempotencyKey, TaskStatus.PENDING]; + } + } + + const result = await queryable.query(query, values); + const row = result.rows[0]; + + if (row) { + return this.toTask(row); + } + + if (options?.force) { + return undefined; + } + + throw new Error(this.buildDeleteErrorMessage(key)); + } + + private buildDeleteErrorMessage>( + key: DeleteInput, + ): string { + if (typeof key === 'string') { + return `Task with id ${key} cannot be deleted as it may not exist or it's not in PENDING status.`; + } + return `Task with kind ${String(key.kind)} and idempotencyKey ${key.idempotencyKey} cannot be deleted as it may not exist or it's not in PENDING status.`; + } + + async claim>( + input: ClaimTaskInput, + ): Promise | undefined> { + const queryable = await this.getQueryable(); + const now = new Date(); + const staleThreshold = new Date(now.getTime() - input.claimStaleTimeoutMs); + + // Single atomic query: SELECT FOR UPDATE SKIP LOCKED + UPDATE in one statement + const result = await queryable.query(CLAIM_QUERY, [ + TaskStatus.CLAIMED, + now, + String(input.kind), + TaskStatus.PENDING, + staleThreshold, + ]); + + // Opportunistic cleanup runs after claim completes + this.maybeCleanupCompletedTasks(); + + const row = result.rows[0]; + return row ? this.toTask(row) : undefined; + } + + async retry( + taskId: string, + retryAt: Date, + ): Promise> { + const queryable = await this.getQueryable(); + const now = new Date(); + + const result = await queryable.query(RETRY_QUERY, [TaskStatus.PENDING, retryAt, now, taskId]); + + return this.extractUpdatedTaskOrThrow(result.rows, taskId); + } + + async complete(taskId: string): Promise> { + const queryable = await this.getQueryable(); + const now = new Date(); + + const result = await queryable.query(COMPLETE_QUERY, [TaskStatus.COMPLETED, now, now, now, taskId]); + + return this.extractUpdatedTaskOrThrow(result.rows, taskId); + } + + async fail(taskId: string): Promise> { + const queryable = await this.getQueryable(); + const now = new Date(); + + const result = await queryable.query(FAIL_QUERY, [TaskStatus.FAILED, now, now, taskId]); + + return this.extractUpdatedTaskOrThrow(result.rows, taskId); + } + + private extractUpdatedTaskOrThrow( + rows: ChronoTaskRow[], + taskId: string, + ): Task { + const row = rows[0]; + if (!row) { + throw new Error(`Task with ID ${taskId} not found`); + } + return this.toTask(row); + } + + /** + * Checks if an error is a PostgreSQL unique constraint violation. + */ + private isUniqueViolation(error: unknown): boolean { + const PG_UNIQUE_VIOLATION = '23505'; + const isErrorObject = typeof error === 'object' && error !== null; + return isErrorObject && 'code' in error && error.code === PG_UNIQUE_VIOLATION; + } + + /** + * Converts a database row to a Task object. + */ + private toTask(row: ChronoTaskRow): Task { + return { + id: row.id, + kind: row.kind as TaskKind, + status: row.status as TaskStatus, + data: row.data as TaskMapping[TaskKind], + priority: row.priority ?? undefined, + idempotencyKey: row.idempotency_key ?? undefined, + originalScheduleDate: row.original_schedule_date, + scheduledAt: row.scheduled_at, + claimedAt: row.claimed_at ?? undefined, + completedAt: row.completed_at ?? undefined, + lastExecutedAt: row.last_executed_at ?? undefined, + retryCount: row.retry_count, + }; + } + + /** + * Opportunistically cleans up old completed tasks. + * Runs in the background (fire-and-forget) to avoid blocking claim(). + * Multiple instances may race; this is harmless as DELETE is idempotent. + */ + private maybeCleanupCompletedTasks(): void { + const now = new Date(); + const timeSinceLastCleanup = now.getTime() - this.lastCleanupTime.getTime(); + + if (timeSinceLastCleanup < this.config.cleanupIntervalSeconds * 1000) { + return; + } + + // Update timestamp before cleanup to prevent concurrent cleanup attempts from this instance + this.lastCleanupTime = now; + + this.cleanupCompletedTasks().catch((error) => { + this.config.onCleanupError?.(error); + }); + } + + private async cleanupCompletedTasks(): Promise { + const pool = this.pool; + if (!pool) { + return; + } + + const cutoffDate = new Date(Date.now() - this.config.completedDocumentTTLSeconds * 1000); + + // Single atomic query: DELETE with LIMIT via subquery + await pool.query(CLEANUP_QUERY, [TaskStatus.COMPLETED, cutoffDate, this.config.cleanupBatchSize]); + } +} diff --git a/packages/chrono-postgres-datastore/src/index.ts b/packages/chrono-postgres-datastore/src/index.ts new file mode 100644 index 0000000..d6cd0ae --- /dev/null +++ b/packages/chrono-postgres-datastore/src/index.ts @@ -0,0 +1,7 @@ +export { + ChronoPostgresDatastore, + type ChronoPostgresDatastoreConfig, + type PostgresDatastoreOptions, +} from './chrono-postgres-datastore'; +export { MIGRATION_DOWN_SQL, MIGRATION_UP_SQL, migrateDown, migrateUp } from './migration'; +export type { ChronoTaskRow } from './types'; diff --git a/packages/chrono-postgres-datastore/src/migration.ts b/packages/chrono-postgres-datastore/src/migration.ts new file mode 100644 index 0000000..2aab30f --- /dev/null +++ b/packages/chrono-postgres-datastore/src/migration.ts @@ -0,0 +1,61 @@ +import type { PoolClient } from 'pg'; + +/** + * SQL to create the chrono_tasks table and indexes. + * Can be used directly in a migration or executed via migrateUp(). + */ +export const MIGRATION_UP_SQL = ` +CREATE TABLE IF NOT EXISTS chrono_tasks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + kind varchar(255) NOT NULL, + status varchar(20) NOT NULL DEFAULT 'PENDING', + data jsonb NOT NULL, + priority integer DEFAULT 0, + idempotency_key varchar(255), + original_schedule_date timestamptz NOT NULL, + scheduled_at timestamptz NOT NULL, + claimed_at timestamptz, + completed_at timestamptz, + last_executed_at timestamptz, + retry_count integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_chrono_tasks_claim + ON chrono_tasks (kind, status, scheduled_at, priority, claimed_at); + +CREATE INDEX IF NOT EXISTS idx_chrono_tasks_cleanup + ON chrono_tasks (status, completed_at); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_chrono_tasks_idempotency + ON chrono_tasks (idempotency_key) + WHERE idempotency_key IS NOT NULL; +`; + +/** + * SQL to drop the chrono_tasks table and indexes. + * Can be used directly in a migration or executed via migrateDown(). + */ +export const MIGRATION_DOWN_SQL = ` +DROP INDEX IF EXISTS idx_chrono_tasks_idempotency; +DROP INDEX IF EXISTS idx_chrono_tasks_cleanup; +DROP INDEX IF EXISTS idx_chrono_tasks_claim; +DROP TABLE IF EXISTS chrono_tasks; +`; + +/** + * Executes the up migration to create the chrono_tasks table. + * @param client - A pg PoolClient (can be within a transaction) + */ +export async function migrateUp(client: PoolClient): Promise { + await client.query(MIGRATION_UP_SQL); +} + +/** + * Executes the down migration to drop the chrono_tasks table. + * @param client - A pg PoolClient (can be within a transaction) + */ +export async function migrateDown(client: PoolClient): Promise { + await client.query(MIGRATION_DOWN_SQL); +} diff --git a/packages/chrono-postgres-datastore/src/queries.ts b/packages/chrono-postgres-datastore/src/queries.ts new file mode 100644 index 0000000..bfd23f4 --- /dev/null +++ b/packages/chrono-postgres-datastore/src/queries.ts @@ -0,0 +1,90 @@ +/** + * SQL query constants for chrono-postgres-datastore. + * All queries use prepared statement placeholders ($1, $2, etc.). + */ + +export const SCHEDULE_QUERY = ` + INSERT INTO chrono_tasks ( + kind, status, data, priority, idempotency_key, + original_schedule_date, scheduled_at, retry_count + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * +`; + +export const FIND_BY_IDEMPOTENCY_KEY_QUERY = ` + SELECT * FROM chrono_tasks WHERE idempotency_key = $1 +`; + +export const CLAIM_QUERY = ` + UPDATE chrono_tasks + SET status = $1, claimed_at = $2, updated_at = $2 + WHERE id = ( + SELECT id FROM chrono_tasks + WHERE kind = $3 + AND scheduled_at <= $2 + AND (status = $4 OR (status = $1 AND claimed_at <= $5)) + ORDER BY priority DESC, scheduled_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING * +`; + +export const RETRY_QUERY = ` + UPDATE chrono_tasks + SET status = $1, scheduled_at = $2, claimed_at = NULL, updated_at = $3, retry_count = retry_count + 1 + WHERE id = $4 + RETURNING * +`; + +export const COMPLETE_QUERY = ` + UPDATE chrono_tasks + SET status = $1, completed_at = $2, last_executed_at = $3, updated_at = $4 + WHERE id = $5 + RETURNING * +`; + +export const FAIL_QUERY = ` + UPDATE chrono_tasks + SET status = $1, last_executed_at = $2, updated_at = $3 + WHERE id = $4 + RETURNING * +`; + +// Delete by ID (without force - only PENDING tasks) +export const DELETE_BY_ID_QUERY = ` + DELETE FROM chrono_tasks + WHERE id = $1 AND status = $2 + RETURNING * +`; + +// Delete by ID (with force - any status) +export const DELETE_BY_ID_FORCE_QUERY = ` + DELETE FROM chrono_tasks + WHERE id = $1 + RETURNING * +`; + +// Delete by kind + idempotency key (without force - only PENDING tasks) +export const DELETE_BY_KEY_QUERY = ` + DELETE FROM chrono_tasks + WHERE kind = $1 AND idempotency_key = $2 AND status = $3 + RETURNING * +`; + +// Delete by kind + idempotency key (with force - any status) +export const DELETE_BY_KEY_FORCE_QUERY = ` + DELETE FROM chrono_tasks + WHERE kind = $1 AND idempotency_key = $2 + RETURNING * +`; + +// Cleanup: delete old completed tasks with limit +export const CLEANUP_QUERY = ` + DELETE FROM chrono_tasks + WHERE id IN ( + SELECT id FROM chrono_tasks + WHERE status = $1 AND completed_at < $2 + LIMIT $3 + ) +`; diff --git a/packages/chrono-postgres-datastore/src/types.ts b/packages/chrono-postgres-datastore/src/types.ts new file mode 100644 index 0000000..01f07a1 --- /dev/null +++ b/packages/chrono-postgres-datastore/src/types.ts @@ -0,0 +1,20 @@ +/** + * Represents a row from the chrono_tasks table. + * Uses snake_case to match database column names. + */ +export type ChronoTaskRow = { + id: string; + kind: string; + status: string; + data: Record; + priority: number | null; + idempotency_key: string | null; + original_schedule_date: Date; + scheduled_at: Date; + claimed_at: Date | null; + completed_at: Date | null; + last_executed_at: Date | null; + retry_count: number; + created_at: Date; + updated_at: Date; +}; diff --git a/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts b/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts new file mode 100644 index 0000000..e505fcc --- /dev/null +++ b/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts @@ -0,0 +1,798 @@ +import { TaskStatus } from '@neofinancial/chrono'; +import { Pool } from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { ChronoPostgresDatastore } from '../src/chrono-postgres-datastore'; +import { MIGRATION_DOWN_SQL, MIGRATION_UP_SQL } from '../src/migration'; +import type { ChronoTaskRow } from '../src/types'; + +const DATABASE_URL = process.env.DATABASE_URL; + +type TaskMapping = { + test: { value: string }; + other: { data: number }; +}; + +describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { + let pool: Pool; + let dataStore: ChronoPostgresDatastore; + + beforeAll(async () => { + pool = new Pool({ connectionString: DATABASE_URL }); + + // Drop and recreate schema + await pool.query(MIGRATION_DOWN_SQL); + await pool.query(MIGRATION_UP_SQL); + + dataStore = new ChronoPostgresDatastore(); + await dataStore.initialize(pool); + }); + + afterAll(async () => { + await pool?.end(); + }); + + beforeEach(async () => { + await pool.query('DELETE FROM chrono_tasks'); + }); + + describe('initialize', () => { + test('throws if already initialized', async () => { + const ds = new ChronoPostgresDatastore(); + await ds.initialize(pool); + + await expect(ds.initialize(pool)).rejects.toThrow('Pool already initialized'); + }); + + test('operations wait for deferred initialization', async () => { + const ds = new ChronoPostgresDatastore(); + + // Start scheduling before initialization - it should wait + const schedulePromise = ds.schedule({ + kind: 'test', + data: { value: 'deferred' }, + priority: 1, + when: new Date(), + }); + + // Initialize after a small delay + await new Promise((resolve) => setTimeout(resolve, 10)); + await ds.initialize(pool); + + // The schedule should complete successfully + const task = await schedulePromise; + expect(task.id).toBeDefined(); + expect(task.data).toEqual({ value: 'deferred' }); + }); + }); + + describe('schedule', () => { + test('creates task with correct properties', async () => { + const when = new Date(); + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'hello' }, + priority: 5, + when, + }); + + expect(task).toMatchObject({ + kind: 'test', + status: TaskStatus.PENDING, + data: { value: 'hello' }, + priority: 5, + retryCount: 0, + }); + expect(task.id).toBeDefined(); + expect(task.scheduledAt).toEqual(when); + expect(task.originalScheduleDate).toEqual(when); + }); + + test('stores task in database', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'stored' }, + priority: 1, + when: new Date(), + }); + + const result = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [task.id]); + expect(result.rows).toHaveLength(1); + expect(result.rows[0]?.kind).toBe('test'); + expect(result.rows[0]?.data).toEqual({ value: 'stored' }); + }); + + test('returns existing task for duplicate idempotency key', async () => { + const task1 = await dataStore.schedule({ + kind: 'test', + data: { value: 'first' }, + priority: 1, + when: new Date(), + idempotencyKey: 'unique-key', + }); + + const task2 = await dataStore.schedule({ + kind: 'test', + data: { value: 'second' }, + priority: 1, + when: new Date(), + idempotencyKey: 'unique-key', + }); + + expect(task1.id).toBe(task2.id); + expect(task2.data).toEqual({ value: 'first' }); + }); + + test('allows same idempotency key for different task kinds', async () => { + const task1 = await dataStore.schedule({ + kind: 'test', + data: { value: 'test-task' }, + priority: 1, + when: new Date(), + idempotencyKey: 'shared-key', + }); + + // Different kind but same key - should fail due to unique constraint on idempotencyKey alone + await expect( + dataStore.schedule({ + kind: 'other', + data: { data: 123 }, + priority: 1, + when: new Date(), + idempotencyKey: 'shared-key', + }), + ).resolves.toMatchObject({ id: task1.id }); + }); + + test('uses provided client for transaction participation', async () => { + let taskId: string | undefined; + + // Start a transaction, schedule a task, then rollback + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'transactional' }, + priority: 1, + when: new Date(), + datastoreOptions: { client }, + }); + taskId = task.id; + + // Task should be visible within the transaction + const result = await client.query('SELECT * FROM chrono_tasks WHERE id = $1', [taskId]); + expect(result.rows.length).toBe(1); + + // Rollback + await client.query('ROLLBACK'); + } finally { + client.release(); + } + + // Task should not exist after rollback + expect(taskId).toBeDefined(); + const result = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [taskId]); + expect(result.rows.length).toBe(0); + }); + }); + + describe('claim', () => { + test('returns undefined when no tasks available', async () => { + const result = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + expect(result).toBeUndefined(); + }); + + test('claims pending task with scheduledAt in the past', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'claim-me' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + + const claimed = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + + expect(claimed).toMatchObject({ + id: task.id, + status: TaskStatus.CLAIMED, + }); + expect(claimed?.claimedAt).toBeDefined(); + }); + + test('does not claim task scheduled in the future', async () => { + await dataStore.schedule({ + kind: 'test', + data: { value: 'future' }, + priority: 1, + when: new Date(Date.now() + 60000), + }); + + const claimed = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + expect(claimed).toBeUndefined(); + }); + + test('claims tasks in priority order (higher first)', async () => { + const low = await dataStore.schedule({ + kind: 'test', + data: { value: 'low' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + const high = await dataStore.schedule({ + kind: 'test', + data: { value: 'high' }, + priority: 10, + when: new Date(Date.now() - 1000), + }); + + const first = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + const second = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + + expect(first?.id).toBe(high.id); + expect(second?.id).toBe(low.id); + }); + + test('claims tasks in scheduledAt order (earlier first) when same priority', async () => { + await dataStore.schedule({ + kind: 'test', + data: { value: 'later' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + const earlier = await dataStore.schedule({ + kind: 'test', + data: { value: 'earlier' }, + priority: 1, + when: new Date(Date.now() - 2000), + }); + + const first = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + expect(first?.id).toBe(earlier.id); + }); + + test('reclaims stale claimed task', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'stale' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + + // First claim + await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + + // Backdate claimedAt to make it stale + await pool.query(`UPDATE chrono_tasks SET claimed_at = NOW() - INTERVAL '10 seconds' WHERE id = $1`, [task.id]); + + // Should be able to reclaim + const reclaimed = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 5000 }); + expect(reclaimed?.id).toBe(task.id); + }); + + test('only claims tasks of specified kind', async () => { + await dataStore.schedule({ + kind: 'other', + data: { data: 123 }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + + const claimed = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + expect(claimed).toBeUndefined(); + }); + + test('does not claim completed tasks', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'completed' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + await dataStore.complete(task.id); + + const claimed = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + expect(claimed).toBeUndefined(); + }); + + test('does not claim failed tasks', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'failed' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + await dataStore.fail(task.id); + + const claimed = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + expect(claimed).toBeUndefined(); + }); + + test('does not claim non-stale claimed task', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'claimed' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + + // First claim succeeds + const first = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 60000 }); + expect(first?.id).toBe(task.id); + + // Second claim should return undefined (task is claimed but not stale) + const second = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 60000 }); + expect(second).toBeUndefined(); + }); + + test('concurrent claims get different tasks (SKIP LOCKED)', async () => { + // Create multiple tasks + const tasks = await Promise.all( + [1, 2, 3, 4, 5].map((i) => + dataStore.schedule({ + kind: 'test', + data: { value: `task-${i}` }, + priority: 1, + when: new Date(Date.now() - 1000), + }), + ), + ); + + // Claim all tasks concurrently + const claims = await Promise.all([ + dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 60000 }), + dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 60000 }), + dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 60000 }), + dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 60000 }), + dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 60000 }), + ]); + + // All claims should succeed + const claimedIds = claims.filter((c) => c !== undefined).map((c) => c.id); + expect(claimedIds).toHaveLength(5); + + // All claimed IDs should be unique (no duplicates) + const uniqueIds = new Set(claimedIds); + expect(uniqueIds.size).toBe(5); + + // All claimed IDs should be from our created tasks + const taskIds = new Set(tasks.map((t) => t.id)); + for (const id of claimedIds) { + expect(taskIds.has(id)).toBe(true); + } + }); + }); + + describe('complete', () => { + test('marks task as completed', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'complete-me' }, + priority: 1, + when: new Date(), + }); + + const completed = await dataStore.complete(task.id); + + expect(completed).toMatchObject({ + id: task.id, + status: TaskStatus.COMPLETED, + }); + expect(completed.completedAt).toBeDefined(); + expect(completed.lastExecutedAt).toBeDefined(); + }); + + test('throws for non-existent task', async () => { + const fakeId = '00000000-0000-0000-0000-000000000000'; + await expect(dataStore.complete(fakeId)).rejects.toThrow(`Task with ID ${fakeId} not found`); + }); + }); + + describe('fail', () => { + test('marks task as failed', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'fail-me' }, + priority: 1, + when: new Date(), + }); + + const failed = await dataStore.fail(task.id); + + expect(failed).toMatchObject({ + id: task.id, + status: TaskStatus.FAILED, + }); + expect(failed.lastExecutedAt).toBeDefined(); + }); + + test('throws for non-existent task', async () => { + const fakeId = '00000000-0000-0000-0000-000000000000'; + await expect(dataStore.fail(fakeId)).rejects.toThrow(`Task with ID ${fakeId} not found`); + }); + }); + + describe('retry', () => { + test('resets task to pending with new scheduledAt', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'retry-me' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + + const retryAt = new Date(Date.now() + 5000); + const retried = await dataStore.retry(task.id, retryAt); + + expect(retried).toMatchObject({ + id: task.id, + status: TaskStatus.PENDING, + retryCount: 1, + }); + expect(retried.scheduledAt.getTime()).toBeCloseTo(retryAt.getTime(), -2); + expect(retried.originalScheduleDate).toEqual(task.originalScheduleDate); + }); + + test('increments retryCount on each retry', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'multi-retry' }, + priority: 1, + when: new Date(), + }); + + await dataStore.retry(task.id, new Date()); + await dataStore.retry(task.id, new Date()); + const retried = await dataStore.retry(task.id, new Date()); + + expect(retried.retryCount).toBe(3); + }); + + test('throws for non-existent task', async () => { + const fakeId = '00000000-0000-0000-0000-000000000000'; + await expect(dataStore.retry(fakeId, new Date())).rejects.toThrow(`Task with ID ${fakeId} not found`); + }); + + test('clears claimedAt after retry', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'claim-then-retry' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + + // Claim the task + const claimed = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 60000 }); + expect(claimed?.claimedAt).toBeDefined(); + + // Retry should clear claimedAt + const retried = await dataStore.retry(task.id, new Date(Date.now() + 5000)); + expect(retried.claimedAt).toBeUndefined(); + + // Verify in database + const result = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [task.id]); + expect(result.rows[0]?.claimed_at).toBeNull(); + }); + }); + + describe('delete', () => { + test('deletes pending task by id', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'delete-me' }, + priority: 1, + when: new Date(), + }); + + const deleted = await dataStore.delete(task.id); + + expect(deleted?.id).toBe(task.id); + const result = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [task.id]); + expect(result.rows.length).toBe(0); + }); + + test('deletes pending task by kind and idempotencyKey', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'delete-by-key' }, + priority: 1, + when: new Date(), + idempotencyKey: 'delete-key', + }); + + const deleted = await dataStore.delete({ kind: 'test', idempotencyKey: 'delete-key' }); + + expect(deleted?.id).toBe(task.id); + }); + + test('throws when deleting non-pending task without force', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'claimed' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + + await expect(dataStore.delete(task.id)).rejects.toThrow('cannot be deleted'); + }); + + test('deletes non-pending task with force option', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'force-delete' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + + const deleted = await dataStore.delete(task.id, { force: true }); + + expect(deleted?.id).toBe(task.id); + }); + + test('returns undefined for non-existent task with force option', async () => { + const fakeId = '00000000-0000-0000-0000-000000000000'; + const result = await dataStore.delete(fakeId, { force: true }); + expect(result).toBeUndefined(); + }); + + test('throws when deleting non-existent task by kind and idempotencyKey', async () => { + await expect(dataStore.delete({ kind: 'test', idempotencyKey: 'non-existent-key' })).rejects.toThrow( + 'cannot be deleted', + ); + }); + + test('returns undefined for non-existent task by kind and idempotencyKey with force', async () => { + const result = await dataStore.delete({ kind: 'test', idempotencyKey: 'non-existent-key' }, { force: true }); + expect(result).toBeUndefined(); + }); + + test('deletes non-pending task by kind and idempotencyKey with force', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'force-delete-by-key' }, + priority: 1, + when: new Date(Date.now() - 1000), + idempotencyKey: 'force-key', + }); + await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + + const deleted = await dataStore.delete({ kind: 'test', idempotencyKey: 'force-key' }, { force: true }); + + expect(deleted?.id).toBe(task.id); + const result = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [task.id]); + expect(result.rows.length).toBe(0); + }); + }); + + describe('cleanup', () => { + const createDataStoreWithConfig = async (config: ConstructorParameters[0]) => { + const ds = new ChronoPostgresDatastore(config); + await ds.initialize(pool); + return ds; + }; + + const waitForCleanup = () => new Promise((resolve) => setTimeout(resolve, 50)); + + test('deletes completed tasks older than TTL after claim', async () => { + const ds = await createDataStoreWithConfig({ + completedDocumentTTLSeconds: 1, + cleanupIntervalSeconds: 0, + }); + + const task = await ds.schedule({ + kind: 'test', + data: { value: 'old-task' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + await ds.complete(task.id); + + // Backdate to be older than TTL + await pool.query(`UPDATE chrono_tasks SET completed_at = NOW() - INTERVAL '2 seconds' WHERE id = $1`, [task.id]); + + await ds.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + await waitForCleanup(); + + const result = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [task.id]); + expect(result.rows.length).toBe(0); + }); + + test('preserves completed tasks newer than TTL', async () => { + const ds = await createDataStoreWithConfig({ + completedDocumentTTLSeconds: 3600, + cleanupIntervalSeconds: 0, + }); + + const task = await ds.schedule({ + kind: 'test', + data: { value: 'recent-task' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + await ds.complete(task.id); + + await ds.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + await waitForCleanup(); + + const result = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [task.id]); + expect(result.rows.length).toBe(1); + }); + + test('respects cleanup interval', async () => { + const ds = await createDataStoreWithConfig({ + completedDocumentTTLSeconds: 1, + cleanupIntervalSeconds: 3600, // 1 hour + }); + + // First task - will be cleaned up + const task1 = await ds.schedule({ + kind: 'test', + data: { value: 'task1' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + await ds.complete(task1.id); + await pool.query(`UPDATE chrono_tasks SET completed_at = NOW() - INTERVAL '2 seconds' WHERE id = $1`, [task1.id]); + + await ds.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + await waitForCleanup(); + + const result1 = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [task1.id]); + expect(result1.rows.length).toBe(0); + + // Second task - should NOT be cleaned up (interval not passed) + const task2 = await ds.schedule({ + kind: 'test', + data: { value: 'task2' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + await ds.complete(task2.id); + await pool.query(`UPDATE chrono_tasks SET completed_at = NOW() - INTERVAL '2 seconds' WHERE id = $1`, [task2.id]); + + await ds.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + await waitForCleanup(); + + const result2 = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [task2.id]); + expect(result2.rows.length).toBe(1); + }); + + test('respects batch size limit', async () => { + const ds = await createDataStoreWithConfig({ + completedDocumentTTLSeconds: 1, + cleanupIntervalSeconds: 0, + cleanupBatchSize: 2, + }); + + // Create and complete 4 tasks + const tasks = await Promise.all( + [1, 2, 3, 4].map((i) => + ds.schedule({ + kind: 'test', + data: { value: `task-${i}` }, + priority: 1, + when: new Date(Date.now() - 1000), + }), + ), + ); + await Promise.all(tasks.map((t) => ds.complete(t.id))); + + // Backdate all + await pool.query(`UPDATE chrono_tasks SET completed_at = NOW() - INTERVAL '2 seconds' WHERE status = $1`, [ + TaskStatus.COMPLETED, + ]); + + await ds.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + await waitForCleanup(); + + const result = await pool.query('SELECT COUNT(*) as count FROM chrono_tasks'); + expect(Number(result.rows[0].count)).toBe(2); // Only 2 deleted (batch size), 2 remain + }); + + test('calls onCleanupError callback on failure', async () => { + const onCleanupError = vi.fn(); + const ds = await createDataStoreWithConfig({ + completedDocumentTTLSeconds: 1, + cleanupIntervalSeconds: 0, + onCleanupError, + }); + + // Create a task to ensure claim triggers cleanup + await ds.schedule({ + kind: 'test', + data: { value: 'error-test' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + + // Mock cleanup to throw - this tests that errors are properly passed to onCleanupError + const cleanupError = new Error('Cleanup failed'); + vi.spyOn(ds as never, 'cleanupCompletedTasks').mockRejectedValue(cleanupError); + + await ds.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + await waitForCleanup(); + + expect(onCleanupError).toHaveBeenCalledWith(cleanupError); + }); + + test('preserves pending and failed tasks during cleanup', async () => { + const ds = await createDataStoreWithConfig({ + completedDocumentTTLSeconds: 1, + cleanupIntervalSeconds: 0, + }); + + // Create tasks in different states + const pendingTask = await ds.schedule({ + kind: 'test', + data: { value: 'pending' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + + const failedTask = await ds.schedule({ + kind: 'test', + data: { value: 'failed' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + await ds.fail(failedTask.id); + + const completedTask = await ds.schedule({ + kind: 'test', + data: { value: 'completed' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + await ds.complete(completedTask.id); + + // Backdate all tasks to be older than TTL + await pool.query(`UPDATE chrono_tasks SET created_at = NOW() - INTERVAL '2 seconds'`); + await pool.query(`UPDATE chrono_tasks SET completed_at = NOW() - INTERVAL '2 seconds' WHERE status = $1`, [ + TaskStatus.COMPLETED, + ]); + + // Trigger cleanup + await ds.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); + await waitForCleanup(); + + // Pending task should still exist + const pendingResult = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [pendingTask.id]); + expect(pendingResult.rows.length).toBe(1); + + // Failed task should still exist + const failedResult = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [failedTask.id]); + expect(failedResult.rows.length).toBe(1); + + // Completed task should be deleted + const completedResult = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [completedTask.id]); + expect(completedResult.rows.length).toBe(0); + }); + }); + + describe('migration', () => { + test('MIGRATION_UP_SQL creates table and indexes', async () => { + // Table should exist (we already ran migration in beforeAll) + const tableResult = await pool.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'chrono_tasks' + ) + `); + expect(tableResult.rows[0].exists).toBe(true); + + // Check indexes + const indexResult = await pool.query(` + SELECT indexname FROM pg_indexes WHERE tablename = 'chrono_tasks' + `); + const indexNames = indexResult.rows.map((r: { indexname: string }) => r.indexname); + + expect(indexNames).toContain('chrono_tasks_pkey'); + expect(indexNames).toContain('idx_chrono_tasks_claim'); + expect(indexNames).toContain('idx_chrono_tasks_cleanup'); + expect(indexNames).toContain('idx_chrono_tasks_idempotency'); + }); + }); +}); diff --git a/packages/chrono-postgres-datastore/test/tsconfig.json b/packages/chrono-postgres-datastore/test/tsconfig.json new file mode 100644 index 0000000..d6e1cfb --- /dev/null +++ b/packages/chrono-postgres-datastore/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".." + }, + "include": ["**/*.ts", "../src/**/*.ts"] +} diff --git a/packages/chrono-postgres-datastore/tsconfig.json b/packages/chrono-postgres-datastore/tsconfig.json new file mode 100644 index 0000000..426c048 --- /dev/null +++ b/packages/chrono-postgres-datastore/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./build", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build", "test"] +} diff --git a/packages/chrono-postgres-datastore/tsdown.config.ts b/packages/chrono-postgres-datastore/tsdown.config.ts new file mode 100644 index 0000000..bc97c40 --- /dev/null +++ b/packages/chrono-postgres-datastore/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm', 'cjs'], + outDir: './build', + sourcemap: true, + hash: false, +}); diff --git a/packages/chrono-postgres-datastore/vitest.config.ts b/packages/chrono-postgres-datastore/vitest.config.ts new file mode 100644 index 0000000..ab9857d --- /dev/null +++ b/packages/chrono-postgres-datastore/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineProject, mergeConfig } from 'vitest/config'; +import baseConfig from '../../vitest.base.config.js'; + +export default mergeConfig( + baseConfig, + defineProject({ + test: { + include: ['test/**/*.test.ts'], + }, + }), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be4896b..1e59d67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,19 @@ importers: specifier: ^10.1.4 version: 10.1.4 + packages/chrono-postgres-datastore: + dependencies: + pg: + specifier: ^8.13.1 + version: 8.17.2 + devDependencies: + '@neofinancial/chrono': + specifier: workspace:* + version: link:../chrono-core + '@types/pg': + specifier: ^8.11.0 + version: 8.16.0 + packages: '@babel/generator@7.28.3': @@ -144,11 +157,11 @@ packages: cpu: [x64] os: [win32] - '@emnapi/core@1.5.0': - resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} - '@emnapi/runtime@1.5.0': - resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -330,100 +343,94 @@ packages: '@mongodb-js/saslprep@1.2.0': resolution: {integrity: sha512-+ywrb0AqkfaYuhHs6LxKWgqbh3I72EpEgESCw37o+9qPx9WTCkgDm2B+eMrwehGtHBWHFU4GXvnSCNiFhhausg==} - '@napi-rs/wasm-runtime@1.0.6': - resolution: {integrity: sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g==} + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} - '@oxc-project/types@0.93.0': - resolution: {integrity: sha512-yNtwmWZIBtJsMr5TEfoZFDxIWV6OdScOpza/f5YxbqUMJk+j6QX3Cf3jgZShGEFYWQJ5j9mJ6jM0tZHu2J9Yrg==} + '@oxc-project/types@0.111.0': + resolution: {integrity: sha512-bh54LJMafgRGl2cPQ/QM+tI5rWaShm/wK9KywEj/w36MhiPKXYM67H2y3q+9pr4YO7ufwg2AKdBAZkhHBD8ClA==} '@quansync/fs@0.1.5': resolution: {integrity: sha512-lNS9hL2aS2NZgNW7BBj+6EBl4rOf8l+tQ0eRY6JWCI8jI2kc53gSoqbjojU0OnAWhzoXiOjFyGsHcDGePB3lhA==} - '@rolldown/binding-android-arm64@1.0.0-beta.41': - resolution: {integrity: sha512-Edflndd9lU7JVhVIvJlZhdCj5DkhYDJPIRn4Dx0RUdfc8asP9xHOI5gMd8MesDDx+BJpdIT/uAmVTearteU/mQ==} + '@rolldown/binding-android-arm64@1.0.0-rc.2': + resolution: {integrity: sha512-AGV80viZ4Hil4C16GFH+PSwq10jclV9oyRFhD+5HdowPOCJ+G+99N5AClQvMkUMIahTY8cX0SQpKEEWcCg6fSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-beta.41': - resolution: {integrity: sha512-XGCzqfjdk7550PlyZRTBKbypXrB7ATtXhw/+bjtxnklLQs0mKP/XkQVOKyn9qGKSlvH8I56JLYryVxl0PCvSNw==} + '@rolldown/binding-darwin-arm64@1.0.0-rc.2': + resolution: {integrity: sha512-PYR+PQu1mMmQiiKHN2JiOctvH32Xc/Mf+Su2RSmWtC9BbIqlqsVWjbulnShk0imjRim0IsbkMMCN5vYQwiuqaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-beta.41': - resolution: {integrity: sha512-Ho6lIwGJed98zub7n0xcRKuEtnZgbxevAmO4x3zn3C3N4GVXZD5xvCvTVxSMoeBJwTcIYzkVDRTIhylQNsTgLQ==} + '@rolldown/binding-darwin-x64@1.0.0-rc.2': + resolution: {integrity: sha512-X2G36Z6oh5ynoYpE2JAyG+uQ4kO/3N7XydM/I98FNk8VVgDKjajFF+v7TXJ2FMq6xa7Xm0UIUKHW2MRQroqoUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-beta.41': - resolution: {integrity: sha512-ijAZETywvL+gACjbT4zBnCp5ez1JhTRs6OxRN4J+D6AzDRbU2zb01Esl51RP5/8ZOlvB37xxsRQ3X4YRVyYb3g==} + '@rolldown/binding-freebsd-x64@1.0.0-rc.2': + resolution: {integrity: sha512-XpiFTsl9qjiDfrmJF6CE3dgj1nmSbxUIT+p2HIbXV6WOj/32btO8FKkWSsOphUwVinEt3R8HVkVrcLtFNruMMQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.41': - resolution: {integrity: sha512-EgIOZt7UildXKFEFvaiLNBXm+4ggQyGe3E5Z1QP9uRcJJs9omihOnm897FwOBQdCuMvI49iBgjFrkhH+wMJ2MA==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.2': + resolution: {integrity: sha512-zjYZ99e47Wlygs4hW+sQ+kshlO8ake9OoY2ecnJ9cwpDGiiIB9rQ3LgP3kt8j6IeVyMSksu//VEhc8Mrd1lRIw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.41': - resolution: {integrity: sha512-F8bUwJq8v/JAU8HSwgF4dztoqJ+FjdyjuvX4//3+Fbe2we9UktFeZ27U4lRMXF1vxWtdV4ey6oCSqI7yUrSEeg==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.2': + resolution: {integrity: sha512-Piso04EZ9IHV1aZSsLQVMOPTiCq4Ps2UPL3pchjNXHGJGFiB9U42s22LubPaEBFS+i6tCawS5EarIwex1zC4BA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.41': - resolution: {integrity: sha512-MioXcCIX/wB1pBnBoJx8q4OGucUAfC1+/X1ilKFsjDK05VwbLZGRgOVD5OJJpUQPK86DhQciNBrfOKDiatxNmg==} + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.2': + resolution: {integrity: sha512-OwJCeMZlmjKsN9pfJfTmqYpe3JC+L6RO87+hu9ajRLr1Lh6cM2FRQ8e48DLRyRDww8Ti695XQvqEANEMmsuzLw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.41': - resolution: {integrity: sha512-m66M61fizvRCwt5pOEiZQMiwBL9/y0bwU/+Kc4Ce/Pef6YfoEkR28y+DzN9rMdjo8Z28NXjsDPq9nH4mXnAP0g==} + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.2': + resolution: {integrity: sha512-uQqBmA8dTWbKvfqbeSsXNUssRGfdgQCc0hkGfhQN7Pf85wG2h0Fd/z2d+ykyT4YbcsjQdgEGxBNsg3v4ekOuEA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-beta.41': - resolution: {integrity: sha512-yRxlSfBvWnnfrdtJfvi9lg8xfG5mPuyoSHm0X01oiE8ArmLRvoJGHUTJydCYz+wbK2esbq5J4B4Tq9WAsOlP1Q==} + '@rolldown/binding-linux-x64-musl@1.0.0-rc.2': + resolution: {integrity: sha512-ItZabVsICCYWHbP+jcAgNzjPAYg5GIVQp/NpqT6iOgWctaMYtobClc5m0kNtxwqfNrLXoyt998xUey4AvcxnGQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.0.0-beta.41': - resolution: {integrity: sha512-PHVxYhBpi8UViS3/hcvQQb9RFqCtvFmFU1PvUoTRiUdBtgHA6fONNHU4x796lgzNlVSD3DO/MZNk1s5/ozSMQg==} + '@rolldown/binding-openharmony-arm64@1.0.0-rc.2': + resolution: {integrity: sha512-U4UYANwafcMXSUC0VqdrqTAgCo2v8T7SiuTYwVFXgia0KOl8jiv3okwCFqeZNuw/G6EWDiqhT8kK1DLgyLsxow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-beta.41': - resolution: {integrity: sha512-OAfcO37ME6GGWmj9qTaDT7jY4rM0T2z0/8ujdQIJQ2x2nl+ztO32EIwURfmXOK0U1tzkyuaKYvE34Pug/ucXlQ==} + '@rolldown/binding-wasm32-wasi@1.0.0-rc.2': + resolution: {integrity: sha512-ZIWCjQsMon4tqRoao0Vzowjwx0cmFT3kublh2nNlgeasIJMWlIGHtr0d4fPypm57Rqx4o1h4L8SweoK2q6sMGA==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.41': - resolution: {integrity: sha512-NIYGuCcuXaq5BC4Q3upbiMBvmZsTsEPG9k/8QKQdmrch+ocSy5Jv9tdpdmXJyighKqm182nh/zBt+tSJkYoNlg==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.2': + resolution: {integrity: sha512-NIo7vwRUPEzZ4MuZGr5YbDdjJ84xdiG+YYf8ZBfTgvIsk9wM0sZamJPEXvaLkzVIHpOw5uqEHXS85Gqqb7aaqQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.41': - resolution: {integrity: sha512-kANdsDbE5FkEOb5NrCGBJBCaZ2Sabp3D7d4PRqMYJqyLljwh9mDyYyYSv5+QNvdAmifj+f3lviNEUUuUZPEFPw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.41': - resolution: {integrity: sha512-UlpxKmFdik0Y2VjZrgUCgoYArZJiZllXgIipdBRV1hw6uK45UbQabSTW6Kp6enuOu7vouYWftwhuxfpE8J2JAg==} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.2': + resolution: {integrity: sha512-bLKzyLFbvngeNPZocuLo3LILrKwCrkyMxmRXs6fZYDrvh7cyZRw9v56maDL9ipPas0OOmQK1kAKYwvTs30G21Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-beta.41': - resolution: {integrity: sha512-ycMEPrS3StOIeb87BT3/+bu+blEtyvwQ4zmo2IcJQy0Rd1DAAhKksA0iUZ3MYSpJtjlPhg0Eo6mvVS6ggPhRbw==} + '@rolldown/pluginutils@1.0.0-rc.2': + resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} '@rollup/rollup-android-arm-eabi@4.35.0': resolution: {integrity: sha512-uYQ2WfPaqz5QtVgMxfN6NpLD+no0MYHDBywl7itPYd3K5TjjSghNKmX8ic9S8NU8w81NVhJv/XojcHptRly7qQ==} @@ -538,6 +545,9 @@ packages: '@types/node@20.19.17': resolution: {integrity: sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==} + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} @@ -939,6 +949,40 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.10.1: + resolution: {integrity: sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.11.0: + resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.17.2: + resolution: {integrity: sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==} + 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==} @@ -954,6 +998,22 @@ packages: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -992,8 +1052,8 @@ packages: vue-tsc: optional: true - rolldown@1.0.0-beta.41: - resolution: {integrity: sha512-U+NPR0Bkg3wm61dteD2L4nAM1U9dtaqVrpDXwC36IKRHpEO/Ubpid4Nijpa2imPchcVNHfxVFwSSMJdwdGFUbg==} + rolldown@1.0.0-rc.2: + resolution: {integrity: sha512-1g/8Us9J8sgJGn3hZfBecX1z4U3y5KO7V/aV2U1M/9UUzLNqHA8RfFQ/NPT7HLxOIldyIgrcjaYTRvA81KhJIg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1038,6 +1098,10 @@ packages: sparse-bitfield@3.0.3: resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1254,6 +1318,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yauzl@3.2.0: resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==} engines: {node: '>=12'} @@ -1316,13 +1384,13 @@ snapshots: '@biomejs/cli-win32-x64@2.2.4': optional: true - '@emnapi/core@1.5.0': + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.5.0': + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 optional: true @@ -1438,64 +1506,61 @@ snapshots: dependencies: sparse-bitfield: 3.0.3 - '@napi-rs/wasm-runtime@1.0.6': + '@napi-rs/wasm-runtime@1.1.1': dependencies: - '@emnapi/core': 1.5.0 - '@emnapi/runtime': 1.5.0 + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 '@tybys/wasm-util': 0.10.1 optional: true - '@oxc-project/types@0.93.0': {} + '@oxc-project/types@0.111.0': {} '@quansync/fs@0.1.5': dependencies: quansync: 0.2.11 - '@rolldown/binding-android-arm64@1.0.0-beta.41': + '@rolldown/binding-android-arm64@1.0.0-rc.2': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-beta.41': + '@rolldown/binding-darwin-arm64@1.0.0-rc.2': optional: true - '@rolldown/binding-darwin-x64@1.0.0-beta.41': + '@rolldown/binding-darwin-x64@1.0.0-rc.2': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-beta.41': + '@rolldown/binding-freebsd-x64@1.0.0-rc.2': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.41': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.2': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.41': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.2': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.41': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.2': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.41': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.2': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-beta.41': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.2': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-beta.41': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.2': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-beta.41': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.2': dependencies: - '@napi-rs/wasm-runtime': 1.0.6 - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.41': + '@napi-rs/wasm-runtime': 1.1.1 optional: true - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.41': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.2': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.41': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.2': optional: true - '@rolldown/pluginutils@1.0.0-beta.41': {} + '@rolldown/pluginutils@1.0.0-rc.2': {} '@rollup/rollup-android-arm-eabi@4.35.0': optional: true @@ -1573,6 +1638,12 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pg@8.16.0': + dependencies: + '@types/node': 20.19.17 + pg-protocol: 1.11.0 + pg-types: 2.2.0 + '@types/webidl-conversions@7.0.3': {} '@types/whatwg-url@11.0.5': @@ -1944,6 +2015,41 @@ snapshots: pend@1.2.0: {} + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.10.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.11.0(pg@8.17.2): + dependencies: + pg: 8.17.2 + + pg-protocol@1.11.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.17.2: + dependencies: + pg-connection-string: 2.10.1 + pg-pool: 3.11.0(pg@8.17.2) + pg-protocol: 1.11.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@4.0.3: {} @@ -1958,6 +2064,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + punycode@2.3.1: {} quansync@0.2.11: {} @@ -1971,7 +2087,7 @@ snapshots: glob: 11.0.1 package-json-from-dist: 1.0.1 - rolldown-plugin-dts@0.16.11(rolldown@1.0.0-beta.41)(typescript@5.9.2): + rolldown-plugin-dts@0.16.11(rolldown@1.0.0-rc.2)(typescript@5.9.2): dependencies: '@babel/generator': 7.28.3 '@babel/parser': 7.28.4 @@ -1982,33 +2098,31 @@ snapshots: dts-resolver: 2.1.2 get-tsconfig: 4.10.1 magic-string: 0.30.19 - rolldown: 1.0.0-beta.41 + rolldown: 1.0.0-rc.2 optionalDependencies: typescript: 5.9.2 transitivePeerDependencies: - oxc-resolver - supports-color - rolldown@1.0.0-beta.41: + rolldown@1.0.0-rc.2: dependencies: - '@oxc-project/types': 0.93.0 - '@rolldown/pluginutils': 1.0.0-beta.41 - ansis: 4.2.0 + '@oxc-project/types': 0.111.0 + '@rolldown/pluginutils': 1.0.0-rc.2 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-beta.41 - '@rolldown/binding-darwin-arm64': 1.0.0-beta.41 - '@rolldown/binding-darwin-x64': 1.0.0-beta.41 - '@rolldown/binding-freebsd-x64': 1.0.0-beta.41 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.41 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.41 - '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.41 - '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.41 - '@rolldown/binding-linux-x64-musl': 1.0.0-beta.41 - '@rolldown/binding-openharmony-arm64': 1.0.0-beta.41 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.41 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.41 - '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.41 - '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.41 + '@rolldown/binding-android-arm64': 1.0.0-rc.2 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.2 + '@rolldown/binding-darwin-x64': 1.0.0-rc.2 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.2 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.2 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.2 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.2 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.2 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.2 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.2 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.2 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.2 rollup@4.35.0: dependencies: @@ -2057,6 +2171,8 @@ snapshots: dependencies: memory-pager: 1.5.0 + split2@4.2.0: {} + stackback@0.0.2: {} std-env@3.9.0: {} @@ -2138,8 +2254,8 @@ snapshots: diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 - rolldown: 1.0.0-beta.41 - rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-beta.41)(typescript@5.9.2) + rolldown: 1.0.0-rc.2 + rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-rc.2)(typescript@5.9.2) semver: 7.7.2 tinyexec: 1.0.1 tinyglobby: 0.2.15 @@ -2273,6 +2389,8 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + xtend@4.0.2: {} + yauzl@3.2.0: dependencies: buffer-crc32: 0.2.13 diff --git a/tsconfig.base.json b/tsconfig.base.json index abcef04..425a445 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -8,6 +8,8 @@ "moduleDetection": "force", "isolatedModules": true, "verbatimModuleSyntax": false, + "declaration": true, + "declarationMap": true, "strict": true, "noUncheckedIndexedAccess": true,