From 5df5678404ba3f8a5eebbcd2c3f80e915bdbdb94 Mon Sep 17 00:00:00 2001 From: James Couch Date: Tue, 27 Jan 2026 02:03:39 -0700 Subject: [PATCH 01/21] Add ChronoPostgresDatastore implementation - Implement PostgreSQL datastore using TypeORM with raw SQL for atomic task claiming - Use FOR UPDATE SKIP LOCKED pattern for concurrent task claiming - Add PGLite-based tests that run without external database dependency - Fix tsdown config across packages to use hash: false for clean output filenames Co-Authored-By: Claude (claude-4-opus) --- packages/chrono-core/tsdown.config.ts | 1 + .../chrono-memory-datastore/tsdown.config.ts | 1 + .../chrono-mongo-datastore/tsdown.config.ts | 1 + .../chrono-postgres-datastore/package.json | 53 ++ .../src/chrono-postgres-datastore.ts | 377 +++++++++ .../src/chrono-task.entity.ts | 47 + .../chrono-postgres-datastore/src/index.ts | 6 + .../test/database-setup.ts | 117 +++ .../unit/chrono-postgres-datastore.test.ts | 550 ++++++++++++ .../chrono-postgres-datastore/tsconfig.json | 13 + .../tsdown.config.ts | 9 + .../vitest.config.ts | 4 + pnpm-lock.yaml | 800 ++++++++++++++++-- 13 files changed, 1889 insertions(+), 90 deletions(-) create mode 100644 packages/chrono-postgres-datastore/package.json create mode 100644 packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts create mode 100644 packages/chrono-postgres-datastore/src/chrono-task.entity.ts create mode 100644 packages/chrono-postgres-datastore/src/index.ts create mode 100644 packages/chrono-postgres-datastore/test/database-setup.ts create mode 100644 packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts create mode 100644 packages/chrono-postgres-datastore/tsconfig.json create mode 100644 packages/chrono-postgres-datastore/tsdown.config.ts create mode 100644 packages/chrono-postgres-datastore/vitest.config.ts 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..92efd3f --- /dev/null +++ b/packages/chrono-postgres-datastore/package.json @@ -0,0 +1,53 @@ +{ + "name": "@neofinancial/chrono-postgres-datastore", + "version": "0.5.1", + "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", + "test": "NODE_ENV=test TZ=UTC vitest run" + }, + "devDependencies": { + "@electric-sql/pglite": "^0.2.0", + "@faker-js/faker": "^9.0.0", + "@neofinancial/chrono": "workspace:*", + "pg": "^8.13.1", + "typeorm": "^0.3.20" + }, + "peerDependencies": { + "typeorm": "^0.3.20", + "@neofinancial/chrono": "workspace:*" + } +} 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..12c2ead --- /dev/null +++ b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts @@ -0,0 +1,377 @@ +import { + type ClaimTaskInput, + type Datastore, + type DeleteInput, + type DeleteOptions, + type ScheduleInput, + type Task, + type TaskMappingBase, + TaskStatus, +} from '@neofinancial/chrono'; +import type { DataSource, EntityManager } from 'typeorm'; +import { ChronoTaskEntity } from './chrono-task.entity'; + +const DEFAULT_TABLE_NAME = 'chrono_tasks'; + +export type ChronoPostgresDatastoreConfig = { + /** + * The name of the table to use for storing tasks. + * + * @default 'chrono_tasks' + */ + tableName?: string; +}; + +export type PostgresDatastoreOptions = { + /** + * Optional EntityManager for participating in external transactions. + * When provided, all operations will use this manager instead of creating new queries. + */ + entityManager?: EntityManager; +}; + +type TaskRow = { + 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; +}; + +export class ChronoPostgresDatastore + implements Datastore +{ + private config: Required; + private dataSource: DataSource | undefined; + private dataSourceResolvers: Array<(ds: DataSource) => void> = []; + + constructor(config?: ChronoPostgresDatastoreConfig) { + this.config = { + tableName: config?.tableName ?? DEFAULT_TABLE_NAME, + }; + } + + /** + * Initializes the datastore with a TypeORM DataSource. + * Must be called before any operations can be performed. + * + * @param dataSource - The TypeORM DataSource connected to PostgreSQL + */ + async initialize(dataSource: DataSource): Promise { + if (this.dataSource) { + throw new Error('DataSource already initialized'); + } + + this.dataSource = dataSource; + + // Resolve any pending operations waiting for the dataSource + const resolvers = this.dataSourceResolvers.splice(0); + for (const resolve of resolvers) { + resolve(dataSource); + } + } + + /** + * Returns the entity class for use with TypeORM. + * Useful for registering the entity with a DataSource. + */ + static getEntity(): typeof ChronoTaskEntity { + return ChronoTaskEntity; + } + + /** + * Asynchronously gets the DataSource. If not yet initialized, + * returns a promise that resolves when initialize() is called. + */ + private async getDataSource(): Promise { + if (this.dataSource) { + return this.dataSource; + } + + return new Promise((resolve) => { + this.dataSourceResolvers.push(resolve); + }); + } + + /** + * Gets the EntityManager to use for operations. + * Uses the provided manager from options if available, otherwise uses the DataSource's manager. + */ + private getManager(options?: PostgresDatastoreOptions): EntityManager { + if (options?.entityManager) { + return options.entityManager; + } + + if (!this.dataSource) { + throw new Error('DataSource not initialized'); + } + + return this.dataSource.manager; + } + + async schedule( + input: ScheduleInput, + ): Promise> { + await this.getDataSource(); + const manager = this.getManager(input.datastoreOptions); + + try { + const entity = manager.create(ChronoTaskEntity, { + kind: String(input.kind), + status: TaskStatus.PENDING, + data: input.data as Record, + priority: input.priority ?? 0, + idempotencyKey: input.idempotencyKey ?? null, + originalScheduleDate: input.when, + scheduledAt: input.when, + retryCount: 0, + }); + + const saved = await manager.save(ChronoTaskEntity, entity); + return this.toTask(saved); + } catch (error) { + // Handle unique constraint violation on idempotency_key + if (this.isUniqueViolation(error) && input.idempotencyKey) { + const existing = await manager.findOne(ChronoTaskEntity, { + where: { idempotencyKey: input.idempotencyKey }, + }); + + if (existing) { + return this.toTask(existing); + } + + throw new Error( + `Failed to find existing task with idempotency key ${input.idempotencyKey} despite unique constraint error`, + ); + } + throw error; + } + } + + async delete>( + key: DeleteInput, + options?: DeleteOptions, + ): Promise | undefined> { + const dataSource = await this.getDataSource(); + const manager = dataSource.manager; + const tableName = this.config.tableName; + + if (typeof key === 'string') { + // Delete by ID + const result = await manager.query( + ` + DELETE FROM ${tableName} + WHERE id = $1 + ${options?.force ? '' : "AND status = 'PENDING'"} + RETURNING * + `, + [key], + ); + + if (result.length === 0) { + if (options?.force) { + return undefined; + } + throw new Error(`Task with id ${key} cannot be deleted as it may not exist or it's not in PENDING status.`); + } + + return this.toTaskFromRow(result[0]); + } else { + // Delete by kind + idempotencyKey + const result = await manager.query( + ` + DELETE FROM ${tableName} + WHERE kind = $1 AND idempotency_key = $2 + ${options?.force ? '' : "AND status = 'PENDING'"} + RETURNING * + `, + [String(key.kind), key.idempotencyKey], + ); + + if (result.length === 0) { + if (options?.force) { + return undefined; + } + throw new Error( + `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.`, + ); + } + + return this.toTaskFromRow(result[0]); + } + } + + async claim>( + input: ClaimTaskInput, + ): Promise | undefined> { + const dataSource = await this.getDataSource(); + const now = new Date(); + const staleThreshold = new Date(now.getTime() - input.claimStaleTimeoutMs); + const tableName = this.config.tableName; + + // Use FOR UPDATE SKIP LOCKED for atomic claiming without blocking other processors + const result = await dataSource.manager.query( + ` + UPDATE ${tableName} + SET status = $1, claimed_at = $2, updated_at = $2 + WHERE id = ( + SELECT id FROM ${tableName} + WHERE kind = $3 + AND scheduled_at <= $4 + AND ( + status = 'PENDING' + OR (status = 'CLAIMED' AND claimed_at <= $5) + ) + ORDER BY priority DESC, scheduled_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING * + `, + [TaskStatus.CLAIMED, now, String(input.kind), now, staleThreshold], + ); + + if (result.length === 0) { + return undefined; + } + + return this.toTaskFromRow(result[0]); + } + + async retry( + taskId: string, + retryAt: Date, + ): Promise> { + const dataSource = await this.getDataSource(); + const now = new Date(); + const tableName = this.config.tableName; + + const result = await dataSource.manager.query( + ` + UPDATE ${tableName} + SET status = $1, + scheduled_at = $2, + last_executed_at = $3, + retry_count = retry_count + 1, + claimed_at = NULL, + updated_at = $3 + WHERE id = $4 + RETURNING * + `, + [TaskStatus.PENDING, retryAt, now, taskId], + ); + + if (result.length === 0) { + throw new Error(`Task with ID ${taskId} not found`); + } + + return this.toTaskFromRow(result[0]); + } + + async complete(taskId: string): Promise> { + const dataSource = await this.getDataSource(); + const now = new Date(); + const tableName = this.config.tableName; + + const result = await dataSource.manager.query( + ` + UPDATE ${tableName} + SET status = $1, + completed_at = $2, + last_executed_at = $2, + updated_at = $2 + WHERE id = $3 + RETURNING * + `, + [TaskStatus.COMPLETED, now, taskId], + ); + + if (result.length === 0) { + throw new Error(`Task with ID ${taskId} not found`); + } + + return this.toTaskFromRow(result[0]); + } + + async fail(taskId: string): Promise> { + const dataSource = await this.getDataSource(); + const now = new Date(); + const tableName = this.config.tableName; + + const result = await dataSource.manager.query( + ` + UPDATE ${tableName} + SET status = $1, + last_executed_at = $2, + updated_at = $2 + WHERE id = $3 + RETURNING * + `, + [TaskStatus.FAILED, now, taskId], + ); + + if (result.length === 0) { + throw new Error(`Task with ID ${taskId} not found`); + } + + return this.toTaskFromRow(result[0]); + } + + /** + * Checks if an error is a PostgreSQL unique constraint violation. + * Error code 23505 is UNIQUE_VIOLATION in PostgreSQL. + */ + private isUniqueViolation(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && 'code' in error && (error as { code: string }).code === '23505' + ); + } + + /** + * Converts a ChronoTaskEntity to a Task object. + */ + private toTask(entity: ChronoTaskEntity): Task { + return { + id: entity.id, + kind: entity.kind as TaskKind, + status: entity.status as TaskStatus, + data: entity.data as TaskMapping[TaskKind], + priority: entity.priority ?? undefined, + idempotencyKey: entity.idempotencyKey ?? undefined, + originalScheduleDate: entity.originalScheduleDate, + scheduledAt: entity.scheduledAt, + claimedAt: entity.claimedAt ?? undefined, + completedAt: entity.completedAt ?? undefined, + lastExecutedAt: entity.lastExecutedAt ?? undefined, + retryCount: entity.retryCount, + }; + } + + /** + * Converts a raw database row to a Task object. + * Used when returning results from raw SQL queries. + */ + private toTaskFromRow(row: TaskRow): 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: new Date(row.original_schedule_date), + scheduledAt: new Date(row.scheduled_at), + claimedAt: row.claimed_at ? new Date(row.claimed_at) : undefined, + completedAt: row.completed_at ? new Date(row.completed_at) : undefined, + lastExecutedAt: row.last_executed_at ? new Date(row.last_executed_at) : undefined, + retryCount: row.retry_count, + }; + } +} diff --git a/packages/chrono-postgres-datastore/src/chrono-task.entity.ts b/packages/chrono-postgres-datastore/src/chrono-task.entity.ts new file mode 100644 index 0000000..a4550f7 --- /dev/null +++ b/packages/chrono-postgres-datastore/src/chrono-task.entity.ts @@ -0,0 +1,47 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('chrono_tasks') +@Index('idx_chrono_tasks_claim', ['kind', 'status', 'scheduledAt', 'priority', 'claimedAt']) +export class ChronoTaskEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 255 }) + kind!: string; + + @Column({ type: 'varchar', length: 20, default: 'PENDING' }) + status!: string; + + @Column({ type: 'jsonb' }) + data!: Record; + + @Column({ type: 'integer', nullable: true, default: 0 }) + priority!: number | null; + + @Column({ name: 'idempotency_key', type: 'varchar', length: 255, nullable: true }) + idempotencyKey!: string | null; + + @Column({ name: 'original_schedule_date', type: 'timestamptz' }) + originalScheduleDate!: Date; + + @Column({ name: 'scheduled_at', type: 'timestamptz' }) + scheduledAt!: Date; + + @Column({ name: 'claimed_at', type: 'timestamptz', nullable: true }) + claimedAt!: Date | null; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt!: Date | null; + + @Column({ name: 'last_executed_at', type: 'timestamptz', nullable: true }) + lastExecutedAt!: Date | null; + + @Column({ name: 'retry_count', type: 'integer', default: 0 }) + retryCount!: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/packages/chrono-postgres-datastore/src/index.ts b/packages/chrono-postgres-datastore/src/index.ts new file mode 100644 index 0000000..340ec1e --- /dev/null +++ b/packages/chrono-postgres-datastore/src/index.ts @@ -0,0 +1,6 @@ +export { + ChronoPostgresDatastore, + type ChronoPostgresDatastoreConfig, + type PostgresDatastoreOptions, +} from './chrono-postgres-datastore'; +export { ChronoTaskEntity } from './chrono-task.entity'; diff --git a/packages/chrono-postgres-datastore/test/database-setup.ts b/packages/chrono-postgres-datastore/test/database-setup.ts new file mode 100644 index 0000000..c076661 --- /dev/null +++ b/packages/chrono-postgres-datastore/test/database-setup.ts @@ -0,0 +1,117 @@ +/* eslint-disable no-console */ +import { PGlite } from '@electric-sql/pglite'; +import { DataSource } from 'typeorm'; + +import { ChronoTaskEntity } from '../src/chrono-task.entity'; + +const formatMemoryUsage = (data: number) => `${Math.round((data / 1024 / 1024) * 100) / 100} MB`; + +export const TEST_TABLE_NAME = 'chrono_tasks'; + +// Global PGlite instance for tests +let pglite: PGlite | undefined; +let dataSource: DataSource | undefined; + +/** + * Creates a TypeORM-compatible DataSource using PGlite + */ +export async function createPGliteDataSource(): Promise { + if (!pglite) { + throw new Error('PGlite not initialized. Call setup() first.'); + } + + // Create a new DataSource that uses PGlite + // We use the 'postgres' type but provide a custom driver + const ds = new DataSource({ + type: 'postgres', + driver: pglite, + entities: [ChronoTaskEntity], + synchronize: true, + logging: false, + } as any); + + await ds.initialize(); + return ds; +} + +/** + * Gets the shared PGlite instance + */ +export function getPGlite(): PGlite { + if (!pglite) { + throw new Error('PGlite not initialized. Call setup() first.'); + } + return pglite; +} + +/** + * @remarks + * https://vitest.dev/config/#globalsetup + */ +export async function setup(): Promise { + const { heapTotal: heapTotalBefore, heapUsed: heapUsedBefore } = process.memoryUsage(); + + // Create in-memory PGlite instance + pglite = new PGlite(); + + console.table({ + database: 'PGlite (in-memory)', + heapTotal: formatMemoryUsage(heapTotalBefore), + heapUsed: formatMemoryUsage(heapUsedBefore), + }); + + // Create the table schema directly using PGlite + await pglite.exec(` + CREATE TABLE IF NOT EXISTS ${TEST_TABLE_NAME} ( + 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 TIMESTAMP WITH TIME ZONE NOT NULL, + scheduled_at TIMESTAMP WITH TIME ZONE NOT NULL, + claimed_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE, + last_executed_at TIMESTAMP WITH TIME ZONE, + retry_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + ) + `); + + // Create indexes + await pglite.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_chrono_tasks_idempotency_key + ON ${TEST_TABLE_NAME} (idempotency_key) + WHERE idempotency_key IS NOT NULL + `); + + await pglite.exec(` + CREATE INDEX IF NOT EXISTS idx_chrono_tasks_claim + ON ${TEST_TABLE_NAME} (kind, status, scheduled_at ASC, priority DESC, claimed_at) + `); + + console.log('PGlite initialized with chrono_tasks table'); +} + +/** + * @remarks + * https://vitest.dev/config/#globalsetup + */ +export async function teardown(): Promise { + const { heapTotal: heapTotalAfter, heapUsed: heapUsedAfter } = process.memoryUsage(); + + if (dataSource?.isInitialized) { + await dataSource.destroy(); + } + + if (pglite) { + await pglite.close(); + } + + console.log({ + heapTotal: formatMemoryUsage(heapTotalAfter), + heapUsed: formatMemoryUsage(heapUsedAfter), + }); +} diff --git a/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts b/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts new file mode 100644 index 0000000..d843560 --- /dev/null +++ b/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts @@ -0,0 +1,550 @@ +import { PGlite } from '@electric-sql/pglite'; +import { faker } from '@faker-js/faker'; +import { TaskStatus } from '@neofinancial/chrono'; +import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { ChronoPostgresDatastore } from '../../src/chrono-postgres-datastore'; +import { TEST_TABLE_NAME } from '../database-setup'; + +type TaskMapping = { + test: { + test: string; + }; +}; + +const TEST_CLAIM_STALE_TIMEOUT_MS = 1_000; // 1 second + +/** + * Creates a mock DataSource that wraps PGlite to provide the interface + * expected by ChronoPostgresDatastore + */ +function createMockDataSource(pglite: PGlite) { + const manager = { + query: async (sql: string, params?: unknown[]) => { + // Convert $1, $2, etc. to PGlite's parameterized query format + const result = await pglite.query(sql, params as any[]); + return result.rows; + }, + create: (_entity: any, data: any) => data, + save: async (_entity: any, data: any) => { + const id = faker.string.uuid(); + const now = new Date(); + const result = await pglite.query( + `INSERT INTO ${TEST_TABLE_NAME} + (id, kind, status, data, priority, idempotency_key, original_schedule_date, scheduled_at, retry_count, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *`, + [ + id, + data.kind, + data.status, + JSON.stringify(data.data), + data.priority ?? 0, + data.idempotencyKey ?? null, + data.originalScheduleDate, + data.scheduledAt, + data.retryCount ?? 0, + now, + now, + ], + ); + const row = result.rows[0] as any; + return { + ...row, + data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data, + idempotencyKey: row.idempotency_key, + originalScheduleDate: row.original_schedule_date, + scheduledAt: row.scheduled_at, + claimedAt: row.claimed_at, + completedAt: row.completed_at, + lastExecutedAt: row.last_executed_at, + retryCount: row.retry_count, + }; + }, + findOne: async (_entity: any, options: any) => { + const where = options.where; + let sql = `SELECT * FROM ${TEST_TABLE_NAME} WHERE 1=1`; + const params: any[] = []; + let paramIndex = 1; + + if (where.id) { + sql += ` AND id = $${paramIndex++}`; + params.push(where.id); + } + if (where.idempotencyKey) { + sql += ` AND idempotency_key = $${paramIndex++}`; + params.push(where.idempotencyKey); + } + sql += ' LIMIT 1'; + + const result = await pglite.query(sql, params); + if (result.rows.length === 0) return null; + + const row = result.rows[0] as any; + return { + ...row, + data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data, + idempotencyKey: row.idempotency_key, + originalScheduleDate: row.original_schedule_date, + scheduledAt: row.scheduled_at, + claimedAt: row.claimed_at, + completedAt: row.completed_at, + lastExecutedAt: row.last_executed_at, + retryCount: row.retry_count, + }; + }, + delete: async (_entity: any, _where: any) => { + await pglite.query(`DELETE FROM ${TEST_TABLE_NAME}`); + }, + }; + + return { + manager, + isInitialized: true, + destroy: async () => {}, + } as any; +} + +describe('ChronoPostgresDatastore', () => { + let pglite: PGlite; + let mockDataSource: ReturnType; + let dataStore: ChronoPostgresDatastore; + + beforeAll(async () => { + // Create in-memory PGlite instance + pglite = new PGlite(); + + // Create the table schema + await pglite.exec(` + CREATE TABLE IF NOT EXISTS ${TEST_TABLE_NAME} ( + 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 TIMESTAMP WITH TIME ZONE NOT NULL, + scheduled_at TIMESTAMP WITH TIME ZONE NOT NULL, + claimed_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE, + last_executed_at TIMESTAMP WITH TIME ZONE, + retry_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + ) + `); + + // Create indexes + await pglite.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_chrono_tasks_idempotency_key + ON ${TEST_TABLE_NAME} (idempotency_key) + WHERE idempotency_key IS NOT NULL + `); + + mockDataSource = createMockDataSource(pglite); + + dataStore = new ChronoPostgresDatastore({ + tableName: TEST_TABLE_NAME, + }); + + await dataStore.initialize(mockDataSource); + }); + + beforeEach(async () => { + await pglite.exec(`DELETE FROM ${TEST_TABLE_NAME}`); + }); + + afterAll(async () => { + await pglite.close(); + }); + + describe('initialize', () => { + test('should throw an error if the DataSource is already set', async () => { + await expect(() => dataStore.initialize(mockDataSource)).rejects.toThrow('DataSource already initialized'); + }); + }); + + describe('schedule', () => { + const input = { + kind: 'test' as const, + data: { test: 'test' }, + priority: 1, + when: new Date(), + }; + + describe('when called with valid input', () => { + test('should return task with correct properties', async () => { + const task = await dataStore.schedule(input); + + expect(task).toEqual( + expect.objectContaining({ + kind: input.kind, + status: 'PENDING', + data: input.data, + priority: input.priority, + originalScheduleDate: expect.any(Date), + scheduledAt: expect.any(Date), + id: expect.any(String), + retryCount: 0, + }), + ); + }); + + test('should store task in the database', async () => { + const task = await dataStore.schedule(input); + + const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task.id]); + + expect(result.rows.length).toBe(1); + expect(result.rows[0]).toEqual( + expect.objectContaining({ + kind: input.kind, + status: 'PENDING', + }), + ); + }); + }); + + describe('idempotency', () => { + test('should return existing task if one exists with same idempotency key', async () => { + const idempotencyKey = faker.string.uuid(); + const inputWithIdempotency = { + kind: 'test' as const, + data: { test: 'test' }, + priority: 1, + when: new Date(), + idempotencyKey, + }; + + const task1 = await dataStore.schedule(inputWithIdempotency); + const task2 = await dataStore.schedule(inputWithIdempotency); + + expect(task1.id).toEqual(task2.id); + expect(task1.idempotencyKey).toEqual(task2.idempotencyKey); + }); + }); + }); + + describe('claim', () => { + const input = { + kind: 'test' as const, + data: { test: 'test' }, + priority: 1, + when: new Date(Date.now() - 1), + }; + + test('should return undefined when no tasks available', async () => { + const result = await dataStore.claim({ + kind: input.kind, + claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS, + }); + + expect(result).toBeUndefined(); + }); + + test('should claim task in PENDING state with scheduledAt in the past', async () => { + const task = await dataStore.schedule({ + ...input, + when: new Date(Date.now() - 1000), + }); + + const claimedTask = await dataStore.claim({ + kind: input.kind, + claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS, + }); + + expect(claimedTask).toEqual( + expect.objectContaining({ + id: task.id, + kind: task.kind, + status: 'CLAIMED', + }), + ); + }); + + test('should claim task in CLAIMED state with claimedAt in the past (stale)', async () => { + const scheduledTask = await dataStore.schedule(input); + + const claimedTask = await dataStore.claim({ + kind: input.kind, + claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS, + }); + + // Trying to claim again should return undefined (no stale tasks) + const claimedTaskAgain = await dataStore.claim({ + kind: input.kind, + claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS, + }); + + // Fast forward time to make the claim stale + const fakeTimer = vi.useFakeTimers(); + fakeTimer.setSystemTime( + new Date((claimedTask?.claimedAt?.getTime() as number) + TEST_CLAIM_STALE_TIMEOUT_MS + 1), + ); + + const claimedTaskAgainAgain = await dataStore.claim({ + kind: input.kind, + claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS, + }); + fakeTimer.useRealTimers(); + + expect(scheduledTask).toEqual( + expect.objectContaining({ + status: TaskStatus.PENDING, + }), + ); + expect(claimedTask).toEqual( + expect.objectContaining({ + id: scheduledTask.id, + kind: scheduledTask.kind, + status: TaskStatus.CLAIMED, + }), + ); + expect(claimedTaskAgain).toBeUndefined(); + expect(claimedTaskAgainAgain).toEqual( + expect.objectContaining({ + id: scheduledTask.id, + kind: scheduledTask.kind, + status: TaskStatus.CLAIMED, + }), + ); + }); + + test('should claim tasks in priority order (higher priority first)', async () => { + const lowPriorityTask = await dataStore.schedule({ + ...input, + priority: 1, + }); + const highPriorityTask = await dataStore.schedule({ + ...input, + priority: 10, + }); + + const firstClaimed = await dataStore.claim({ + kind: input.kind, + claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS, + }); + + expect(firstClaimed?.id).toEqual(highPriorityTask.id); + + const secondClaimed = await dataStore.claim({ + kind: input.kind, + claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS, + }); + + expect(secondClaimed?.id).toEqual(lowPriorityTask.id); + }); + }); + + describe('complete', () => { + test('should mark task as completed', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { test: 'test' }, + priority: 1, + when: new Date(), + }); + + const completedTask = await dataStore.complete(task.id); + + expect(completedTask).toEqual( + expect.objectContaining({ + id: task.id, + kind: task.kind, + status: TaskStatus.COMPLETED, + completedAt: expect.any(Date), + }), + ); + + // Verify in database + const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task.id]); + expect(result.rows[0]).toEqual( + expect.objectContaining({ + status: TaskStatus.COMPLETED, + }), + ); + }); + + test('should throw an error if task is not found', async () => { + const taskId = faker.string.uuid(); + + await expect(() => dataStore.complete(taskId)).rejects.toThrow(`Task with ID ${taskId} not found`); + }); + }); + + describe('fail', () => { + test('should mark task as failed', async () => { + const task = await dataStore.schedule({ + kind: 'test', + data: { test: 'test' }, + priority: 1, + when: new Date(), + }); + + const failedTask = await dataStore.fail(task.id); + + expect(failedTask).toEqual( + expect.objectContaining({ + id: task.id, + kind: task.kind, + status: TaskStatus.FAILED, + }), + ); + + // Verify in database + const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task.id]); + expect(result.rows[0]).toEqual( + expect.objectContaining({ + status: TaskStatus.FAILED, + }), + ); + }); + + test('should throw an error if task is not found', async () => { + const taskId = faker.string.uuid(); + + await expect(() => dataStore.fail(taskId)).rejects.toThrow(`Task with ID ${taskId} not found`); + }); + }); + + describe('retry', () => { + test('should retry task', async () => { + const firstScheduleDate = faker.date.past(); + const secondScheduleDate = faker.date.future(); + + const task = await dataStore.schedule({ + kind: 'test', + data: { test: 'test' }, + priority: 1, + when: firstScheduleDate, + }); + + expect(task).toEqual( + expect.objectContaining({ + status: TaskStatus.PENDING, + retryCount: 0, + }), + ); + + const taskToRetry = await dataStore.retry(task.id, secondScheduleDate); + + expect(taskToRetry).toEqual( + expect.objectContaining({ + id: task.id, + kind: task.kind, + status: TaskStatus.PENDING, + retryCount: 1, + }), + ); + + // Verify scheduledAt was updated + expect(taskToRetry.scheduledAt.getTime()).toBeCloseTo(secondScheduleDate.getTime(), -3); + // Verify originalScheduleDate was preserved + expect(taskToRetry.originalScheduleDate.getTime()).toBeCloseTo(firstScheduleDate.getTime(), -3); + }); + + test('should throw an error if task is not found', async () => { + const taskId = faker.string.uuid(); + + await expect(() => dataStore.retry(taskId, new Date())).rejects.toThrow(`Task with ID ${taskId} not found`); + }); + }); + + describe('delete', () => { + test('deletes task by id removing from datastore', async () => { + const when = new Date(); + + const task = await dataStore.schedule({ + kind: 'test', + data: { test: 'test' }, + priority: 1, + when, + }); + + await dataStore.delete(task.id); + + const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task.id]); + expect(result.rows.length).toBe(0); + }); + + test('deletes task by task kind and idempotency key removing from datastore', async () => { + const when = new Date(); + + const task = await dataStore.schedule({ + idempotencyKey: 'test-idempotency-key', + kind: 'test', + data: { test: 'test' }, + priority: 1, + when, + }); + + await dataStore.delete({ kind: task.kind, idempotencyKey: task.idempotencyKey ?? 'undefined' }); + + const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task.id]); + expect(result.rows.length).toBe(0); + }); + + test('returns deleted task', async () => { + const when = new Date(); + + const task = await dataStore.schedule({ + kind: 'test', + data: { test: 'test' }, + priority: 1, + when, + }); + + const deletedTask = await dataStore.delete(task.id); + + expect(deletedTask?.id).toEqual(task.id); + expect(deletedTask?.kind).toEqual(task.kind); + }); + + test('throws when attempting to delete a task that is not PENDING', async () => { + const when = new Date(Date.now() - 1000); + + const task = await dataStore.schedule({ + kind: 'test', + data: { test: 'test' }, + priority: 1, + when, + }); + + await dataStore.claim({ kind: task.kind, claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS }); + + await expect(dataStore.delete(task.id)).rejects.toThrow( + `Task with id ${task.id} cannot be deleted as it may not exist or it's not in PENDING status.`, + ); + }); + + test('force deletes non-PENDING task removing from datastore', async () => { + const when = new Date(Date.now() - 1000); + + const task = await dataStore.schedule({ + kind: 'test', + data: { test: 'test' }, + priority: 1, + when, + }); + + await dataStore.claim({ kind: task.kind, claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS }); + + await dataStore.delete(task.id, { force: true }); + + const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task.id]); + expect(result.rows.length).toBe(0); + }); + + test('noops when force deleting a task that does not exist', async () => { + const result = await dataStore.delete(faker.string.uuid(), { force: true }); + expect(result).toBeUndefined(); + }); + }); + + describe('getEntity', () => { + test('should return the ChronoTaskEntity class', () => { + const entity = ChronoPostgresDatastore.getEntity(); + expect(entity.name).toBe('ChronoTaskEntity'); + }); + }); +}); diff --git a/packages/chrono-postgres-datastore/tsconfig.json b/packages/chrono-postgres-datastore/tsconfig.json new file mode 100644 index 0000000..83d5159 --- /dev/null +++ b/packages/chrono-postgres-datastore/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./build", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true + }, + "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..166aed4 --- /dev/null +++ b/packages/chrono-postgres-datastore/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineProject, mergeConfig } from 'vitest/config'; +import baseConfig from '../../vitest.base.config.js'; + +export default mergeConfig(baseConfig, defineProject({})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be4896b..ed4406e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,24 @@ importers: specifier: ^10.1.4 version: 10.1.4 + packages/chrono-postgres-datastore: + devDependencies: + '@electric-sql/pglite': + specifier: ^0.2.0 + version: 0.2.17 + '@faker-js/faker': + specifier: ^9.0.0 + version: 9.9.0 + '@neofinancial/chrono': + specifier: workspace:* + version: link:../chrono-core + pg: + specifier: ^8.13.1 + version: 8.17.2 + typeorm: + specifier: ^0.3.20 + version: 0.3.28(mongodb@6.15.0)(pg@8.17.2) + packages: '@babel/generator@7.28.3': @@ -144,11 +162,14 @@ packages: cpu: [x64] os: [win32] - '@emnapi/core@1.5.0': - resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} + '@electric-sql/pglite@0.2.17': + resolution: {integrity: sha512-qEpKRT2oUaWDH6tjRxLHjdzMqRUGYDnGZlKrnL4dJ77JVMcP2Hpo3NYnOSPKdZdeec57B6QPprCUFg0picx5Pw==} + + '@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 +351,98 @@ 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.110.0': + resolution: {integrity: sha512-6Ct21OIlrEnFEJk5LT4e63pk3btsI6/TusD/GStLi7wYlGJNOl1GI9qvXAnRAxQU9zqA2Oz+UwhfTOU2rPZVow==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} '@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.1': + resolution: {integrity: sha512-He6ZoCfv5D7dlRbrhNBkuMVIHd0GDnjJwbICE1OWpG7G3S2gmJ+eXkcNLJjzjNDpeI2aRy56ou39AJM9AD8YFA==} 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.1': + resolution: {integrity: sha512-YzJdn08kSOXnj85ghHauH2iHpOJ6eSmstdRTLyaziDcUxe9SyQJgGyx/5jDIhDvtOcNvMm2Ju7m19+S/Rm1jFg==} 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.1': + resolution: {integrity: sha512-cIvAbqM+ZVV6lBSKSBtlNqH5iCiW933t1q8j0H66B3sjbe8AxIRetVqfGgcHcJtMzBIkIALlL9fcDrElWLJQcQ==} 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.1': + resolution: {integrity: sha512-rVt+B1B/qmKwCl1XD02wKfgh3vQPXRXdB/TicV2w6g7RVAM1+cZcpigwhLarqiVCxDObFZ7UgXCxPC7tpDoRog==} 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.1': + resolution: {integrity: sha512-69YKwJJBOFprQa1GktPgbuBOfnn+EGxu8sBJ1TjPER+zhSpYeaU4N07uqmyBiksOLGXsMegymuecLobfz03h8Q==} 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.1': + resolution: {integrity: sha512-9JDhHUf3WcLfnViFWm+TyorqUtnSAHaCzlSNmMOq824prVuuzDOK91K0Hl8DUcEb9M5x2O+d2/jmBMsetRIn3g==} 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.1': + resolution: {integrity: sha512-UvApLEGholmxw/HIwmUnLq3CwdydbhaHHllvWiCTNbyGom7wTwOtz5OAQbAKZYyiEOeIXZNPkM7nA4Dtng7CLw==} 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.1': + resolution: {integrity: sha512-uVctNgZHiGnJx5Fij7wHLhgw4uyZBVi6mykeWKOqE7bVy9Hcxn0fM/IuqdMwk6hXlaf9fFShDTFz2+YejP+x0A==} 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.1': + resolution: {integrity: sha512-T6Eg0xWwcxd/MzBcuv4Z37YVbUbJxy5cMNnbIt/Yr99wFwli30O4BPlY8hKeGyn6lWNtU0QioBS46lVzDN38bg==} 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.1': + resolution: {integrity: sha512-PuGZVS2xNJyLADeh2F04b+Cz4NwvpglbtWACgrDOa5YDTEHKwmiTDjoD5eZ9/ptXtcpeFrMqD2H4Zn33KAh1Eg==} 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.1': + resolution: {integrity: sha512-2mOxY562ihHlz9lEXuaGEIDCZ1vI+zyFdtsoa3M62xsEunDXQE+DVPO4S4x5MPK9tKulG/aFcA/IH5eVN257Cw==} 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.1': + resolution: {integrity: sha512-oQVOP5cfAWZwRD0Q3nGn/cA9FW3KhMMuQ0NIndALAe6obqjLhqYVYDiGGRGrxvnjJsVbpLwR14gIUYnpIcHR1g==} 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.1': + resolution: {integrity: sha512-Ydsxxx++FNOuov3wCBPaYjZrEvKOOGq3k+BF4BPridhg2pENfitSRD2TEuQ8i33bp5VptuNdC9IzxRKU031z5A==} 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.1': + resolution: {integrity: sha512-UTBjtTxVOhodhzFVp/ayITaTETRHPUPYZPXQe0WU0wOgxghMojXxYjOiPOauKIYNWJAWS2fd7gJgGQK8GU8vDA==} '@rollup/rollup-android-arm-eabi@4.35.0': resolution: {integrity: sha512-uYQ2WfPaqz5QtVgMxfN6NpLD+no0MYHDBywl7itPYd3K5TjjSghNKmX8ic9S8NU8w81NVhJv/XojcHptRly7qQ==} @@ -520,6 +539,9 @@ packages: cpu: [x64] os: [win32] + '@sqltools/formatter@1.2.5': + resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -597,6 +619,10 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + app-root-path@3.1.0: + resolution: {integrity: sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==} + engines: {node: '>= 6.0.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -608,6 +634,10 @@ packages: async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + b4a@1.6.7: resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} @@ -617,6 +647,9 @@ packages: bare-events@2.5.4: resolution: {integrity: sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + birpc@2.6.1: resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==} @@ -630,10 +663,25 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -650,6 +698,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -664,6 +716,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -682,10 +737,22 @@ packages: supports-color: optional: true + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} @@ -693,6 +760,10 @@ packages: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} engines: {node: '>=0.3.1'} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dts-resolver@2.1.2: resolution: {integrity: sha512-xeXHBQkn2ISSXxbJWD828PFjtyg+/UrMDo7W4Ffcs7+YWCquxU8YjV1KoxuiL+eJ5pg3ll+bC6flVv61L3LKZg==} engines: {node: '>=20.18.0'} @@ -702,6 +773,10 @@ packages: oxc-resolver: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -715,14 +790,30 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + esbuild@0.25.1: resolution: {integrity: sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==} engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -762,6 +853,10 @@ packages: debug: optional: true + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -771,14 +866,52 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + hasBin: true + glob@11.0.1: resolution: {integrity: sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==} engines: {node: 20 || >=22} hasBin: true + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -791,13 +924,33 @@ packages: engines: {node: '>=18'} hasBin: true + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.1.0: resolution: {integrity: sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==} engines: {node: 20 || >=22} @@ -827,6 +980,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.0.2: resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==} engines: {node: 20 || >=22} @@ -841,6 +997,10 @@ packages: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + memory-pager@1.5.0: resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} @@ -848,6 +1008,10 @@ packages: resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} engines: {node: 20 || >=22} + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -925,6 +1089,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.0: resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} engines: {node: 20 || >=22} @@ -939,6 +1107,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==} @@ -950,10 +1152,30 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postcss@8.5.3: 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'} @@ -965,6 +1187,13 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -992,8 +1221,8 @@ packages: vue-tsc: optional: true - rolldown@1.0.0-beta.41: - resolution: {integrity: sha512-U+NPR0Bkg3wm61dteD2L4nAM1U9dtaqVrpDXwC36IKRHpEO/Ubpid4Nijpa2imPchcVNHfxVFwSSMJdwdGFUbg==} + rolldown@1.0.0-rc.1: + resolution: {integrity: sha512-M3AeZjYE6UclblEf531Hch0WfVC/NOL43Cc+WdF3J50kk5/fvouHhDumSGTh0oRjbZ8C4faaVr5r6Nx1xMqDGg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1002,6 +1231,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -1016,6 +1248,15 @@ packages: engines: {node: '>=10'} hasBin: true + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1038,6 +1279,14 @@ packages: sparse-bitfield@3.0.3: resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sql-highlight@6.1.0: + resolution: {integrity: sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==} + engines: {node: '>=14'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1097,6 +1346,10 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + tr46@5.1.0: resolution: {integrity: sha512-IUWnUK7ADYR5Sl1fZlO1INDUhVhatWl7BtJWsIhwJ0UAK7ilzzIa8uIqOO/aYVWHZPJkKbEL+362wrzoeRF7bw==} engines: {node: '>=18'} @@ -1138,6 +1391,65 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typeorm@0.3.28: + resolution: {integrity: sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==} + engines: {node: '>=16.13.0'} + hasBin: true + peerDependencies: + '@google-cloud/spanner': ^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@sap/hana-client': ^2.14.22 + better-sqlite3: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 + ioredis: ^5.0.4 + mongodb: ^5.8.0 || ^6.0.0 + mssql: ^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0 + mysql2: ^2.2.5 || ^3.0.1 + oracledb: ^6.3.0 + pg: ^8.5.1 + pg-native: ^3.0.0 + pg-query-stream: ^4.0.0 + redis: ^3.1.1 || ^4.0.0 || ^5.0.14 + sql.js: ^1.4.0 + sqlite3: ^5.0.3 + ts-node: ^10.7.0 + typeorm-aurora-data-api-driver: ^2.0.0 || ^3.0.0 + peerDependenciesMeta: + '@google-cloud/spanner': + optional: true + '@sap/hana-client': + optional: true + better-sqlite3: + optional: true + ioredis: + optional: true + mongodb: + optional: true + mssql: + optional: true + mysql2: + optional: true + oracledb: + optional: true + pg: + optional: true + pg-native: + optional: true + pg-query-stream: + optional: true + redis: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + ts-node: + optional: true + typeorm-aurora-data-api-driver: + optional: true + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} @@ -1149,6 +1461,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1236,6 +1552,10 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1254,6 +1574,22 @@ 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'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yauzl@3.2.0: resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==} engines: {node: '>=12'} @@ -1316,13 +1652,15 @@ snapshots: '@biomejs/cli-win32-x64@2.2.4': optional: true - '@emnapi/core@1.5.0': + '@electric-sql/pglite@0.2.17': {} + + '@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 +1776,64 @@ 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.110.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true '@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.1': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-beta.41': + '@rolldown/binding-darwin-arm64@1.0.0-rc.1': optional: true - '@rolldown/binding-darwin-x64@1.0.0-beta.41': + '@rolldown/binding-darwin-x64@1.0.0-rc.1': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-beta.41': + '@rolldown/binding-freebsd-x64@1.0.0-rc.1': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.41': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.1': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.41': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.1': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.41': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.1': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.41': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.1': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-beta.41': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.1': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-beta.41': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.1': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-beta.41': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.1': 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.1': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.41': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.1': optional: true - '@rolldown/pluginutils@1.0.0-beta.41': {} + '@rolldown/pluginutils@1.0.0-rc.1': {} '@rollup/rollup-android-arm-eabi@4.35.0': optional: true @@ -1554,6 +1892,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.35.0': optional: true + '@sqltools/formatter@1.2.5': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -1635,6 +1975,8 @@ snapshots: ansis@4.2.0: {} + app-root-path@3.1.0: {} + assertion-error@2.0.1: {} ast-kit@2.1.2: @@ -1646,6 +1988,10 @@ snapshots: dependencies: tslib: 2.8.1 + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + b4a@1.6.7: {} balanced-match@1.0.2: {} @@ -1653,6 +1999,8 @@ snapshots: bare-events@2.5.4: optional: true + base64-js@1.5.1: {} + birpc@2.6.1: {} brace-expansion@2.0.1: @@ -1663,8 +2011,30 @@ snapshots: buffer-crc32@0.2.13: {} + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + camelcase@6.3.0: {} chai@5.2.0: @@ -1681,6 +2051,12 @@ snapshots: dependencies: readdirp: 4.1.2 + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1695,6 +2071,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + dayjs@1.11.19: {} + debug@4.4.0: dependencies: ms: 2.1.3 @@ -1703,14 +2081,30 @@ snapshots: dependencies: ms: 2.1.3 + dedent@1.7.1: {} + deep-eql@5.0.2: {} + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + defu@6.1.4: {} diff@8.0.2: {} + dotenv@16.6.1: {} + dts-resolver@2.1.2: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + eastasianwidth@0.2.0: {} emoji-regex@8.0.0: {} @@ -1719,8 +2113,16 @@ snapshots: empathic@2.0.0: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + esbuild@0.25.1: optionalDependencies: '@esbuild/aix-ppc64': 0.25.1 @@ -1749,6 +2151,8 @@ snapshots: '@esbuild/win32-ia32': 0.25.1 '@esbuild/win32-x64': 0.25.1 + escalade@3.2.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -1780,6 +2184,10 @@ snapshots: optionalDependencies: debug: 4.4.0 + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -1788,10 +2196,41 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-tsconfig@4.10.1: dependencies: resolve-pkg-maps: 1.0.0 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@11.0.1: dependencies: foreground-child: 3.3.1 @@ -1801,6 +2240,22 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.0 + gopd@1.2.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + hookable@5.5.3: {} https-proxy-agent@7.0.6: @@ -1812,10 +2267,28 @@ snapshots: husky@9.1.7: {} + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + is-callable@1.2.7: {} + is-fullwidth-code-point@3.0.0: {} + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + isarray@2.0.5: {} + isexe@2.0.0: {} + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jackspeak@4.1.0: dependencies: '@isaacs/cliui': 8.0.2 @@ -1836,6 +2309,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + lru-cache@11.0.2: {} magic-string@0.30.17: @@ -1850,12 +2325,18 @@ snapshots: dependencies: semver: 6.3.1 + math-intrinsics@1.1.0: {} + memory-pager@1.5.0: {} minimatch@10.0.1: dependencies: brace-expansion: 2.0.1 + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + minipass@7.1.2: {} mongodb-connection-string-url@3.0.2: @@ -1933,6 +2414,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + path-scurry@2.0.0: dependencies: lru-cache: 11.0.2 @@ -1944,6 +2430,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: {} @@ -1952,18 +2473,34 @@ snapshots: dependencies: find-up: 4.1.0 + possible-typed-array-names@1.1.0: {} + postcss@8.5.3: dependencies: nanoid: 3.3.9 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: {} readdirp@4.1.2: {} + reflect-metadata@0.2.2: {} + + require-directory@2.1.1: {} + resolve-pkg-maps@1.0.0: {} rimraf@6.0.1: @@ -1971,7 +2508,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.1)(typescript@5.9.2): dependencies: '@babel/generator': 7.28.3 '@babel/parser': 7.28.4 @@ -1982,33 +2519,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.1 optionalDependencies: typescript: 5.9.2 transitivePeerDependencies: - oxc-resolver - supports-color - rolldown@1.0.0-beta.41: + rolldown@1.0.0-rc.1: dependencies: - '@oxc-project/types': 0.93.0 - '@rolldown/pluginutils': 1.0.0-beta.41 - ansis: 4.2.0 + '@oxc-project/types': 0.110.0 + '@rolldown/pluginutils': 1.0.0-rc.1 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.1 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.1 + '@rolldown/binding-darwin-x64': 1.0.0-rc.1 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.1 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.1 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.1 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.1 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.1 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.1 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.1 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.1 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.1 rollup@4.35.0: dependencies: @@ -2035,12 +2570,29 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.35.0 fsevents: 2.3.3 + safe-buffer@5.2.1: {} + semver@6.3.1: {} semver@7.7.1: {} semver@7.7.2: {} + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2057,6 +2609,10 @@ snapshots: dependencies: memory-pager: 1.5.0 + split2@4.2.0: {} + + sql-highlight@6.1.0: {} + stackback@0.0.2: {} std-env@3.9.0: {} @@ -2119,6 +2675,12 @@ snapshots: tinyspy@4.0.4: {} + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + tr46@5.1.0: dependencies: punycode: 2.3.1 @@ -2138,8 +2700,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.1 + rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-rc.1)(typescript@5.9.2) semver: 7.7.2 tinyexec: 1.0.1 tinyglobby: 0.2.15 @@ -2156,6 +2718,36 @@ snapshots: tslib@2.8.1: {} + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typeorm@0.3.28(mongodb@6.15.0)(pg@8.17.2): + dependencies: + '@sqltools/formatter': 1.2.5 + ansis: 4.2.0 + app-root-path: 3.1.0 + buffer: 6.0.3 + dayjs: 1.11.19 + debug: 4.4.3 + dedent: 1.7.1 + dotenv: 16.6.1 + glob: 10.5.0 + reflect-metadata: 0.2.2 + sha.js: 2.4.12 + sql-highlight: 6.1.0 + tslib: 2.8.1 + uuid: 11.1.0 + yargs: 17.7.2 + optionalDependencies: + mongodb: 6.15.0 + pg: 8.17.2 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + typescript@5.9.2: {} unconfig@7.3.3: @@ -2167,6 +2759,8 @@ snapshots: undici-types@6.21.0: {} + uuid@11.1.0: {} + vite-node@3.2.4(@types/node@20.19.17)(jiti@2.6.1): dependencies: cac: 6.7.14 @@ -2252,6 +2846,16 @@ snapshots: tr46: 5.1.0 webidl-conversions: 7.0.0 + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -2273,6 +2877,22 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yauzl@3.2.0: dependencies: buffer-crc32: 0.2.13 From 03eda5595c18fa88a6d83521f05470a253033a0d Mon Sep 17 00:00:00 2001 From: James Couch Date: Tue, 27 Jan 2026 02:38:58 -0700 Subject: [PATCH 02/21] prettify --- .../src/chrono-postgres-datastore.ts | 201 +++++++++--------- 1 file changed, 95 insertions(+), 106 deletions(-) diff --git a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts index 12c2ead..eb1b8e1 100644 --- a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts +++ b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts @@ -122,37 +122,46 @@ export class ChronoPostgresDatastore await this.getDataSource(); const manager = this.getManager(input.datastoreOptions); - try { - const entity = manager.create(ChronoTaskEntity, { - kind: String(input.kind), - status: TaskStatus.PENDING, - data: input.data as Record, - priority: input.priority ?? 0, - idempotencyKey: input.idempotencyKey ?? null, - originalScheduleDate: input.when, - scheduledAt: input.when, - retryCount: 0, - }); + const entity = manager.create(ChronoTaskEntity, { + kind: String(input.kind), + status: TaskStatus.PENDING, + data: input.data as Record, + priority: input.priority ?? 0, + idempotencyKey: input.idempotencyKey ?? null, + originalScheduleDate: input.when, + scheduledAt: input.when, + retryCount: 0, + }); + try { const saved = await manager.save(ChronoTaskEntity, entity); return this.toTask(saved); } catch (error) { - // Handle unique constraint violation on idempotency_key - if (this.isUniqueViolation(error) && input.idempotencyKey) { - const existing = await manager.findOne(ChronoTaskEntity, { - where: { idempotencyKey: input.idempotencyKey }, - }); - - if (existing) { - return this.toTask(existing); - } - - throw new Error( - `Failed to find existing task with idempotency key ${input.idempotencyKey} despite unique constraint error`, - ); - } + return this.handleScheduleError(error, input.idempotencyKey, manager); + } + } + + private async handleScheduleError( + error: unknown, + idempotencyKey: string | undefined, + manager: EntityManager, + ): Promise> { + const isIdempotencyConflict = this.isUniqueViolation(error) && idempotencyKey; + if (!isIdempotencyConflict) { throw error; } + + const existing = await manager.findOne(ChronoTaskEntity, { + where: { idempotencyKey }, + }); + + if (!existing) { + throw new Error( + `Failed to find existing task with idempotency key ${idempotencyKey} despite unique constraint error`, + ); + } + + return this.toTask(existing); } async delete>( @@ -161,51 +170,50 @@ export class ChronoPostgresDatastore ): Promise | undefined> { const dataSource = await this.getDataSource(); const manager = dataSource.manager; + + const { sql, params, errorMessage } = this.buildDeleteQuery(key, options); + const result = await manager.query(sql, params); + + return this.processDeleteResult(result, options?.force, errorMessage); + } + + private buildDeleteQuery>( + key: DeleteInput, + options?: DeleteOptions, + ): { sql: string; params: unknown[]; errorMessage: string } { const tableName = this.config.tableName; + const statusClause = options?.force ? '' : "AND status = 'PENDING'"; if (typeof key === 'string') { - // Delete by ID - const result = await manager.query( - ` - DELETE FROM ${tableName} - WHERE id = $1 - ${options?.force ? '' : "AND status = 'PENDING'"} - RETURNING * - `, - [key], - ); + return { + sql: `DELETE FROM ${tableName} WHERE id = $1 ${statusClause} RETURNING *`, + params: [key], + errorMessage: `Task with id ${key} cannot be deleted as it may not exist or it's not in PENDING status.`, + }; + } - if (result.length === 0) { - if (options?.force) { - return undefined; - } - throw new Error(`Task with id ${key} cannot be deleted as it may not exist or it's not in PENDING status.`); - } - - return this.toTaskFromRow(result[0]); - } else { - // Delete by kind + idempotencyKey - const result = await manager.query( - ` - DELETE FROM ${tableName} - WHERE kind = $1 AND idempotency_key = $2 - ${options?.force ? '' : "AND status = 'PENDING'"} - RETURNING * - `, - [String(key.kind), key.idempotencyKey], - ); + return { + sql: `DELETE FROM ${tableName} WHERE kind = $1 AND idempotency_key = $2 ${statusClause} RETURNING *`, + params: [String(key.kind), key.idempotencyKey], + errorMessage: `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.`, + }; + } - if (result.length === 0) { - if (options?.force) { - return undefined; - } - throw new Error( - `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.`, - ); - } + private processDeleteResult( + result: TaskRow[], + force: boolean | undefined, + errorMessage: string, + ): Task | undefined { + const [row] = result; + if (row) { + return this.toTaskFromRow(row); + } - return this.toTaskFromRow(result[0]); + if (force) { + return undefined; } + + throw new Error(errorMessage); } async claim>( @@ -249,13 +257,9 @@ export class ChronoPostgresDatastore taskId: string, retryAt: Date, ): Promise> { - const dataSource = await this.getDataSource(); const now = new Date(); - const tableName = this.config.tableName; - - const result = await dataSource.manager.query( - ` - UPDATE ${tableName} + const sql = ` + UPDATE ${this.config.tableName} SET status = $1, scheduled_at = $2, last_executed_at = $3, @@ -264,58 +268,44 @@ export class ChronoPostgresDatastore updated_at = $3 WHERE id = $4 RETURNING * - `, - [TaskStatus.PENDING, retryAt, now, taskId], - ); - - if (result.length === 0) { - throw new Error(`Task with ID ${taskId} not found`); - } - - return this.toTaskFromRow(result[0]); + `; + return this.updateTaskById(taskId, sql, [TaskStatus.PENDING, retryAt, now, taskId]); } async complete(taskId: string): Promise> { - const dataSource = await this.getDataSource(); const now = new Date(); - const tableName = this.config.tableName; - - const result = await dataSource.manager.query( - ` - UPDATE ${tableName} + const sql = ` + UPDATE ${this.config.tableName} SET status = $1, completed_at = $2, last_executed_at = $2, updated_at = $2 WHERE id = $3 RETURNING * - `, - [TaskStatus.COMPLETED, now, taskId], - ); - - if (result.length === 0) { - throw new Error(`Task with ID ${taskId} not found`); - } - - return this.toTaskFromRow(result[0]); + `; + return this.updateTaskById(taskId, sql, [TaskStatus.COMPLETED, now, taskId]); } async fail(taskId: string): Promise> { - const dataSource = await this.getDataSource(); const now = new Date(); - const tableName = this.config.tableName; - - const result = await dataSource.manager.query( - ` - UPDATE ${tableName} + const sql = ` + UPDATE ${this.config.tableName} SET status = $1, last_executed_at = $2, updated_at = $2 WHERE id = $3 RETURNING * - `, - [TaskStatus.FAILED, now, taskId], - ); + `; + return this.updateTaskById(taskId, sql, [TaskStatus.FAILED, now, taskId]); + } + + private async updateTaskById( + taskId: string, + sql: string, + params: unknown[], + ): Promise> { + const dataSource = await this.getDataSource(); + const result = await dataSource.manager.query(sql, params); if (result.length === 0) { throw new Error(`Task with ID ${taskId} not found`); @@ -326,12 +316,11 @@ export class ChronoPostgresDatastore /** * Checks if an error is a PostgreSQL unique constraint violation. - * Error code 23505 is UNIQUE_VIOLATION in PostgreSQL. */ private isUniqueViolation(error: unknown): boolean { - return ( - typeof error === 'object' && error !== null && 'code' in error && (error as { code: string }).code === '23505' - ); + const PG_UNIQUE_VIOLATION = '23505'; + const isErrorObject = typeof error === 'object' && error !== null; + return isErrorObject && 'code' in error && error.code === PG_UNIQUE_VIOLATION; } /** From ef30e5ad734aad047dfc8d6f491f51413d81192a Mon Sep 17 00:00:00 2001 From: James Couch Date: Tue, 27 Jan 2026 02:56:35 -0700 Subject: [PATCH 03/21] Refactor to use TypeORM QueryBuilder instead of raw SQL - Replace all string interpolation SQL with QueryBuilder methods - Use Brackets for complex OR conditions in claim query - Use transaction with pessimistic_write lock for atomic claiming - Update test mock to support QueryBuilder API Co-Authored-By: Claude (claude-4-opus) --- .../src/chrono-postgres-datastore.ts | 281 ++++++-------- .../unit/chrono-postgres-datastore.test.ts | 358 ++++++++++++++++-- 2 files changed, 442 insertions(+), 197 deletions(-) diff --git a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts index eb1b8e1..1ea2c3b 100644 --- a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts +++ b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts @@ -8,19 +8,11 @@ import { type TaskMappingBase, TaskStatus, } from '@neofinancial/chrono'; -import type { DataSource, EntityManager } from 'typeorm'; +import { Brackets, type DataSource, type EntityManager } from 'typeorm'; import { ChronoTaskEntity } from './chrono-task.entity'; -const DEFAULT_TABLE_NAME = 'chrono_tasks'; - -export type ChronoPostgresDatastoreConfig = { - /** - * The name of the table to use for storing tasks. - * - * @default 'chrono_tasks' - */ - tableName?: string; -}; +/** @deprecated Config is no longer used - table name is determined by the entity */ +export type ChronoPostgresDatastoreConfig = Record; export type PostgresDatastoreOptions = { /** @@ -30,34 +22,12 @@ export type PostgresDatastoreOptions = { entityManager?: EntityManager; }; -type TaskRow = { - 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; -}; - export class ChronoPostgresDatastore implements Datastore { - private config: Required; private dataSource: DataSource | undefined; private dataSourceResolvers: Array<(ds: DataSource) => void> = []; - constructor(config?: ChronoPostgresDatastoreConfig) { - this.config = { - tableName: config?.tableName ?? DEFAULT_TABLE_NAME, - }; - } - /** * Initializes the datastore with a TypeORM DataSource. * Must be called before any operations can be performed. @@ -169,51 +139,43 @@ export class ChronoPostgresDatastore options?: DeleteOptions, ): Promise | undefined> { const dataSource = await this.getDataSource(); - const manager = dataSource.manager; - - const { sql, params, errorMessage } = this.buildDeleteQuery(key, options); - const result = await manager.query(sql, params); - return this.processDeleteResult(result, options?.force, errorMessage); - } - - private buildDeleteQuery>( - key: DeleteInput, - options?: DeleteOptions, - ): { sql: string; params: unknown[]; errorMessage: string } { - const tableName = this.config.tableName; - const statusClause = options?.force ? '' : "AND status = 'PENDING'"; + const qb = dataSource.createQueryBuilder().delete().from(ChronoTaskEntity).returning('*'); if (typeof key === 'string') { - return { - sql: `DELETE FROM ${tableName} WHERE id = $1 ${statusClause} RETURNING *`, - params: [key], - errorMessage: `Task with id ${key} cannot be deleted as it may not exist or it's not in PENDING status.`, - }; + qb.where('id = :id', { id: key }); + } else { + qb.where('kind = :kind AND idempotency_key = :idempotencyKey', { + kind: String(key.kind), + idempotencyKey: key.idempotencyKey, + }); } - return { - sql: `DELETE FROM ${tableName} WHERE kind = $1 AND idempotency_key = $2 ${statusClause} RETURNING *`, - params: [String(key.kind), key.idempotencyKey], - errorMessage: `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.`, - }; - } + if (!options?.force) { + qb.andWhere('status = :status', { status: TaskStatus.PENDING }); + } + + const result = await qb.execute(); + const [row] = result.raw as ChronoTaskEntity[]; - private processDeleteResult( - result: TaskRow[], - force: boolean | undefined, - errorMessage: string, - ): Task | undefined { - const [row] = result; if (row) { - return this.toTaskFromRow(row); + return this.toTask(row); } - if (force) { + if (options?.force) { return undefined; } - throw new Error(errorMessage); + 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>( @@ -222,96 +184,124 @@ export class ChronoPostgresDatastore const dataSource = await this.getDataSource(); const now = new Date(); const staleThreshold = new Date(now.getTime() - input.claimStaleTimeoutMs); - const tableName = this.config.tableName; - - // Use FOR UPDATE SKIP LOCKED for atomic claiming without blocking other processors - const result = await dataSource.manager.query( - ` - UPDATE ${tableName} - SET status = $1, claimed_at = $2, updated_at = $2 - WHERE id = ( - SELECT id FROM ${tableName} - WHERE kind = $3 - AND scheduled_at <= $4 - AND ( - status = 'PENDING' - OR (status = 'CLAIMED' AND claimed_at <= $5) - ) - ORDER BY priority DESC, scheduled_at ASC - LIMIT 1 - FOR UPDATE SKIP LOCKED - ) - RETURNING * - `, - [TaskStatus.CLAIMED, now, String(input.kind), now, staleThreshold], - ); - - if (result.length === 0) { - return undefined; - } - return this.toTaskFromRow(result[0]); + // Use a transaction to atomically select and update + return dataSource.transaction(async (manager) => { + // Find and lock the next claimable task + const taskToClaimQuery = manager + .createQueryBuilder(ChronoTaskEntity, 'task') + .where('task.kind = :kind', { kind: String(input.kind) }) + .andWhere('task.scheduledAt <= :now', { now }) + .andWhere( + new Brackets((qb) => { + qb.where('task.status = :pending', { pending: TaskStatus.PENDING }).orWhere( + 'task.status = :claimed AND task.claimedAt <= :staleThreshold', + { claimed: TaskStatus.CLAIMED, staleThreshold }, + ); + }), + ) + .orderBy('task.priority', 'DESC') + .addOrderBy('task.scheduledAt', 'ASC') + .limit(1) + .setLock('pessimistic_write', undefined, ['skipLocked']); + + const taskToClaim = await taskToClaimQuery.getOne(); + + if (!taskToClaim) { + return undefined; + } + + // Update the task to claim it + const updateResult = await manager + .createQueryBuilder() + .update(ChronoTaskEntity) + .set({ + status: TaskStatus.CLAIMED, + claimedAt: now, + updatedAt: now, + }) + .where('id = :id', { id: taskToClaim.id }) + .returning('*') + .execute(); + + const [claimedTask] = updateResult.raw as ChronoTaskEntity[]; + return claimedTask ? this.toTask(claimedTask) : undefined; + }); } async retry( taskId: string, retryAt: Date, ): Promise> { + const dataSource = await this.getDataSource(); const now = new Date(); - const sql = ` - UPDATE ${this.config.tableName} - SET status = $1, - scheduled_at = $2, - last_executed_at = $3, - retry_count = retry_count + 1, - claimed_at = NULL, - updated_at = $3 - WHERE id = $4 - RETURNING * - `; - return this.updateTaskById(taskId, sql, [TaskStatus.PENDING, retryAt, now, taskId]); + + const result = await dataSource + .createQueryBuilder() + .update(ChronoTaskEntity) + .set({ + status: TaskStatus.PENDING, + scheduledAt: retryAt, + lastExecutedAt: now, + claimedAt: () => 'NULL', + updatedAt: now, + retryCount: () => 'retry_count + 1', + }) + .where('id = :id', { id: taskId }) + .returning('*') + .execute(); + + return this.extractUpdatedTaskOrThrow(result.raw, taskId); } async complete(taskId: string): Promise> { + const dataSource = await this.getDataSource(); const now = new Date(); - const sql = ` - UPDATE ${this.config.tableName} - SET status = $1, - completed_at = $2, - last_executed_at = $2, - updated_at = $2 - WHERE id = $3 - RETURNING * - `; - return this.updateTaskById(taskId, sql, [TaskStatus.COMPLETED, now, taskId]); + + const result = await dataSource + .createQueryBuilder() + .update(ChronoTaskEntity) + .set({ + status: TaskStatus.COMPLETED, + completedAt: now, + lastExecutedAt: now, + updatedAt: now, + }) + .where('id = :id', { id: taskId }) + .returning('*') + .execute(); + + return this.extractUpdatedTaskOrThrow(result.raw, taskId); } async fail(taskId: string): Promise> { + const dataSource = await this.getDataSource(); const now = new Date(); - const sql = ` - UPDATE ${this.config.tableName} - SET status = $1, - last_executed_at = $2, - updated_at = $2 - WHERE id = $3 - RETURNING * - `; - return this.updateTaskById(taskId, sql, [TaskStatus.FAILED, now, taskId]); + + const result = await dataSource + .createQueryBuilder() + .update(ChronoTaskEntity) + .set({ + status: TaskStatus.FAILED, + lastExecutedAt: now, + updatedAt: now, + }) + .where('id = :id', { id: taskId }) + .returning('*') + .execute(); + + return this.extractUpdatedTaskOrThrow(result.raw, taskId); } - private async updateTaskById( + private extractUpdatedTaskOrThrow( + raw: ChronoTaskEntity[], taskId: string, - sql: string, - params: unknown[], - ): Promise> { - const dataSource = await this.getDataSource(); - const result = await dataSource.manager.query(sql, params); - - if (result.length === 0) { + ): Task { + const [entity] = raw; + if (!entity) { throw new Error(`Task with ID ${taskId} not found`); } - - return this.toTaskFromRow(result[0]); + return this.toTask(entity); } /** @@ -342,25 +332,4 @@ export class ChronoPostgresDatastore retryCount: entity.retryCount, }; } - - /** - * Converts a raw database row to a Task object. - * Used when returning results from raw SQL queries. - */ - private toTaskFromRow(row: TaskRow): 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: new Date(row.original_schedule_date), - scheduledAt: new Date(row.scheduled_at), - claimedAt: row.claimed_at ? new Date(row.claimed_at) : undefined, - completedAt: row.completed_at ? new Date(row.completed_at) : undefined, - lastExecutedAt: row.last_executed_at ? new Date(row.last_executed_at) : undefined, - retryCount: row.retry_count, - }; - } } diff --git a/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts b/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts index d843560..3db171b 100644 --- a/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts +++ b/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts @@ -4,6 +4,7 @@ import { TaskStatus } from '@neofinancial/chrono'; import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { ChronoPostgresDatastore } from '../../src/chrono-postgres-datastore'; +import type { ChronoTaskEntity } from '../../src/chrono-task.entity'; import { TEST_TABLE_NAME } from '../database-setup'; type TaskMapping = { @@ -14,19 +15,313 @@ type TaskMapping = { const TEST_CLAIM_STALE_TIMEOUT_MS = 1_000; // 1 second +/** + * Maps a raw database row to a ChronoTaskEntity-like object + */ +function mapRowToEntity(row: Record): ChronoTaskEntity { + return { + id: row.id as string, + kind: row.kind as string, + status: row.status as string, + data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data, + priority: row.priority as number | null, + idempotencyKey: row.idempotency_key as string | null, + originalScheduleDate: new Date(row.original_schedule_date as string), + scheduledAt: new Date(row.scheduled_at as string), + claimedAt: row.claimed_at ? new Date(row.claimed_at as string) : null, + completedAt: row.completed_at ? new Date(row.completed_at as string) : null, + lastExecutedAt: row.last_executed_at ? new Date(row.last_executed_at as string) : null, + retryCount: row.retry_count as number, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + } as ChronoTaskEntity; +} + +/** + * Creates a mock QueryBuilder that executes against PGlite + */ +function createMockQueryBuilder(pglite: PGlite, _initialEntity?: typeof ChronoTaskEntity) { + let operation: 'select' | 'update' | 'delete' = 'select'; + let alias = 'entity'; + const whereClauses: Array<{ sql: string; params: Record }> = []; + let setValues: Record = {}; + let orderByClauses: Array<{ field: string; direction: 'ASC' | 'DESC' }> = []; + let hasReturning = false; + let lockMode: string | undefined; + + // Convert camelCase to snake_case + const toSnakeCase = (str: string): string => { + return str.replace(/([A-Z])/g, '_$1').toLowerCase(); + }; + + // Convert column references from camelCase to snake_case in SQL + const convertColumnNames = (sql: string): string => { + // Match word.word patterns (alias.column) and convert column part + return sql.replace(/(\w+)\.(\w+)/g, (_match, tableAlias, column) => { + return `${tableAlias}.${toSnakeCase(column)}`; + }); + }; + + const replaceParams = (sql: string, params: Record): { sql: string; values: unknown[] } => { + const values: unknown[] = []; + let paramIndex = 1; + let result = convertColumnNames(sql); + + for (const [key, value] of Object.entries(params)) { + const placeholder = `:${key}`; + if (result.includes(placeholder)) { + result = result.split(placeholder).join(`$${paramIndex}`); + values.push(value); + paramIndex++; + } + } + + return { sql: result, values }; + }; + + const buildWhereClause = (): { sql: string; values: unknown[] } => { + if (whereClauses.length === 0) { + return { sql: '', values: [] }; + } + + const allParams: Record = {}; + const conditions: string[] = []; + + for (const clause of whereClauses) { + conditions.push(clause.sql); + Object.assign(allParams, clause.params); + } + + const combinedSql = conditions.join(' AND '); + return replaceParams(combinedSql, allParams); + }; + + const qb = { + select: () => { + operation = 'select'; + return qb; + }, + update: (_entity: typeof ChronoTaskEntity) => { + operation = 'update'; + return qb; + }, + delete: () => { + operation = 'delete'; + return qb; + }, + from: (_entity: typeof ChronoTaskEntity, entityAlias?: string) => { + if (entityAlias) alias = entityAlias; + return qb; + }, + where: (condition: string | { getQuery: () => string } | unknown, params?: Record) => { + if (typeof condition === 'function') { + // Handle callback style (direct function) + const bracketsQb = createBracketsQb(); + condition(bracketsQb); + whereClauses.push({ sql: `(${bracketsQb.getSql()})`, params: bracketsQb.getParams() }); + } else if (typeof condition === 'object' && condition !== null && 'getQuery' in condition) { + // Handle Brackets object - it has a callback we need to invoke + const bracketsObj = condition as { getQuery: () => string }; + whereClauses.push({ sql: bracketsObj.getQuery(), params: params || {} }); + } else if (typeof condition === 'string') { + // Remove alias prefix and convert camelCase column names to snake_case + // But don't convert parameter placeholders (starting with :) + let cleanCondition = condition.replace(new RegExp(`${alias}\\.`, 'g'), ''); + // Convert camelCase identifiers to snake_case, but not :param placeholders + cleanCondition = cleanCondition.replace(/(? toSnakeCase(match)); + whereClauses.push({ sql: cleanCondition, params: params || {} }); + } + return qb; + }, + andWhere: (condition: string | { getQuery: () => string } | unknown, params?: Record) => { + // Handle Brackets object from TypeORM (uses 'whereFactory' property) + if (typeof condition === 'object' && condition !== null && 'whereFactory' in condition) { + const bracketsQb = createBracketsQb(); + (condition as { whereFactory: (qb: ReturnType) => void }).whereFactory(bracketsQb); + whereClauses.push({ sql: `(${bracketsQb.getSql()})`, params: bracketsQb.getParams() }); + return qb; + } + return qb.where(condition, params); + }, + orWhere: (condition: string, params?: Record) => { + const cleanCondition = condition.replace(new RegExp(`${alias}\\.`, 'g'), ''); + const lastClause = whereClauses.pop(); + if (lastClause) { + whereClauses.push({ + sql: `(${lastClause.sql} OR ${cleanCondition})`, + params: { ...lastClause.params, ...params }, + }); + } else { + whereClauses.push({ sql: cleanCondition, params: params || {} }); + } + return qb; + }, + set: (values: Record) => { + setValues = values; + return qb; + }, + orderBy: (field: string, direction: 'ASC' | 'DESC' = 'ASC') => { + const cleanField = field.replace(new RegExp(`${alias}\\.`, 'g'), ''); + orderByClauses = [{ field: cleanField, direction }]; + return qb; + }, + addOrderBy: (field: string, direction: 'ASC' | 'DESC' = 'ASC') => { + const cleanField = field.replace(new RegExp(`${alias}\\.`, 'g'), ''); + orderByClauses.push({ field: cleanField, direction }); + return qb; + }, + limit: (_limit: number) => { + // Limit is always 1 in our SQL for getOne + return qb; + }, + returning: (_columns: string) => { + hasReturning = true; + return qb; + }, + setLock: (mode: string, _version?: unknown, _tables?: string[]) => { + lockMode = mode; + return qb; + }, + getOne: async (): Promise => { + const { sql: whereClause, values } = buildWhereClause(); + + let sql = `SELECT * FROM ${TEST_TABLE_NAME}`; + if (whereClause) { + sql += ` WHERE ${whereClause}`; + } + if (orderByClauses.length > 0) { + const orderBy = orderByClauses.map((o) => { + // Convert camelCase to snake_case + const snakeField = o.field.replace(/([A-Z])/g, '_$1').toLowerCase(); + return `${snakeField} ${o.direction}`; + }); + sql += ` ORDER BY ${orderBy.join(', ')}`; + } + sql += ' LIMIT 1'; + if (lockMode) { + sql += ' FOR UPDATE SKIP LOCKED'; + } + + const result = await pglite.query(sql, values); + if (result.rows.length === 0) return null; + return mapRowToEntity(result.rows[0] as Record); + }, + execute: async (): Promise<{ raw: ChronoTaskEntity[]; affected: number }> => { + const { sql: whereClause, values: whereValues } = buildWhereClause(); + + if (operation === 'delete') { + let sql = `DELETE FROM ${TEST_TABLE_NAME}`; + if (whereClause) { + sql += ` WHERE ${whereClause}`; + } + if (hasReturning) { + sql += ' RETURNING *'; + } + + const result = await pglite.query(sql, whereValues); + const entities = (result.rows as Record[]).map(mapRowToEntity); + return { raw: entities, affected: result.rows.length }; + } + + if (operation === 'update') { + const setClauses: string[] = []; + const setParamValues: unknown[] = []; + let paramIndex = whereValues.length + 1; + + for (const [key, value] of Object.entries(setValues)) { + // Convert camelCase to snake_case + const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); + if (typeof value === 'function') { + // Raw SQL expression like () => 'NULL' or () => 'retry_count + 1' + const rawValue = value(); + setClauses.push(`${snakeKey} = ${rawValue}`); + } else { + setClauses.push(`${snakeKey} = $${paramIndex}`); + setParamValues.push(value); + paramIndex++; + } + } + + let sql = `UPDATE ${TEST_TABLE_NAME} SET ${setClauses.join(', ')}`; + if (whereClause) { + sql += ` WHERE ${whereClause}`; + } + if (hasReturning) { + sql += ' RETURNING *'; + } + + const result = await pglite.query(sql, [...whereValues, ...setParamValues]); + const entities = (result.rows as Record[]).map(mapRowToEntity); + return { raw: entities, affected: result.rows.length }; + } + + return { raw: [], affected: 0 }; + }, + }; + + return qb; +} + +function createBracketsQb() { + const conditions: Array<{ type: 'where' | 'or'; sql: string; params: Record }> = []; + + // Convert camelCase to snake_case + const toSnakeCase = (str: string): string => { + return str.replace(/([A-Z])/g, '_$1').toLowerCase(); + }; + + const bqb = { + where: (sql: string, params?: Record) => { + conditions.push({ type: 'where', sql, params: params || {} }); + return bqb; + }, + orWhere: (sql: string, params?: Record) => { + conditions.push({ type: 'or', sql, params: params || {} }); + return bqb; + }, + getSql: (): string => { + return conditions + .map((c, i) => { + // Remove task. prefix and convert camelCase column names to snake_case + // But don't convert :param placeholders + let cleanSql = c.sql.replace(/task\./g, ''); + cleanSql = cleanSql.replace(/(? toSnakeCase(match)); + if (i === 0) return cleanSql; + return c.type === 'or' ? `OR ${cleanSql}` : `AND ${cleanSql}`; + }) + .join(' '); + }, + getParams: (): Record => { + const result: Record = {}; + for (const c of conditions) { + Object.assign(result, c.params); + } + return result; + }, + }; + return bqb; +} + /** * Creates a mock DataSource that wraps PGlite to provide the interface - * expected by ChronoPostgresDatastore + * expected by ChronoPostgresDatastore with QueryBuilder support */ function createMockDataSource(pglite: PGlite) { + const createQueryBuilder = (entity?: typeof ChronoTaskEntity, entityAlias?: string) => { + const qb = createMockQueryBuilder(pglite, entity); + if (entityAlias && entity) { + qb.from(entity, entityAlias); + } + return qb; + }; + const manager = { query: async (sql: string, params?: unknown[]) => { - // Convert $1, $2, etc. to PGlite's parameterized query format - const result = await pglite.query(sql, params as any[]); + const result = await pglite.query(sql, params as unknown[]); return result.rows; }, - create: (_entity: any, data: any) => data, - save: async (_entity: any, data: any) => { + create: (_entity: typeof ChronoTaskEntity, data: Partial) => data, + save: async (_entity: typeof ChronoTaskEntity, data: Partial) => { const id = faker.string.uuid(); const now = new Date(); const result = await pglite.query( @@ -48,23 +343,12 @@ function createMockDataSource(pglite: PGlite) { now, ], ); - const row = result.rows[0] as any; - return { - ...row, - data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data, - idempotencyKey: row.idempotency_key, - originalScheduleDate: row.original_schedule_date, - scheduledAt: row.scheduled_at, - claimedAt: row.claimed_at, - completedAt: row.completed_at, - lastExecutedAt: row.last_executed_at, - retryCount: row.retry_count, - }; + return mapRowToEntity(result.rows[0] as Record); }, - findOne: async (_entity: any, options: any) => { + findOne: async (_entity: typeof ChronoTaskEntity, options: { where: Partial }) => { const where = options.where; let sql = `SELECT * FROM ${TEST_TABLE_NAME} WHERE 1=1`; - const params: any[] = []; + const params: unknown[] = []; let paramIndex = 1; if (where.id) { @@ -79,30 +363,22 @@ function createMockDataSource(pglite: PGlite) { const result = await pglite.query(sql, params); if (result.rows.length === 0) return null; - - const row = result.rows[0] as any; - return { - ...row, - data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data, - idempotencyKey: row.idempotency_key, - originalScheduleDate: row.original_schedule_date, - scheduledAt: row.scheduled_at, - claimedAt: row.claimed_at, - completedAt: row.completed_at, - lastExecutedAt: row.last_executed_at, - retryCount: row.retry_count, - }; - }, - delete: async (_entity: any, _where: any) => { - await pglite.query(`DELETE FROM ${TEST_TABLE_NAME}`); + return mapRowToEntity(result.rows[0] as Record); }, + createQueryBuilder, }; return { manager, isInitialized: true, destroy: async () => {}, - } as any; + createQueryBuilder, + transaction: async (work: (manager: typeof manager) => Promise): Promise => { + // PGlite doesn't support real transactions in the same way, + // but for testing purposes we can just execute the work + return work(manager); + }, + }; } describe('ChronoPostgresDatastore', () => { @@ -143,11 +419,9 @@ describe('ChronoPostgresDatastore', () => { mockDataSource = createMockDataSource(pglite); - dataStore = new ChronoPostgresDatastore({ - tableName: TEST_TABLE_NAME, - }); + dataStore = new ChronoPostgresDatastore({}); - await dataStore.initialize(mockDataSource); + await dataStore.initialize(mockDataSource as unknown as Parameters[0]); }); beforeEach(async () => { @@ -160,7 +434,9 @@ describe('ChronoPostgresDatastore', () => { describe('initialize', () => { test('should throw an error if the DataSource is already set', async () => { - await expect(() => dataStore.initialize(mockDataSource)).rejects.toThrow('DataSource already initialized'); + await expect(() => + dataStore.initialize(mockDataSource as unknown as Parameters[0]), + ).rejects.toThrow('DataSource already initialized'); }); }); From 1d73f8d386755c3ae712bc252120307106e54ff3 Mon Sep 17 00:00:00 2001 From: James Couch Date: Tue, 27 Jan 2026 03:03:52 -0700 Subject: [PATCH 04/21] Extract mock DataSource helpers to separate file Move mock QueryBuilder and DataSource infrastructure from test file to test/helpers/mock-datasource.ts to improve test file readability. Co-Authored-By: Claude (claude-4-opus) --- .../test/helpers/mock-datasource.ts | 373 ++++++++++++++++++ .../unit/chrono-postgres-datastore.test.ts | 370 +---------------- 2 files changed, 375 insertions(+), 368 deletions(-) create mode 100644 packages/chrono-postgres-datastore/test/helpers/mock-datasource.ts diff --git a/packages/chrono-postgres-datastore/test/helpers/mock-datasource.ts b/packages/chrono-postgres-datastore/test/helpers/mock-datasource.ts new file mode 100644 index 0000000..0df8c29 --- /dev/null +++ b/packages/chrono-postgres-datastore/test/helpers/mock-datasource.ts @@ -0,0 +1,373 @@ +import type { PGlite } from '@electric-sql/pglite'; +import { faker } from '@faker-js/faker'; + +import type { ChronoTaskEntity } from '../../src/chrono-task.entity'; +import { TEST_TABLE_NAME } from '../database-setup'; + +/** + * Maps a raw database row to a ChronoTaskEntity-like object + */ +export function mapRowToEntity(row: Record): ChronoTaskEntity { + return { + id: row.id as string, + kind: row.kind as string, + status: row.status as string, + data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data, + priority: row.priority as number | null, + idempotencyKey: row.idempotency_key as string | null, + originalScheduleDate: new Date(row.original_schedule_date as string), + scheduledAt: new Date(row.scheduled_at as string), + claimedAt: row.claimed_at ? new Date(row.claimed_at as string) : null, + completedAt: row.completed_at ? new Date(row.completed_at as string) : null, + lastExecutedAt: row.last_executed_at ? new Date(row.last_executed_at as string) : null, + retryCount: row.retry_count as number, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + } as ChronoTaskEntity; +} + +/** + * Convert camelCase to snake_case + */ +function toSnakeCase(str: string): string { + return str.replace(/([A-Z])/g, '_$1').toLowerCase(); +} + +/** + * Creates a sub-query builder for handling Brackets clauses + */ +export function createBracketsQb() { + const conditions: Array<{ type: 'where' | 'or'; sql: string; params: Record }> = []; + + const bqb = { + where: (sql: string, params?: Record) => { + conditions.push({ type: 'where', sql, params: params || {} }); + return bqb; + }, + orWhere: (sql: string, params?: Record) => { + conditions.push({ type: 'or', sql, params: params || {} }); + return bqb; + }, + getSql: (): string => { + return conditions + .map((c, i) => { + // Remove task. prefix and convert camelCase column names to snake_case + // But don't convert :param placeholders + let cleanSql = c.sql.replace(/task\./g, ''); + cleanSql = cleanSql.replace(/(? toSnakeCase(match)); + if (i === 0) return cleanSql; + return c.type === 'or' ? `OR ${cleanSql}` : `AND ${cleanSql}`; + }) + .join(' '); + }, + getParams: (): Record => { + const result: Record = {}; + for (const c of conditions) { + Object.assign(result, c.params); + } + return result; + }, + }; + return bqb; +} + +/** + * Creates a mock QueryBuilder that executes against PGlite + */ +export function createMockQueryBuilder(pglite: PGlite, _initialEntity?: typeof ChronoTaskEntity) { + let operation: 'select' | 'update' | 'delete' = 'select'; + let alias = 'entity'; + const whereClauses: Array<{ sql: string; params: Record }> = []; + let setValues: Record = {}; + let orderByClauses: Array<{ field: string; direction: 'ASC' | 'DESC' }> = []; + let hasReturning = false; + let lockMode: string | undefined; + + // Convert column references from camelCase to snake_case in SQL + const convertColumnNames = (sql: string): string => { + // Match word.word patterns (alias.column) and convert column part + return sql.replace(/(\w+)\.(\w+)/g, (_match, tableAlias, column) => { + return `${tableAlias}.${toSnakeCase(column)}`; + }); + }; + + const replaceParams = (sql: string, params: Record): { sql: string; values: unknown[] } => { + const values: unknown[] = []; + let paramIndex = 1; + let result = convertColumnNames(sql); + + for (const [key, value] of Object.entries(params)) { + const placeholder = `:${key}`; + if (result.includes(placeholder)) { + result = result.split(placeholder).join(`$${paramIndex}`); + values.push(value); + paramIndex++; + } + } + + return { sql: result, values }; + }; + + const buildWhereClause = (): { sql: string; values: unknown[] } => { + if (whereClauses.length === 0) { + return { sql: '', values: [] }; + } + + const allParams: Record = {}; + const conditions: string[] = []; + + for (const clause of whereClauses) { + conditions.push(clause.sql); + Object.assign(allParams, clause.params); + } + + const combinedSql = conditions.join(' AND '); + return replaceParams(combinedSql, allParams); + }; + + const qb = { + select: () => { + operation = 'select'; + return qb; + }, + update: (_entity: typeof ChronoTaskEntity) => { + operation = 'update'; + return qb; + }, + delete: () => { + operation = 'delete'; + return qb; + }, + from: (_entity: typeof ChronoTaskEntity, entityAlias?: string) => { + if (entityAlias) alias = entityAlias; + return qb; + }, + where: (condition: string | { getQuery: () => string } | unknown, params?: Record) => { + if (typeof condition === 'function') { + // Handle callback style (direct function) + const bracketsQb = createBracketsQb(); + condition(bracketsQb); + whereClauses.push({ sql: `(${bracketsQb.getSql()})`, params: bracketsQb.getParams() }); + } else if (typeof condition === 'object' && condition !== null && 'getQuery' in condition) { + // Handle Brackets object - it has a callback we need to invoke + const bracketsObj = condition as { getQuery: () => string }; + whereClauses.push({ sql: bracketsObj.getQuery(), params: params || {} }); + } else if (typeof condition === 'string') { + // Remove alias prefix and convert camelCase column names to snake_case + // But don't convert parameter placeholders (starting with :) + let cleanCondition = condition.replace(new RegExp(`${alias}\\.`, 'g'), ''); + // Convert camelCase identifiers to snake_case, but not :param placeholders + cleanCondition = cleanCondition.replace(/(? toSnakeCase(match)); + whereClauses.push({ sql: cleanCondition, params: params || {} }); + } + return qb; + }, + andWhere: (condition: string | { getQuery: () => string } | unknown, params?: Record) => { + // Handle Brackets object from TypeORM (uses 'whereFactory' property) + if (typeof condition === 'object' && condition !== null && 'whereFactory' in condition) { + const bracketsQb = createBracketsQb(); + (condition as { whereFactory: (qb: ReturnType) => void }).whereFactory(bracketsQb); + whereClauses.push({ sql: `(${bracketsQb.getSql()})`, params: bracketsQb.getParams() }); + return qb; + } + return qb.where(condition, params); + }, + orWhere: (condition: string, params?: Record) => { + const cleanCondition = condition.replace(new RegExp(`${alias}\\.`, 'g'), ''); + const lastClause = whereClauses.pop(); + if (lastClause) { + whereClauses.push({ + sql: `(${lastClause.sql} OR ${cleanCondition})`, + params: { ...lastClause.params, ...params }, + }); + } else { + whereClauses.push({ sql: cleanCondition, params: params || {} }); + } + return qb; + }, + set: (values: Record) => { + setValues = values; + return qb; + }, + orderBy: (field: string, direction: 'ASC' | 'DESC' = 'ASC') => { + const cleanField = field.replace(new RegExp(`${alias}\\.`, 'g'), ''); + orderByClauses = [{ field: cleanField, direction }]; + return qb; + }, + addOrderBy: (field: string, direction: 'ASC' | 'DESC' = 'ASC') => { + const cleanField = field.replace(new RegExp(`${alias}\\.`, 'g'), ''); + orderByClauses.push({ field: cleanField, direction }); + return qb; + }, + limit: (_limit: number) => { + // Limit is always 1 in our SQL for getOne + return qb; + }, + returning: (_columns: string) => { + hasReturning = true; + return qb; + }, + setLock: (mode: string, _version?: unknown, _tables?: string[]) => { + lockMode = mode; + return qb; + }, + getOne: async (): Promise => { + const { sql: whereClause, values } = buildWhereClause(); + + let sql = `SELECT * FROM ${TEST_TABLE_NAME}`; + if (whereClause) { + sql += ` WHERE ${whereClause}`; + } + if (orderByClauses.length > 0) { + const orderBy = orderByClauses.map((o) => { + // Convert camelCase to snake_case + const snakeField = o.field.replace(/([A-Z])/g, '_$1').toLowerCase(); + return `${snakeField} ${o.direction}`; + }); + sql += ` ORDER BY ${orderBy.join(', ')}`; + } + sql += ' LIMIT 1'; + if (lockMode) { + sql += ' FOR UPDATE SKIP LOCKED'; + } + + const result = await pglite.query(sql, values); + if (result.rows.length === 0) return null; + return mapRowToEntity(result.rows[0] as Record); + }, + execute: async (): Promise<{ raw: ChronoTaskEntity[]; affected: number }> => { + const { sql: whereClause, values: whereValues } = buildWhereClause(); + + if (operation === 'delete') { + let sql = `DELETE FROM ${TEST_TABLE_NAME}`; + if (whereClause) { + sql += ` WHERE ${whereClause}`; + } + if (hasReturning) { + sql += ' RETURNING *'; + } + + const result = await pglite.query(sql, whereValues); + const entities = (result.rows as Record[]).map(mapRowToEntity); + return { raw: entities, affected: result.rows.length }; + } + + if (operation === 'update') { + const setClauses: string[] = []; + const setParamValues: unknown[] = []; + let paramIndex = whereValues.length + 1; + + for (const [key, value] of Object.entries(setValues)) { + // Convert camelCase to snake_case + const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); + if (typeof value === 'function') { + // Raw SQL expression like () => 'NULL' or () => 'retry_count + 1' + const rawValue = value(); + setClauses.push(`${snakeKey} = ${rawValue}`); + } else { + setClauses.push(`${snakeKey} = $${paramIndex}`); + setParamValues.push(value); + paramIndex++; + } + } + + let sql = `UPDATE ${TEST_TABLE_NAME} SET ${setClauses.join(', ')}`; + if (whereClause) { + sql += ` WHERE ${whereClause}`; + } + if (hasReturning) { + sql += ' RETURNING *'; + } + + const result = await pglite.query(sql, [...whereValues, ...setParamValues]); + const entities = (result.rows as Record[]).map(mapRowToEntity); + return { raw: entities, affected: result.rows.length }; + } + + return { raw: [], affected: 0 }; + }, + }; + + return qb; +} + +/** + * Creates a mock DataSource that wraps PGlite to provide the interface + * expected by ChronoPostgresDatastore with QueryBuilder support + */ +export function createMockDataSource(pglite: PGlite) { + const createQueryBuilder = (entity?: typeof ChronoTaskEntity, entityAlias?: string) => { + const qb = createMockQueryBuilder(pglite, entity); + if (entityAlias && entity) { + qb.from(entity, entityAlias); + } + return qb; + }; + + const manager = { + query: async (sql: string, params?: unknown[]) => { + const result = await pglite.query(sql, params as unknown[]); + return result.rows; + }, + create: (_entity: typeof ChronoTaskEntity, data: Partial) => data, + save: async (_entity: typeof ChronoTaskEntity, data: Partial) => { + const id = faker.string.uuid(); + const now = new Date(); + const result = await pglite.query( + `INSERT INTO ${TEST_TABLE_NAME} + (id, kind, status, data, priority, idempotency_key, original_schedule_date, scheduled_at, retry_count, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *`, + [ + id, + data.kind, + data.status, + JSON.stringify(data.data), + data.priority ?? 0, + data.idempotencyKey ?? null, + data.originalScheduleDate, + data.scheduledAt, + data.retryCount ?? 0, + now, + now, + ], + ); + return mapRowToEntity(result.rows[0] as Record); + }, + findOne: async (_entity: typeof ChronoTaskEntity, options: { where: Partial }) => { + const where = options.where; + let sql = `SELECT * FROM ${TEST_TABLE_NAME} WHERE 1=1`; + const params: unknown[] = []; + let paramIndex = 1; + + if (where.id) { + sql += ` AND id = $${paramIndex++}`; + params.push(where.id); + } + if (where.idempotencyKey) { + sql += ` AND idempotency_key = $${paramIndex++}`; + params.push(where.idempotencyKey); + } + sql += ' LIMIT 1'; + + const result = await pglite.query(sql, params); + if (result.rows.length === 0) return null; + return mapRowToEntity(result.rows[0] as Record); + }, + createQueryBuilder, + }; + + return { + manager, + isInitialized: true, + destroy: async () => {}, + createQueryBuilder, + transaction: async (work: (manager: typeof manager) => Promise): Promise => { + // PGlite doesn't support real transactions in the same way, + // but for testing purposes we can just execute the work + return work(manager); + }, + }; +} + +export type MockDataSource = ReturnType; diff --git a/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts b/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts index 3db171b..50e551f 100644 --- a/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts +++ b/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts @@ -4,8 +4,8 @@ import { TaskStatus } from '@neofinancial/chrono'; import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { ChronoPostgresDatastore } from '../../src/chrono-postgres-datastore'; -import type { ChronoTaskEntity } from '../../src/chrono-task.entity'; import { TEST_TABLE_NAME } from '../database-setup'; +import { createMockDataSource, type MockDataSource } from '../helpers/mock-datasource'; type TaskMapping = { test: { @@ -15,375 +15,9 @@ type TaskMapping = { const TEST_CLAIM_STALE_TIMEOUT_MS = 1_000; // 1 second -/** - * Maps a raw database row to a ChronoTaskEntity-like object - */ -function mapRowToEntity(row: Record): ChronoTaskEntity { - return { - id: row.id as string, - kind: row.kind as string, - status: row.status as string, - data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data, - priority: row.priority as number | null, - idempotencyKey: row.idempotency_key as string | null, - originalScheduleDate: new Date(row.original_schedule_date as string), - scheduledAt: new Date(row.scheduled_at as string), - claimedAt: row.claimed_at ? new Date(row.claimed_at as string) : null, - completedAt: row.completed_at ? new Date(row.completed_at as string) : null, - lastExecutedAt: row.last_executed_at ? new Date(row.last_executed_at as string) : null, - retryCount: row.retry_count as number, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - } as ChronoTaskEntity; -} - -/** - * Creates a mock QueryBuilder that executes against PGlite - */ -function createMockQueryBuilder(pglite: PGlite, _initialEntity?: typeof ChronoTaskEntity) { - let operation: 'select' | 'update' | 'delete' = 'select'; - let alias = 'entity'; - const whereClauses: Array<{ sql: string; params: Record }> = []; - let setValues: Record = {}; - let orderByClauses: Array<{ field: string; direction: 'ASC' | 'DESC' }> = []; - let hasReturning = false; - let lockMode: string | undefined; - - // Convert camelCase to snake_case - const toSnakeCase = (str: string): string => { - return str.replace(/([A-Z])/g, '_$1').toLowerCase(); - }; - - // Convert column references from camelCase to snake_case in SQL - const convertColumnNames = (sql: string): string => { - // Match word.word patterns (alias.column) and convert column part - return sql.replace(/(\w+)\.(\w+)/g, (_match, tableAlias, column) => { - return `${tableAlias}.${toSnakeCase(column)}`; - }); - }; - - const replaceParams = (sql: string, params: Record): { sql: string; values: unknown[] } => { - const values: unknown[] = []; - let paramIndex = 1; - let result = convertColumnNames(sql); - - for (const [key, value] of Object.entries(params)) { - const placeholder = `:${key}`; - if (result.includes(placeholder)) { - result = result.split(placeholder).join(`$${paramIndex}`); - values.push(value); - paramIndex++; - } - } - - return { sql: result, values }; - }; - - const buildWhereClause = (): { sql: string; values: unknown[] } => { - if (whereClauses.length === 0) { - return { sql: '', values: [] }; - } - - const allParams: Record = {}; - const conditions: string[] = []; - - for (const clause of whereClauses) { - conditions.push(clause.sql); - Object.assign(allParams, clause.params); - } - - const combinedSql = conditions.join(' AND '); - return replaceParams(combinedSql, allParams); - }; - - const qb = { - select: () => { - operation = 'select'; - return qb; - }, - update: (_entity: typeof ChronoTaskEntity) => { - operation = 'update'; - return qb; - }, - delete: () => { - operation = 'delete'; - return qb; - }, - from: (_entity: typeof ChronoTaskEntity, entityAlias?: string) => { - if (entityAlias) alias = entityAlias; - return qb; - }, - where: (condition: string | { getQuery: () => string } | unknown, params?: Record) => { - if (typeof condition === 'function') { - // Handle callback style (direct function) - const bracketsQb = createBracketsQb(); - condition(bracketsQb); - whereClauses.push({ sql: `(${bracketsQb.getSql()})`, params: bracketsQb.getParams() }); - } else if (typeof condition === 'object' && condition !== null && 'getQuery' in condition) { - // Handle Brackets object - it has a callback we need to invoke - const bracketsObj = condition as { getQuery: () => string }; - whereClauses.push({ sql: bracketsObj.getQuery(), params: params || {} }); - } else if (typeof condition === 'string') { - // Remove alias prefix and convert camelCase column names to snake_case - // But don't convert parameter placeholders (starting with :) - let cleanCondition = condition.replace(new RegExp(`${alias}\\.`, 'g'), ''); - // Convert camelCase identifiers to snake_case, but not :param placeholders - cleanCondition = cleanCondition.replace(/(? toSnakeCase(match)); - whereClauses.push({ sql: cleanCondition, params: params || {} }); - } - return qb; - }, - andWhere: (condition: string | { getQuery: () => string } | unknown, params?: Record) => { - // Handle Brackets object from TypeORM (uses 'whereFactory' property) - if (typeof condition === 'object' && condition !== null && 'whereFactory' in condition) { - const bracketsQb = createBracketsQb(); - (condition as { whereFactory: (qb: ReturnType) => void }).whereFactory(bracketsQb); - whereClauses.push({ sql: `(${bracketsQb.getSql()})`, params: bracketsQb.getParams() }); - return qb; - } - return qb.where(condition, params); - }, - orWhere: (condition: string, params?: Record) => { - const cleanCondition = condition.replace(new RegExp(`${alias}\\.`, 'g'), ''); - const lastClause = whereClauses.pop(); - if (lastClause) { - whereClauses.push({ - sql: `(${lastClause.sql} OR ${cleanCondition})`, - params: { ...lastClause.params, ...params }, - }); - } else { - whereClauses.push({ sql: cleanCondition, params: params || {} }); - } - return qb; - }, - set: (values: Record) => { - setValues = values; - return qb; - }, - orderBy: (field: string, direction: 'ASC' | 'DESC' = 'ASC') => { - const cleanField = field.replace(new RegExp(`${alias}\\.`, 'g'), ''); - orderByClauses = [{ field: cleanField, direction }]; - return qb; - }, - addOrderBy: (field: string, direction: 'ASC' | 'DESC' = 'ASC') => { - const cleanField = field.replace(new RegExp(`${alias}\\.`, 'g'), ''); - orderByClauses.push({ field: cleanField, direction }); - return qb; - }, - limit: (_limit: number) => { - // Limit is always 1 in our SQL for getOne - return qb; - }, - returning: (_columns: string) => { - hasReturning = true; - return qb; - }, - setLock: (mode: string, _version?: unknown, _tables?: string[]) => { - lockMode = mode; - return qb; - }, - getOne: async (): Promise => { - const { sql: whereClause, values } = buildWhereClause(); - - let sql = `SELECT * FROM ${TEST_TABLE_NAME}`; - if (whereClause) { - sql += ` WHERE ${whereClause}`; - } - if (orderByClauses.length > 0) { - const orderBy = orderByClauses.map((o) => { - // Convert camelCase to snake_case - const snakeField = o.field.replace(/([A-Z])/g, '_$1').toLowerCase(); - return `${snakeField} ${o.direction}`; - }); - sql += ` ORDER BY ${orderBy.join(', ')}`; - } - sql += ' LIMIT 1'; - if (lockMode) { - sql += ' FOR UPDATE SKIP LOCKED'; - } - - const result = await pglite.query(sql, values); - if (result.rows.length === 0) return null; - return mapRowToEntity(result.rows[0] as Record); - }, - execute: async (): Promise<{ raw: ChronoTaskEntity[]; affected: number }> => { - const { sql: whereClause, values: whereValues } = buildWhereClause(); - - if (operation === 'delete') { - let sql = `DELETE FROM ${TEST_TABLE_NAME}`; - if (whereClause) { - sql += ` WHERE ${whereClause}`; - } - if (hasReturning) { - sql += ' RETURNING *'; - } - - const result = await pglite.query(sql, whereValues); - const entities = (result.rows as Record[]).map(mapRowToEntity); - return { raw: entities, affected: result.rows.length }; - } - - if (operation === 'update') { - const setClauses: string[] = []; - const setParamValues: unknown[] = []; - let paramIndex = whereValues.length + 1; - - for (const [key, value] of Object.entries(setValues)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - if (typeof value === 'function') { - // Raw SQL expression like () => 'NULL' or () => 'retry_count + 1' - const rawValue = value(); - setClauses.push(`${snakeKey} = ${rawValue}`); - } else { - setClauses.push(`${snakeKey} = $${paramIndex}`); - setParamValues.push(value); - paramIndex++; - } - } - - let sql = `UPDATE ${TEST_TABLE_NAME} SET ${setClauses.join(', ')}`; - if (whereClause) { - sql += ` WHERE ${whereClause}`; - } - if (hasReturning) { - sql += ' RETURNING *'; - } - - const result = await pglite.query(sql, [...whereValues, ...setParamValues]); - const entities = (result.rows as Record[]).map(mapRowToEntity); - return { raw: entities, affected: result.rows.length }; - } - - return { raw: [], affected: 0 }; - }, - }; - - return qb; -} - -function createBracketsQb() { - const conditions: Array<{ type: 'where' | 'or'; sql: string; params: Record }> = []; - - // Convert camelCase to snake_case - const toSnakeCase = (str: string): string => { - return str.replace(/([A-Z])/g, '_$1').toLowerCase(); - }; - - const bqb = { - where: (sql: string, params?: Record) => { - conditions.push({ type: 'where', sql, params: params || {} }); - return bqb; - }, - orWhere: (sql: string, params?: Record) => { - conditions.push({ type: 'or', sql, params: params || {} }); - return bqb; - }, - getSql: (): string => { - return conditions - .map((c, i) => { - // Remove task. prefix and convert camelCase column names to snake_case - // But don't convert :param placeholders - let cleanSql = c.sql.replace(/task\./g, ''); - cleanSql = cleanSql.replace(/(? toSnakeCase(match)); - if (i === 0) return cleanSql; - return c.type === 'or' ? `OR ${cleanSql}` : `AND ${cleanSql}`; - }) - .join(' '); - }, - getParams: (): Record => { - const result: Record = {}; - for (const c of conditions) { - Object.assign(result, c.params); - } - return result; - }, - }; - return bqb; -} - -/** - * Creates a mock DataSource that wraps PGlite to provide the interface - * expected by ChronoPostgresDatastore with QueryBuilder support - */ -function createMockDataSource(pglite: PGlite) { - const createQueryBuilder = (entity?: typeof ChronoTaskEntity, entityAlias?: string) => { - const qb = createMockQueryBuilder(pglite, entity); - if (entityAlias && entity) { - qb.from(entity, entityAlias); - } - return qb; - }; - - const manager = { - query: async (sql: string, params?: unknown[]) => { - const result = await pglite.query(sql, params as unknown[]); - return result.rows; - }, - create: (_entity: typeof ChronoTaskEntity, data: Partial) => data, - save: async (_entity: typeof ChronoTaskEntity, data: Partial) => { - const id = faker.string.uuid(); - const now = new Date(); - const result = await pglite.query( - `INSERT INTO ${TEST_TABLE_NAME} - (id, kind, status, data, priority, idempotency_key, original_schedule_date, scheduled_at, retry_count, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING *`, - [ - id, - data.kind, - data.status, - JSON.stringify(data.data), - data.priority ?? 0, - data.idempotencyKey ?? null, - data.originalScheduleDate, - data.scheduledAt, - data.retryCount ?? 0, - now, - now, - ], - ); - return mapRowToEntity(result.rows[0] as Record); - }, - findOne: async (_entity: typeof ChronoTaskEntity, options: { where: Partial }) => { - const where = options.where; - let sql = `SELECT * FROM ${TEST_TABLE_NAME} WHERE 1=1`; - const params: unknown[] = []; - let paramIndex = 1; - - if (where.id) { - sql += ` AND id = $${paramIndex++}`; - params.push(where.id); - } - if (where.idempotencyKey) { - sql += ` AND idempotency_key = $${paramIndex++}`; - params.push(where.idempotencyKey); - } - sql += ' LIMIT 1'; - - const result = await pglite.query(sql, params); - if (result.rows.length === 0) return null; - return mapRowToEntity(result.rows[0] as Record); - }, - createQueryBuilder, - }; - - return { - manager, - isInitialized: true, - destroy: async () => {}, - createQueryBuilder, - transaction: async (work: (manager: typeof manager) => Promise): Promise => { - // PGlite doesn't support real transactions in the same way, - // but for testing purposes we can just execute the work - return work(manager); - }, - }; -} - describe('ChronoPostgresDatastore', () => { let pglite: PGlite; - let mockDataSource: ReturnType; + let mockDataSource: MockDataSource; let dataStore: ChronoPostgresDatastore; beforeAll(async () => { From bacd330953dfa3c739a5851ac4907ef2b34395e4 Mon Sep 17 00:00:00 2001 From: James Couch Date: Tue, 27 Jan 2026 03:11:26 -0700 Subject: [PATCH 05/21] Refactor test helpers into modular, readable files Split the dense mock-datasource.ts into focused, well-documented modules: - sql-utils.ts: SQL string manipulation (snake_case, params) - entity-mapper.ts: Database row to entity conversion - where-clause-builder.ts: WHERE clause construction - mock-query-builder.ts: TypeORM QueryBuilder simulation - mock-entity-manager.ts: TypeORM EntityManager simulation - mock-datasource.ts: Main DataSource entry point - index.ts: Re-exports with architecture diagram Each file has clear documentation and single responsibility. Co-Authored-By: Claude (claude-4-opus) --- .../test/helpers/entity-mapper.ts | 64 +++ .../test/helpers/index.ts | 44 ++ .../test/helpers/mock-datasource.ts | 408 +++--------------- .../test/helpers/mock-entity-manager.ts | 132 ++++++ .../test/helpers/mock-query-builder.ts | 360 ++++++++++++++++ .../test/helpers/sql-utils.ts | 91 ++++ .../test/helpers/where-clause-builder.ts | 205 +++++++++ 7 files changed, 954 insertions(+), 350 deletions(-) create mode 100644 packages/chrono-postgres-datastore/test/helpers/entity-mapper.ts create mode 100644 packages/chrono-postgres-datastore/test/helpers/index.ts create mode 100644 packages/chrono-postgres-datastore/test/helpers/mock-entity-manager.ts create mode 100644 packages/chrono-postgres-datastore/test/helpers/mock-query-builder.ts create mode 100644 packages/chrono-postgres-datastore/test/helpers/sql-utils.ts create mode 100644 packages/chrono-postgres-datastore/test/helpers/where-clause-builder.ts diff --git a/packages/chrono-postgres-datastore/test/helpers/entity-mapper.ts b/packages/chrono-postgres-datastore/test/helpers/entity-mapper.ts new file mode 100644 index 0000000..8b3dc75 --- /dev/null +++ b/packages/chrono-postgres-datastore/test/helpers/entity-mapper.ts @@ -0,0 +1,64 @@ +/** + * Entity Mapper + * + * Converts raw PostgreSQL rows (with snake_case columns) to + * ChronoTaskEntity objects (with camelCase properties). + */ + +import type { ChronoTaskEntity } from '../../src/chrono-task.entity'; + +/** + * A raw database row with snake_case column names. + */ +type DatabaseRow = Record; + +/** + * Safely parses a value as a Date, or returns null if the value is falsy. + */ +function parseDate(value: unknown): Date | null { + if (!value) return null; + return new Date(value as string); +} + +/** + * Parses the JSON data field, handling both string and object formats. + * PGlite may return JSONB as either a string or an already-parsed object. + */ +function parseJsonData(data: unknown): Record { + if (typeof data === 'string') { + return JSON.parse(data); + } + return data as Record; +} + +/** + * Maps a raw PostgreSQL row to a ChronoTaskEntity object. + * + * This handles the conversion from: + * - snake_case column names to camelCase property names + * - String dates to Date objects + * - JSONB data that may be a string or object + * + * @example + * const row = { id: '123', scheduled_at: '2024-01-01', ... }; + * const entity = mapRowToEntity(row); + * // entity.scheduledAt is now a Date object + */ +export function mapRowToEntity(row: DatabaseRow): ChronoTaskEntity { + return { + id: row.id as string, + kind: row.kind as string, + status: row.status as string, + data: parseJsonData(row.data), + priority: row.priority as number | null, + idempotencyKey: row.idempotency_key as string | null, + originalScheduleDate: new Date(row.original_schedule_date as string), + scheduledAt: new Date(row.scheduled_at as string), + claimedAt: parseDate(row.claimed_at), + completedAt: parseDate(row.completed_at), + lastExecutedAt: parseDate(row.last_executed_at), + retryCount: row.retry_count as number, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + } as ChronoTaskEntity; +} diff --git a/packages/chrono-postgres-datastore/test/helpers/index.ts b/packages/chrono-postgres-datastore/test/helpers/index.ts new file mode 100644 index 0000000..7a51657 --- /dev/null +++ b/packages/chrono-postgres-datastore/test/helpers/index.ts @@ -0,0 +1,44 @@ +/** + * Test Helpers + * + * This module provides mock implementations of TypeORM's DataSource and + * related classes for testing ChronoPostgresDatastore without a real database. + * + * Architecture: + * ┌─────────────────────────────────────────────────────────────────┐ + * │ MockDataSource │ + * │ - Main entry point for tests │ + * │ - Provides manager, createQueryBuilder, transaction │ + * └─────────────────────────────────────────────────────────────────┘ + * │ + * ┌──────────────────┼──────────────────┐ + * ▼ ▼ + * ┌─────────────────────┐ ┌─────────────────────┐ + * │ MockEntityManager │ │ MockQueryBuilder │ + * │ - create, save │ │ - where, andWhere │ + * │ - findOne, query │ │ - set, orderBy │ + * └─────────────────────┘ │ - getOne, execute │ + * └─────────────────────┘ + * │ + * ▼ + * ┌─────────────────────┐ + * │ WhereClauseBuilder │ + * │ - Builds WHERE SQL │ + * │ - Handles Brackets │ + * └─────────────────────┘ + * │ + * ┌─────────────────────────────────────┼─────────────────┐ + * ▼ ▼ ▼ + * ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ + * │ SQL Utils │ │ Entity Mapper │ │ Database Setup │ + * │ - toSnakeCase │ │ - mapRowToEntity│ │ - TEST_TABLE │ + * │ - replaceParams │ └─────────────────┘ └─────────────────┘ + * └─────────────────┘ + */ + +export { mapRowToEntity } from './entity-mapper'; +// Main export - this is what tests should import +export { createMockDataSource, type MockDataSource } from './mock-datasource'; +// Lower-level exports for advanced usage or extension +export { createMockEntityManager, type MockEntityManager } from './mock-entity-manager'; +export { createMockQueryBuilder, type MockQueryBuilder } from './mock-query-builder'; diff --git a/packages/chrono-postgres-datastore/test/helpers/mock-datasource.ts b/packages/chrono-postgres-datastore/test/helpers/mock-datasource.ts index 0df8c29..1d00c9b 100644 --- a/packages/chrono-postgres-datastore/test/helpers/mock-datasource.ts +++ b/packages/chrono-postgres-datastore/test/helpers/mock-datasource.ts @@ -1,370 +1,78 @@ -import type { PGlite } from '@electric-sql/pglite'; -import { faker } from '@faker-js/faker'; - -import type { ChronoTaskEntity } from '../../src/chrono-task.entity'; -import { TEST_TABLE_NAME } from '../database-setup'; - -/** - * Maps a raw database row to a ChronoTaskEntity-like object - */ -export function mapRowToEntity(row: Record): ChronoTaskEntity { - return { - id: row.id as string, - kind: row.kind as string, - status: row.status as string, - data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data, - priority: row.priority as number | null, - idempotencyKey: row.idempotency_key as string | null, - originalScheduleDate: new Date(row.original_schedule_date as string), - scheduledAt: new Date(row.scheduled_at as string), - claimedAt: row.claimed_at ? new Date(row.claimed_at as string) : null, - completedAt: row.completed_at ? new Date(row.completed_at as string) : null, - lastExecutedAt: row.last_executed_at ? new Date(row.last_executed_at as string) : null, - retryCount: row.retry_count as number, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - } as ChronoTaskEntity; -} - /** - * Convert camelCase to snake_case + * Mock DataSource + * + * Simulates TypeORM's DataSource interface for testing ChronoPostgresDatastore. + * This is the main entry point for test mocking - it provides everything needed + * to test the datastore without a real PostgreSQL connection. + * + * Usage: + * ```ts + * const pglite = new PGlite(); + * const mockDataSource = createMockDataSource(pglite); + * await dataStore.initialize(mockDataSource); + * ``` */ -function toSnakeCase(str: string): string { - return str.replace(/([A-Z])/g, '_$1').toLowerCase(); -} -/** - * Creates a sub-query builder for handling Brackets clauses - */ -export function createBracketsQb() { - const conditions: Array<{ type: 'where' | 'or'; sql: string; params: Record }> = []; +import type { PGlite } from '@electric-sql/pglite'; - const bqb = { - where: (sql: string, params?: Record) => { - conditions.push({ type: 'where', sql, params: params || {} }); - return bqb; - }, - orWhere: (sql: string, params?: Record) => { - conditions.push({ type: 'or', sql, params: params || {} }); - return bqb; - }, - getSql: (): string => { - return conditions - .map((c, i) => { - // Remove task. prefix and convert camelCase column names to snake_case - // But don't convert :param placeholders - let cleanSql = c.sql.replace(/task\./g, ''); - cleanSql = cleanSql.replace(/(? toSnakeCase(match)); - if (i === 0) return cleanSql; - return c.type === 'or' ? `OR ${cleanSql}` : `AND ${cleanSql}`; - }) - .join(' '); - }, - getParams: (): Record => { - const result: Record = {}; - for (const c of conditions) { - Object.assign(result, c.params); - } - return result; - }, - }; - return bqb; -} +import type { ChronoTaskEntity } from '../../src/chrono-task.entity'; +import { createMockEntityManager, type MockEntityManager } from './mock-entity-manager'; +import { createMockQueryBuilder, type MockQueryBuilder } from './mock-query-builder'; /** - * Creates a mock QueryBuilder that executes against PGlite + * Creates a mock DataSource that wraps PGlite. + * + * The DataSource provides: + * - `manager`: An EntityManager for CRUD operations + * - `createQueryBuilder`: For complex queries + * - `transaction`: For wrapping operations in a transaction */ -export function createMockQueryBuilder(pglite: PGlite, _initialEntity?: typeof ChronoTaskEntity) { - let operation: 'select' | 'update' | 'delete' = 'select'; - let alias = 'entity'; - const whereClauses: Array<{ sql: string; params: Record }> = []; - let setValues: Record = {}; - let orderByClauses: Array<{ field: string; direction: 'ASC' | 'DESC' }> = []; - let hasReturning = false; - let lockMode: string | undefined; - - // Convert column references from camelCase to snake_case in SQL - const convertColumnNames = (sql: string): string => { - // Match word.word patterns (alias.column) and convert column part - return sql.replace(/(\w+)\.(\w+)/g, (_match, tableAlias, column) => { - return `${tableAlias}.${toSnakeCase(column)}`; - }); - }; - - const replaceParams = (sql: string, params: Record): { sql: string; values: unknown[] } => { - const values: unknown[] = []; - let paramIndex = 1; - let result = convertColumnNames(sql); - - for (const [key, value] of Object.entries(params)) { - const placeholder = `:${key}`; - if (result.includes(placeholder)) { - result = result.split(placeholder).join(`$${paramIndex}`); - values.push(value); - paramIndex++; - } - } - - return { sql: result, values }; - }; - - const buildWhereClause = (): { sql: string; values: unknown[] } => { - if (whereClauses.length === 0) { - return { sql: '', values: [] }; - } - - const allParams: Record = {}; - const conditions: string[] = []; - - for (const clause of whereClauses) { - conditions.push(clause.sql); - Object.assign(allParams, clause.params); - } - - const combinedSql = conditions.join(' AND '); - return replaceParams(combinedSql, allParams); - }; - - const qb = { - select: () => { - operation = 'select'; - return qb; - }, - update: (_entity: typeof ChronoTaskEntity) => { - operation = 'update'; - return qb; - }, - delete: () => { - operation = 'delete'; - return qb; - }, - from: (_entity: typeof ChronoTaskEntity, entityAlias?: string) => { - if (entityAlias) alias = entityAlias; - return qb; - }, - where: (condition: string | { getQuery: () => string } | unknown, params?: Record) => { - if (typeof condition === 'function') { - // Handle callback style (direct function) - const bracketsQb = createBracketsQb(); - condition(bracketsQb); - whereClauses.push({ sql: `(${bracketsQb.getSql()})`, params: bracketsQb.getParams() }); - } else if (typeof condition === 'object' && condition !== null && 'getQuery' in condition) { - // Handle Brackets object - it has a callback we need to invoke - const bracketsObj = condition as { getQuery: () => string }; - whereClauses.push({ sql: bracketsObj.getQuery(), params: params || {} }); - } else if (typeof condition === 'string') { - // Remove alias prefix and convert camelCase column names to snake_case - // But don't convert parameter placeholders (starting with :) - let cleanCondition = condition.replace(new RegExp(`${alias}\\.`, 'g'), ''); - // Convert camelCase identifiers to snake_case, but not :param placeholders - cleanCondition = cleanCondition.replace(/(? toSnakeCase(match)); - whereClauses.push({ sql: cleanCondition, params: params || {} }); - } - return qb; - }, - andWhere: (condition: string | { getQuery: () => string } | unknown, params?: Record) => { - // Handle Brackets object from TypeORM (uses 'whereFactory' property) - if (typeof condition === 'object' && condition !== null && 'whereFactory' in condition) { - const bracketsQb = createBracketsQb(); - (condition as { whereFactory: (qb: ReturnType) => void }).whereFactory(bracketsQb); - whereClauses.push({ sql: `(${bracketsQb.getSql()})`, params: bracketsQb.getParams() }); - return qb; - } - return qb.where(condition, params); - }, - orWhere: (condition: string, params?: Record) => { - const cleanCondition = condition.replace(new RegExp(`${alias}\\.`, 'g'), ''); - const lastClause = whereClauses.pop(); - if (lastClause) { - whereClauses.push({ - sql: `(${lastClause.sql} OR ${cleanCondition})`, - params: { ...lastClause.params, ...params }, - }); - } else { - whereClauses.push({ sql: cleanCondition, params: params || {} }); - } - return qb; - }, - set: (values: Record) => { - setValues = values; - return qb; - }, - orderBy: (field: string, direction: 'ASC' | 'DESC' = 'ASC') => { - const cleanField = field.replace(new RegExp(`${alias}\\.`, 'g'), ''); - orderByClauses = [{ field: cleanField, direction }]; - return qb; - }, - addOrderBy: (field: string, direction: 'ASC' | 'DESC' = 'ASC') => { - const cleanField = field.replace(new RegExp(`${alias}\\.`, 'g'), ''); - orderByClauses.push({ field: cleanField, direction }); - return qb; - }, - limit: (_limit: number) => { - // Limit is always 1 in our SQL for getOne - return qb; - }, - returning: (_columns: string) => { - hasReturning = true; - return qb; - }, - setLock: (mode: string, _version?: unknown, _tables?: string[]) => { - lockMode = mode; - return qb; - }, - getOne: async (): Promise => { - const { sql: whereClause, values } = buildWhereClause(); - - let sql = `SELECT * FROM ${TEST_TABLE_NAME}`; - if (whereClause) { - sql += ` WHERE ${whereClause}`; - } - if (orderByClauses.length > 0) { - const orderBy = orderByClauses.map((o) => { - // Convert camelCase to snake_case - const snakeField = o.field.replace(/([A-Z])/g, '_$1').toLowerCase(); - return `${snakeField} ${o.direction}`; - }); - sql += ` ORDER BY ${orderBy.join(', ')}`; - } - sql += ' LIMIT 1'; - if (lockMode) { - sql += ' FOR UPDATE SKIP LOCKED'; - } - - const result = await pglite.query(sql, values); - if (result.rows.length === 0) return null; - return mapRowToEntity(result.rows[0] as Record); - }, - execute: async (): Promise<{ raw: ChronoTaskEntity[]; affected: number }> => { - const { sql: whereClause, values: whereValues } = buildWhereClause(); - - if (operation === 'delete') { - let sql = `DELETE FROM ${TEST_TABLE_NAME}`; - if (whereClause) { - sql += ` WHERE ${whereClause}`; - } - if (hasReturning) { - sql += ' RETURNING *'; - } - - const result = await pglite.query(sql, whereValues); - const entities = (result.rows as Record[]).map(mapRowToEntity); - return { raw: entities, affected: result.rows.length }; - } - - if (operation === 'update') { - const setClauses: string[] = []; - const setParamValues: unknown[] = []; - let paramIndex = whereValues.length + 1; - - for (const [key, value] of Object.entries(setValues)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - if (typeof value === 'function') { - // Raw SQL expression like () => 'NULL' or () => 'retry_count + 1' - const rawValue = value(); - setClauses.push(`${snakeKey} = ${rawValue}`); - } else { - setClauses.push(`${snakeKey} = $${paramIndex}`); - setParamValues.push(value); - paramIndex++; - } - } +export function createMockDataSource(pglite: PGlite) { + const manager = createMockEntityManager(pglite); - let sql = `UPDATE ${TEST_TABLE_NAME} SET ${setClauses.join(', ')}`; - if (whereClause) { - sql += ` WHERE ${whereClause}`; - } - if (hasReturning) { - sql += ' RETURNING *'; - } + return { + /** + * The EntityManager for this DataSource. + */ + manager, - const result = await pglite.query(sql, [...whereValues, ...setParamValues]); - const entities = (result.rows as Record[]).map(mapRowToEntity); - return { raw: entities, affected: result.rows.length }; - } + /** + * Whether the DataSource has been initialized. + */ + isInitialized: true, - return { raw: [], affected: 0 }; + /** + * Closes the DataSource connection. + * No-op for PGlite since we manage its lifecycle separately. + */ + async destroy(): Promise { + // PGlite lifecycle is managed by the test setup }, - }; - return qb; -} + /** + * Creates a QueryBuilder for complex queries. + */ + createQueryBuilder(entity?: typeof ChronoTaskEntity, alias?: string): MockQueryBuilder { + const qb = createMockQueryBuilder(pglite); -/** - * Creates a mock DataSource that wraps PGlite to provide the interface - * expected by ChronoPostgresDatastore with QueryBuilder support - */ -export function createMockDataSource(pglite: PGlite) { - const createQueryBuilder = (entity?: typeof ChronoTaskEntity, entityAlias?: string) => { - const qb = createMockQueryBuilder(pglite, entity); - if (entityAlias && entity) { - qb.from(entity, entityAlias); - } - return qb; - }; - - const manager = { - query: async (sql: string, params?: unknown[]) => { - const result = await pglite.query(sql, params as unknown[]); - return result.rows; - }, - create: (_entity: typeof ChronoTaskEntity, data: Partial) => data, - save: async (_entity: typeof ChronoTaskEntity, data: Partial) => { - const id = faker.string.uuid(); - const now = new Date(); - const result = await pglite.query( - `INSERT INTO ${TEST_TABLE_NAME} - (id, kind, status, data, priority, idempotency_key, original_schedule_date, scheduled_at, retry_count, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING *`, - [ - id, - data.kind, - data.status, - JSON.stringify(data.data), - data.priority ?? 0, - data.idempotencyKey ?? null, - data.originalScheduleDate, - data.scheduledAt, - data.retryCount ?? 0, - now, - now, - ], - ); - return mapRowToEntity(result.rows[0] as Record); - }, - findOne: async (_entity: typeof ChronoTaskEntity, options: { where: Partial }) => { - const where = options.where; - let sql = `SELECT * FROM ${TEST_TABLE_NAME} WHERE 1=1`; - const params: unknown[] = []; - let paramIndex = 1; - - if (where.id) { - sql += ` AND id = $${paramIndex++}`; - params.push(where.id); - } - if (where.idempotencyKey) { - sql += ` AND idempotency_key = $${paramIndex++}`; - params.push(where.idempotencyKey); + if (alias && entity) { + qb.from(entity, alias); } - sql += ' LIMIT 1'; - const result = await pglite.query(sql, params); - if (result.rows.length === 0) return null; - return mapRowToEntity(result.rows[0] as Record); + return qb; }, - createQueryBuilder, - }; - return { - manager, - isInitialized: true, - destroy: async () => {}, - createQueryBuilder, - transaction: async (work: (manager: typeof manager) => Promise): Promise => { - // PGlite doesn't support real transactions in the same way, - // but for testing purposes we can just execute the work + /** + * Executes a callback within a transaction. + * + * Note: PGlite doesn't support true transactions in the same way as + * a real PostgreSQL connection. For testing purposes, we simply + * execute the work directly. This is acceptable because: + * 1. Tests run sequentially within a single test file + * 2. We clean up the database between tests + * 3. We're testing logic, not transaction isolation + */ + async transaction(work: (transactionManager: MockEntityManager) => Promise): Promise { return work(manager); }, }; diff --git a/packages/chrono-postgres-datastore/test/helpers/mock-entity-manager.ts b/packages/chrono-postgres-datastore/test/helpers/mock-entity-manager.ts new file mode 100644 index 0000000..864ec61 --- /dev/null +++ b/packages/chrono-postgres-datastore/test/helpers/mock-entity-manager.ts @@ -0,0 +1,132 @@ +/** + * Mock Entity Manager + * + * Simulates TypeORM's EntityManager interface for testing. + * Provides methods for creating, saving, and finding entities. + */ + +import type { PGlite } from '@electric-sql/pglite'; +import { faker } from '@faker-js/faker'; + +import type { ChronoTaskEntity } from '../../src/chrono-task.entity'; +import { TEST_TABLE_NAME } from '../database-setup'; +import { mapRowToEntity } from './entity-mapper'; +import { createMockQueryBuilder, type MockQueryBuilder } from './mock-query-builder'; + +/** + * Options for the findOne method. + */ +type FindOneOptions = { + where: Partial; +}; + +/** + * Creates a mock EntityManager that executes operations against PGlite. + */ +export function createMockEntityManager(pglite: PGlite) { + return { + /** + * Executes a raw SQL query. + */ + async query(sql: string, params?: unknown[]): Promise { + const result = await pglite.query(sql, params); + return result.rows; + }, + + /** + * Creates an entity instance (doesn't persist it). + * In TypeORM, this initializes default values and sets up the entity. + * For testing, we just return the data as-is. + */ + create(_entityClass: typeof ChronoTaskEntity, data: Partial): Partial { + return data; + }, + + /** + * Saves an entity to the database. + * Generates an ID if not provided and sets timestamps. + */ + async save(_entityClass: typeof ChronoTaskEntity, data: Partial): Promise { + const id = faker.string.uuid(); + const now = new Date(); + + const result = await pglite.query( + `INSERT INTO ${TEST_TABLE_NAME} + (id, kind, status, data, priority, idempotency_key, + original_schedule_date, scheduled_at, retry_count, + created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *`, + [ + id, + data.kind, + data.status, + JSON.stringify(data.data), + data.priority ?? 0, + data.idempotencyKey ?? null, + data.originalScheduleDate, + data.scheduledAt, + data.retryCount ?? 0, + now, + now, + ], + ); + + return mapRowToEntity(result.rows[0] as Record); + }, + + /** + * Finds a single entity matching the given criteria. + * Currently supports filtering by `id` and `idempotencyKey`. + */ + async findOne(_entityClass: typeof ChronoTaskEntity, options: FindOneOptions): Promise { + const { where } = options; + + // Build the query dynamically based on provided filters + const conditions: string[] = ['1=1']; // Start with always-true for easy AND appending + const params: unknown[] = []; + let paramIndex = 1; + + if (where.id) { + conditions.push(`id = $${paramIndex}`); + params.push(where.id); + paramIndex++; + } + + if (where.idempotencyKey) { + conditions.push(`idempotency_key = $${paramIndex}`); + params.push(where.idempotencyKey); + paramIndex++; + } + + const sql = ` + SELECT * FROM ${TEST_TABLE_NAME} + WHERE ${conditions.join(' AND ')} + LIMIT 1 + `; + + const result = await pglite.query(sql, params); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToEntity(result.rows[0] as Record); + }, + + /** + * Creates a QueryBuilder for complex queries. + */ + createQueryBuilder(entity?: typeof ChronoTaskEntity, alias?: string): MockQueryBuilder { + const qb = createMockQueryBuilder(pglite); + + if (alias && entity) { + qb.from(entity, alias); + } + + return qb; + }, + }; +} + +export type MockEntityManager = ReturnType; diff --git a/packages/chrono-postgres-datastore/test/helpers/mock-query-builder.ts b/packages/chrono-postgres-datastore/test/helpers/mock-query-builder.ts new file mode 100644 index 0000000..98df25e --- /dev/null +++ b/packages/chrono-postgres-datastore/test/helpers/mock-query-builder.ts @@ -0,0 +1,360 @@ +/** + * Mock Query Builder + * + * Simulates TypeORM's QueryBuilder interface but executes queries against PGlite. + * This allows testing the datastore without needing a full TypeORM setup. + * + * The QueryBuilder uses a fluent interface where each method returns `this`, + * allowing method chaining like: + * ```ts + * qb.update(Entity).set({ status: 'DONE' }).where('id = :id', { id: '123' }).execute() + * ``` + */ + +import type { PGlite } from '@electric-sql/pglite'; + +import type { ChronoTaskEntity } from '../../src/chrono-task.entity'; +import { TEST_TABLE_NAME } from '../database-setup'; +import { mapRowToEntity } from './entity-mapper'; +import { removeAliasPrefix, toSnakeCase } from './sql-utils'; +import { createBracketsQueryBuilder, WhereClauseCollection } from './where-clause-builder'; + +/** + * The type of SQL operation being built. + */ +type OperationType = 'select' | 'update' | 'delete'; + +/** + * Defines the sort order for a column. + */ +type OrderByClause = { + field: string; + direction: 'ASC' | 'DESC'; +}; + +/** + * The result of executing a query. + */ +export type QueryResult = { + raw: ChronoTaskEntity[]; + affected: number; +}; + +/** + * Creates a mock QueryBuilder that translates TypeORM method calls into + * raw SQL executed against PGlite. + */ +export function createMockQueryBuilder(pglite: PGlite) { + // Query state + let operation: OperationType = 'select'; + let tableAlias = 'entity'; + let setValues: Record = {}; + let orderByClauses: OrderByClause[] = []; + let includeReturning = false; + let lockMode: string | undefined; + + // WHERE clause builder + const whereBuilder = new WhereClauseCollection(tableAlias); + + // The query builder object with all chainable methods + const queryBuilder = { + // ───────────────────────────────────────────────────────────────── + // Operation Type Methods + // ───────────────────────────────────────────────────────────────── + + select() { + operation = 'select'; + return queryBuilder; + }, + + update(_entity: typeof ChronoTaskEntity) { + operation = 'update'; + return queryBuilder; + }, + + delete() { + operation = 'delete'; + return queryBuilder; + }, + + from(_entity: typeof ChronoTaskEntity, alias?: string) { + if (alias) { + tableAlias = alias; + whereBuilder.setAlias(alias); + } + return queryBuilder; + }, + + // ───────────────────────────────────────────────────────────────── + // WHERE Clause Methods + // ───────────────────────────────────────────────────────────────── + + /** + * Adds a WHERE condition. Handles three formats: + * 1. String condition: 'status = :status' + * 2. Callback function: (qb) => qb.where(...) + * 3. Brackets object: new Brackets((qb) => ...) + */ + where(condition: string | { getQuery: () => string } | unknown, params?: Record) { + if (typeof condition === 'function') { + // Direct callback function + const bracketsQb = createBracketsQueryBuilder(tableAlias); + (condition as (qb: ReturnType) => void)(bracketsQb); + whereBuilder.addBracketsCondition(bracketsQb); + } else if (isObjectWithProperty(condition, 'getQuery')) { + // Brackets object with getQuery method + const bracketsObj = condition as { getQuery: () => string }; + whereBuilder.addCondition(bracketsObj.getQuery(), params || {}); + } else if (typeof condition === 'string') { + // Simple string condition + whereBuilder.addCondition(condition, params || {}); + } + + return queryBuilder; + }, + + /** + * Adds an AND WHERE condition. + * Also handles TypeORM Brackets objects (which use 'whereFactory' property). + */ + andWhere(condition: string | { getQuery: () => string } | unknown, params?: Record) { + // TypeORM Brackets objects store their callback in 'whereFactory' + if (isObjectWithProperty(condition, 'whereFactory')) { + const bracketsQb = createBracketsQueryBuilder(tableAlias); + const brackets = condition as { + whereFactory: (qb: ReturnType) => void; + }; + brackets.whereFactory(bracketsQb); + whereBuilder.addBracketsCondition(bracketsQb); + return queryBuilder; + } + + // Otherwise, treat it as a regular where condition + return queryBuilder.where(condition, params); + }, + + /** + * Adds an OR condition to the previous WHERE clause. + */ + orWhere(condition: string, params?: Record) { + whereBuilder.addOrCondition(condition, params || {}); + return queryBuilder; + }, + + // ───────────────────────────────────────────────────────────────── + // UPDATE Methods + // ───────────────────────────────────────────────────────────────── + + /** + * Sets the values to update. + * Values can be primitives or functions that return raw SQL. + */ + set(values: Record) { + setValues = values; + return queryBuilder; + }, + + // ───────────────────────────────────────────────────────────────── + // SELECT Methods + // ───────────────────────────────────────────────────────────────── + + /** + * Sets the primary ORDER BY column (replaces any existing order). + */ + orderBy(field: string, direction: 'ASC' | 'DESC' = 'ASC') { + const cleanField = removeAliasPrefix(field, tableAlias); + orderByClauses = [{ field: cleanField, direction }]; + return queryBuilder; + }, + + /** + * Adds an additional ORDER BY column. + */ + addOrderBy(field: string, direction: 'ASC' | 'DESC' = 'ASC') { + const cleanField = removeAliasPrefix(field, tableAlias); + orderByClauses.push({ field: cleanField, direction }); + return queryBuilder; + }, + + /** + * Sets a LIMIT (stored but always uses LIMIT 1 for getOne). + */ + limit(_limit: number) { + // We always use LIMIT 1 for getOne queries + return queryBuilder; + }, + + /** + * Enables RETURNING * for INSERT/UPDATE/DELETE queries. + */ + returning(_columns: string) { + includeReturning = true; + return queryBuilder; + }, + + /** + * Sets the lock mode for SELECT queries. + */ + setLock(mode: string, _version?: unknown, _tables?: string[]) { + lockMode = mode; + return queryBuilder; + }, + + // ───────────────────────────────────────────────────────────────── + // Execution Methods + // ───────────────────────────────────────────────────────────────── + + /** + * Executes a SELECT query and returns at most one result. + */ + async getOne(): Promise { + const sql = buildSelectSql(); + const { values } = whereBuilder.build(); + + const result = await pglite.query(sql, values); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToEntity(result.rows[0] as Record); + }, + + /** + * Executes the query (UPDATE or DELETE) and returns affected rows. + */ + async execute(): Promise { + if (operation === 'delete') { + return executeDelete(); + } + + if (operation === 'update') { + return executeUpdate(); + } + + return { raw: [], affected: 0 }; + }, + }; + + // ───────────────────────────────────────────────────────────────── + // Helper Functions + // ───────────────────────────────────────────────────────────────── + + /** + * Builds a SELECT SQL statement with all clauses. + */ + function buildSelectSql(): string { + const { sql: whereClause } = whereBuilder.build(); + + let sql = `SELECT * FROM ${TEST_TABLE_NAME}`; + + if (whereClause) { + sql += ` WHERE ${whereClause}`; + } + + if (orderByClauses.length > 0) { + const orderByParts = orderByClauses.map((clause) => { + const snakeField = toSnakeCase(clause.field); + return `${snakeField} ${clause.direction}`; + }); + sql += ` ORDER BY ${orderByParts.join(', ')}`; + } + + sql += ' LIMIT 1'; + + if (lockMode) { + sql += ' FOR UPDATE SKIP LOCKED'; + } + + return sql; + } + + /** + * Executes a DELETE statement. + */ + async function executeDelete(): Promise { + const { sql: whereClause, values: whereValues } = whereBuilder.build(); + + let sql = `DELETE FROM ${TEST_TABLE_NAME}`; + + if (whereClause) { + sql += ` WHERE ${whereClause}`; + } + + if (includeReturning) { + sql += ' RETURNING *'; + } + + const result = await pglite.query(sql, whereValues); + const entities = (result.rows as Record[]).map(mapRowToEntity); + + return { raw: entities, affected: result.rows.length }; + } + + /** + * Executes an UPDATE statement. + */ + async function executeUpdate(): Promise { + const { sql: whereClause, values: whereValues } = whereBuilder.build(); + + // Build SET clause + const { setClauses, setParamValues } = buildSetClause(whereValues.length); + + let sql = `UPDATE ${TEST_TABLE_NAME} SET ${setClauses.join(', ')}`; + + if (whereClause) { + sql += ` WHERE ${whereClause}`; + } + + if (includeReturning) { + sql += ' RETURNING *'; + } + + const allValues = [...whereValues, ...setParamValues]; + const result = await pglite.query(sql, allValues); + const entities = (result.rows as Record[]).map(mapRowToEntity); + + return { raw: entities, affected: result.rows.length }; + } + + /** + * Builds the SET clause for an UPDATE statement. + * Handles both regular values and raw SQL expressions (functions). + */ + function buildSetClause(startParamIndex: number): { + setClauses: string[]; + setParamValues: unknown[]; + } { + const setClauses: string[] = []; + const setParamValues: unknown[] = []; + let paramIndex = startParamIndex + 1; + + for (const [key, value] of Object.entries(setValues)) { + const snakeKey = toSnakeCase(key); + + if (typeof value === 'function') { + // Raw SQL expression, e.g., () => 'NULL' or () => 'retry_count + 1' + const rawSql = value(); + setClauses.push(`${snakeKey} = ${rawSql}`); + } else { + // Regular parameter value + setClauses.push(`${snakeKey} = $${paramIndex}`); + setParamValues.push(value); + paramIndex++; + } + } + + return { setClauses, setParamValues }; + } + + return queryBuilder; +} + +/** + * Type guard to check if a value is an object with a specific property. + */ +function isObjectWithProperty(value: unknown, property: string): boolean { + return typeof value === 'object' && value !== null && property in value; +} + +export type MockQueryBuilder = ReturnType; diff --git a/packages/chrono-postgres-datastore/test/helpers/sql-utils.ts b/packages/chrono-postgres-datastore/test/helpers/sql-utils.ts new file mode 100644 index 0000000..01680d4 --- /dev/null +++ b/packages/chrono-postgres-datastore/test/helpers/sql-utils.ts @@ -0,0 +1,91 @@ +/** + * SQL Utility Functions + * + * Helpers for converting between TypeORM's conventions and raw PostgreSQL. + * TypeORM uses camelCase for column names, but PostgreSQL uses snake_case. + */ + +/** + * Converts a camelCase string to snake_case. + * + * @example + * toSnakeCase('scheduledAt') // returns 'scheduled_at' + * toSnakeCase('idempotencyKey') // returns 'idempotency_key' + */ +export function toSnakeCase(str: string): string { + return str.replace(/([A-Z])/g, '_$1').toLowerCase(); +} + +/** + * Converts camelCase column names in SQL to snake_case. + * Only converts "alias.columnName" patterns, leaving other text untouched. + * + * @example + * convertAliasedColumns('task.scheduledAt <= :now') + * // returns 'task.scheduled_at <= :now' + */ +export function convertAliasedColumns(sql: string): string { + return sql.replace(/(\w+)\.(\w+)/g, (_match, tableAlias, column) => { + return `${tableAlias}.${toSnakeCase(column)}`; + }); +} + +/** + * Converts camelCase identifiers to snake_case, but preserves :param placeholders. + * Uses negative lookbehind to avoid converting parameter names like :scheduledAt. + * + * @example + * convertIdentifiers('scheduledAt = :scheduledAt') + * // returns 'scheduled_at = :scheduledAt' + */ +export function convertCamelCaseIdentifiers(sql: string): string { + // Pattern matches camelCase words (lowercase followed by uppercase) + // Negative lookbehind (? toSnakeCase(match)); +} + +/** + * Removes a table alias prefix from a SQL fragment. + * + * @example + * removeAliasPrefix('task.status = :status', 'task') + * // returns 'status = :status' + */ +export function removeAliasPrefix(sql: string, alias: string): string { + return sql.replace(new RegExp(`${alias}\\.`, 'g'), ''); +} + +/** + * Result of replacing named parameters with positional parameters. + */ +export type ParameterReplacementResult = { + sql: string; + values: unknown[]; +}; + +/** + * Replaces TypeORM-style named parameters (:paramName) with PostgreSQL-style + * positional parameters ($1, $2, etc.). + * + * @example + * replaceNamedParameters('status = :status AND kind = :kind', { status: 'PENDING', kind: 'test' }) + * // returns { sql: 'status = $1 AND kind = $2', values: ['PENDING', 'test'] } + */ +export function replaceNamedParameters(sql: string, params: Record): ParameterReplacementResult { + const values: unknown[] = []; + let paramIndex = 1; + let result = convertAliasedColumns(sql); + + for (const [key, value] of Object.entries(params)) { + const placeholder = `:${key}`; + + if (result.includes(placeholder)) { + // Replace all occurrences of this placeholder + result = result.split(placeholder).join(`$${paramIndex}`); + values.push(value); + paramIndex++; + } + } + + return { sql: result, values }; +} diff --git a/packages/chrono-postgres-datastore/test/helpers/where-clause-builder.ts b/packages/chrono-postgres-datastore/test/helpers/where-clause-builder.ts new file mode 100644 index 0000000..e409240 --- /dev/null +++ b/packages/chrono-postgres-datastore/test/helpers/where-clause-builder.ts @@ -0,0 +1,205 @@ +/** + * WHERE Clause Builder + * + * Builds SQL WHERE clauses from TypeORM-style conditions. + * Handles simple conditions, AND/OR combinations, and nested Brackets. + */ + +import { + convertCamelCaseIdentifiers, + type ParameterReplacementResult, + removeAliasPrefix, + replaceNamedParameters, +} from './sql-utils'; + +/** + * A single WHERE condition with its SQL fragment and parameters. + */ +export type WhereCondition = { + sql: string; + params: Record; +}; + +/** + * A sub-query builder for handling TypeORM Brackets. + * + * TypeORM's Brackets class groups conditions with parentheses: + * ```ts + * new Brackets(qb => { + * qb.where('status = :pending', { pending: 'PENDING' }) + * .orWhere('status = :claimed', { claimed: 'CLAIMED' }) + * }) + * ``` + * + * This creates a mini query builder that collects the conditions + * and can output them as a grouped SQL string. + * + * @param tableAlias - The table alias to remove from column references (e.g., 'task') + */ +export function createBracketsQueryBuilder(tableAlias: string = 'task') { + const conditions: Array<{ + type: 'where' | 'or'; + sql: string; + params: Record; + }> = []; + + /** + * Cleans up a SQL fragment by removing the table alias and converting to snake_case. + */ + function cleanSql(sql: string): string { + let result = removeAliasPrefix(sql, tableAlias); + result = convertCamelCaseIdentifiers(result); + return result; + } + + return { + /** + * Adds a WHERE condition. The first condition added uses this. + */ + where(sql: string, params?: Record) { + conditions.push({ + type: 'where', + sql, + params: params || {}, + }); + return this; + }, + + /** + * Adds an OR condition to the group. + */ + orWhere(sql: string, params?: Record) { + conditions.push({ + type: 'or', + sql, + params: params || {}, + }); + return this; + }, + + /** + * Builds the SQL string for all conditions in this group. + * + * @returns SQL like: "status = :pending OR status = :claimed AND claimed_at <= :staleThreshold" + */ + getSql(): string { + return conditions + .map((condition, index) => { + const cleaned = cleanSql(condition.sql); + + // First condition doesn't need AND/OR prefix + if (index === 0) { + return cleaned; + } + + // Subsequent conditions get their appropriate connector + const connector = condition.type === 'or' ? 'OR' : 'AND'; + return `${connector} ${cleaned}`; + }) + .join(' '); + }, + + /** + * Collects all parameters from all conditions in this group. + */ + getParams(): Record { + const result: Record = {}; + for (const condition of conditions) { + Object.assign(result, condition.params); + } + return result; + }, + }; +} + +/** + * Manages a collection of WHERE clauses and builds the final SQL. + */ +export class WhereClauseCollection { + private clauses: WhereCondition[] = []; + private tableAlias: string; + + constructor(tableAlias: string = 'entity') { + this.tableAlias = tableAlias; + } + + /** + * Updates the table alias used for removing prefixes. + * Call this when the alias changes (e.g., after from() is called). + */ + setAlias(alias: string): void { + this.tableAlias = alias; + } + + /** + * Gets the current table alias. + */ + getAlias(): string { + return this.tableAlias; + } + + /** + * Adds a simple string condition. + */ + addCondition(sql: string, params: Record = {}): void { + // Remove table alias and convert column names to snake_case + let cleanSql = removeAliasPrefix(sql, this.tableAlias); + cleanSql = convertCamelCaseIdentifiers(cleanSql); + + this.clauses.push({ sql: cleanSql, params }); + } + + /** + * Adds a condition from a Brackets query builder. + * Wraps the result in parentheses to maintain grouping. + */ + addBracketsCondition(bracketsQb: ReturnType): void { + this.clauses.push({ + sql: `(${bracketsQb.getSql()})`, + params: bracketsQb.getParams(), + }); + } + + /** + * Adds an OR condition to the previous clause. + * Combines the last clause with the new condition using OR. + */ + addOrCondition(sql: string, params: Record = {}): void { + const cleanSql = removeAliasPrefix(sql, this.tableAlias); + const lastClause = this.clauses.pop(); + + if (lastClause) { + // Combine with previous clause using OR + this.clauses.push({ + sql: `(${lastClause.sql} OR ${cleanSql})`, + params: { ...lastClause.params, ...params }, + }); + } else { + // No previous clause, just add this one + this.clauses.push({ sql: cleanSql, params }); + } + } + + /** + * Builds the final WHERE clause SQL with positional parameters. + * + * @returns Object with the SQL string and array of parameter values + */ + build(): ParameterReplacementResult { + if (this.clauses.length === 0) { + return { sql: '', values: [] }; + } + + // Combine all parameters + const allParams: Record = {}; + for (const clause of this.clauses) { + Object.assign(allParams, clause.params); + } + + // Join clauses with AND + const combinedSql = this.clauses.map((c) => c.sql).join(' AND '); + + // Replace named parameters with positional ones + return replaceNamedParameters(combinedSql, allParams); + } +} From 47c6f0a93abb3daa307167ed8762f57aef07dffe Mon Sep 17 00:00:00 2001 From: James Couch Date: Tue, 27 Jan 2026 18:13:58 -0700 Subject: [PATCH 06/21] add cleanup handling --- .../src/chrono-postgres-datastore.ts | 89 +++++++- .../src/chrono-task.entity.ts | 2 + .../test/helpers/mock-query-builder.ts | 66 +++++- .../unit/chrono-postgres-datastore.test.ts | 191 ++++++++++++++++++ 4 files changed, 334 insertions(+), 14 deletions(-) diff --git a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts index 1ea2c3b..7cc2d98 100644 --- a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts +++ b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts @@ -11,8 +11,23 @@ import { import { Brackets, type DataSource, type EntityManager } from 'typeorm'; import { ChronoTaskEntity } from './chrono-task.entity'; -/** @deprecated Config is no longer used - table name is determined by the entity */ -export type ChronoPostgresDatastoreConfig = Record; +const DEFAULT_TTL_SECONDS = 60 * 60 * 24 * 30; +const DEFAULT_CLEANUP_INTERVAL_SECONDS = 60; +const DEFAULT_CLEANUP_BATCH_SIZE = 100; + +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; +}; export type PostgresDatastoreOptions = { /** @@ -22,11 +37,25 @@ export type PostgresDatastoreOptions = { entityManager?: EntityManager; }; +type ResolvedConfig = Required> & + Pick; + export class ChronoPostgresDatastore implements Datastore { + private config: ResolvedConfig; private dataSource: DataSource | undefined; private dataSourceResolvers: Array<(ds: DataSource) => 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, + }; + } /** * Initializes the datastore with a TypeORM DataSource. @@ -186,7 +215,7 @@ export class ChronoPostgresDatastore const staleThreshold = new Date(now.getTime() - input.claimStaleTimeoutMs); // Use a transaction to atomically select and update - return dataSource.transaction(async (manager) => { + const result = await dataSource.transaction(async (manager) => { // Find and lock the next claimable task const taskToClaimQuery = manager .createQueryBuilder(ChronoTaskEntity, 'task') @@ -227,6 +256,11 @@ export class ChronoPostgresDatastore const [claimedTask] = updateResult.raw as ChronoTaskEntity[]; return claimedTask ? this.toTask(claimedTask) : undefined; }); + + // Opportunistic cleanup runs after claim completes, outside the transaction + this.maybeCleanupCompletedTasks(); + + return result; } async retry( @@ -332,4 +366,53 @@ export class ChronoPostgresDatastore retryCount: entity.retryCount, }; } + + /** + * 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 { + if (!this.dataSource) { + return; + } + + const cutoffDate = new Date(Date.now() - this.config.completedDocumentTTLSeconds * 1000); + + // Two-step cleanup: SELECT with LIMIT, then DELETE by IDs + const tasksToDelete = await this.dataSource + .createQueryBuilder(ChronoTaskEntity, 'task') + .select('task.id') + .where('task.status = :status', { status: TaskStatus.COMPLETED }) + .andWhere('task.completedAt < :cutoffDate', { cutoffDate }) + .limit(this.config.cleanupBatchSize) + .getMany(); + + if (tasksToDelete.length === 0) { + return; + } + + await this.dataSource + .createQueryBuilder() + .delete() + .from(ChronoTaskEntity) + .whereInIds(tasksToDelete.map((t) => t.id)) + .execute(); + } } diff --git a/packages/chrono-postgres-datastore/src/chrono-task.entity.ts b/packages/chrono-postgres-datastore/src/chrono-task.entity.ts index a4550f7..d0dd322 100644 --- a/packages/chrono-postgres-datastore/src/chrono-task.entity.ts +++ b/packages/chrono-postgres-datastore/src/chrono-task.entity.ts @@ -2,6 +2,8 @@ import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, Update @Entity('chrono_tasks') @Index('idx_chrono_tasks_claim', ['kind', 'status', 'scheduledAt', 'priority', 'claimedAt']) +@Index('idx_chrono_tasks_cleanup', ['status', 'completedAt']) +@Index('idx_chrono_tasks_idempotency', ['idempotencyKey'], { unique: true, where: '"idempotency_key" IS NOT NULL' }) export class ChronoTaskEntity { @PrimaryGeneratedColumn('uuid') id!: string; diff --git a/packages/chrono-postgres-datastore/test/helpers/mock-query-builder.ts b/packages/chrono-postgres-datastore/test/helpers/mock-query-builder.ts index 98df25e..297a420 100644 --- a/packages/chrono-postgres-datastore/test/helpers/mock-query-builder.ts +++ b/packages/chrono-postgres-datastore/test/helpers/mock-query-builder.ts @@ -52,6 +52,9 @@ export function createMockQueryBuilder(pglite: PGlite) { let orderByClauses: OrderByClause[] = []; let includeReturning = false; let lockMode: string | undefined; + let limitValue: number | undefined; + let selectColumns: string[] = ['*']; + let whereInIdsValues: string[] = []; // WHERE clause builder const whereBuilder = new WhereClauseCollection(tableAlias); @@ -62,8 +65,11 @@ export function createMockQueryBuilder(pglite: PGlite) { // Operation Type Methods // ───────────────────────────────────────────────────────────────── - select() { + select(columns?: string | string[]) { operation = 'select'; + if (columns) { + selectColumns = Array.isArray(columns) ? columns : [columns]; + } return queryBuilder; }, @@ -141,6 +147,14 @@ export function createMockQueryBuilder(pglite: PGlite) { return queryBuilder; }, + /** + * Adds a WHERE IN condition for IDs. + */ + whereInIds(ids: string[]) { + whereInIdsValues = ids; + return queryBuilder; + }, + // ───────────────────────────────────────────────────────────────── // UPDATE Methods // ───────────────────────────────────────────────────────────────── @@ -177,10 +191,10 @@ export function createMockQueryBuilder(pglite: PGlite) { }, /** - * Sets a LIMIT (stored but always uses LIMIT 1 for getOne). + * Sets a LIMIT for the query. */ - limit(_limit: number) { - // We always use LIMIT 1 for getOne queries + limit(limit: number) { + limitValue = limit; return queryBuilder; }, @@ -208,7 +222,7 @@ export function createMockQueryBuilder(pglite: PGlite) { * Executes a SELECT query and returns at most one result. */ async getOne(): Promise { - const sql = buildSelectSql(); + const sql = buildSelectSql(1); const { values } = whereBuilder.build(); const result = await pglite.query(sql, values); @@ -220,6 +234,18 @@ export function createMockQueryBuilder(pglite: PGlite) { return mapRowToEntity(result.rows[0] as Record); }, + /** + * Executes a SELECT query and returns all matching results (respects limit). + */ + async getMany(): Promise { + const sql = buildSelectSql(limitValue); + const { values } = whereBuilder.build(); + + const result = await pglite.query(sql, values); + + return (result.rows as Record[]).map(mapRowToEntity); + }, + /** * Executes the query (UPDATE or DELETE) and returns affected rows. */ @@ -243,10 +269,20 @@ export function createMockQueryBuilder(pglite: PGlite) { /** * Builds a SELECT SQL statement with all clauses. */ - function buildSelectSql(): string { + function buildSelectSql(limit?: number): string { const { sql: whereClause } = whereBuilder.build(); - let sql = `SELECT * FROM ${TEST_TABLE_NAME}`; + // Build column list from selectColumns + const columns = selectColumns + .map((col) => { + if (col === '*') return '*'; + // Remove alias prefix (e.g., 'task.id' -> 'id') + const cleanCol = removeAliasPrefix(col, tableAlias); + return toSnakeCase(cleanCol); + }) + .join(', '); + + let sql = `SELECT ${columns} FROM ${TEST_TABLE_NAME}`; if (whereClause) { sql += ` WHERE ${whereClause}`; @@ -260,7 +296,9 @@ export function createMockQueryBuilder(pglite: PGlite) { sql += ` ORDER BY ${orderByParts.join(', ')}`; } - sql += ' LIMIT 1'; + if (limit !== undefined) { + sql += ` LIMIT ${limit}`; + } if (lockMode) { sql += ' FOR UPDATE SKIP LOCKED'; @@ -276,8 +314,14 @@ export function createMockQueryBuilder(pglite: PGlite) { const { sql: whereClause, values: whereValues } = whereBuilder.build(); let sql = `DELETE FROM ${TEST_TABLE_NAME}`; - - if (whereClause) { + let values = whereValues; + + if (whereInIdsValues.length > 0) { + // Handle whereInIds - create parameterized IN clause + const placeholders = whereInIdsValues.map((_, i) => `$${i + 1}`).join(', '); + sql += ` WHERE id IN (${placeholders})`; + values = whereInIdsValues; + } else if (whereClause) { sql += ` WHERE ${whereClause}`; } @@ -285,7 +329,7 @@ export function createMockQueryBuilder(pglite: PGlite) { sql += ' RETURNING *'; } - const result = await pglite.query(sql, whereValues); + const result = await pglite.query(sql, values); const entities = (result.rows as Record[]).map(mapRowToEntity); return { raw: entities, affected: result.rows.length }; diff --git a/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts b/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts index 50e551f..4149da3 100644 --- a/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts +++ b/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts @@ -457,4 +457,195 @@ describe('ChronoPostgresDatastore', () => { expect(entity.name).toBe('ChronoTaskEntity'); }); }); + + describe('cleanup', () => { + // Helper to wait for fire-and-forget cleanup to complete + const waitForCleanup = () => new Promise((resolve) => setTimeout(resolve, 50)); + + test('should delete completed tasks older than TTL after claim', async () => { + const ds = new ChronoPostgresDatastore({ + completedDocumentTTLSeconds: 1, + cleanupIntervalSeconds: 0, + }); + await ds.initialize(mockDataSource as unknown as Parameters[0]); + + // Create a task and complete it + const task = await ds.schedule({ + kind: 'test', + data: { test: 'test' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + await ds.complete(task.id); + + // Backdate completed_at to be older than TTL + await pglite.exec( + `UPDATE ${TEST_TABLE_NAME} SET completed_at = NOW() - INTERVAL '2 seconds' WHERE id = '${task.id}'`, + ); + + // Trigger cleanup via claim + await ds.claim({ kind: 'test', claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS }); + await waitForCleanup(); + + const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task.id]); + expect(result.rows.length).toBe(0); + }); + + test('should not delete completed tasks newer than TTL', async () => { + const ds = new ChronoPostgresDatastore({ + completedDocumentTTLSeconds: 3600, + cleanupIntervalSeconds: 0, + }); + await ds.initialize(mockDataSource as unknown as Parameters[0]); + + const task = await ds.schedule({ + kind: 'test', + data: { test: 'test' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + await ds.complete(task.id); + + // Trigger cleanup via claim - task should NOT be deleted (completed just now) + await ds.claim({ kind: 'test', claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS }); + await waitForCleanup(); + + const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task.id]); + expect(result.rows.length).toBe(1); + }); + + test('should respect cleanup interval', async () => { + const ds = new ChronoPostgresDatastore({ + completedDocumentTTLSeconds: 1, + cleanupIntervalSeconds: 3600, + }); + await ds.initialize(mockDataSource as unknown as Parameters[0]); + + // Create and complete first task + const task1 = await ds.schedule({ + kind: 'test', + data: { test: 'test1' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + await ds.complete(task1.id); + + // Backdate to be older than TTL + await pglite.exec( + `UPDATE ${TEST_TABLE_NAME} SET completed_at = NOW() - INTERVAL '2 seconds' WHERE id = '${task1.id}'`, + ); + + // First claim triggers cleanup (interval starts at epoch) + await ds.claim({ kind: 'test', claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS }); + await waitForCleanup(); + + const result1 = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task1.id]); + expect(result1.rows.length).toBe(0); + + // Create and complete second task + const task2 = await ds.schedule({ + kind: 'test', + data: { test: 'test2' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + await ds.complete(task2.id); + + // Backdate to be older than TTL + await pglite.exec( + `UPDATE ${TEST_TABLE_NAME} SET completed_at = NOW() - INTERVAL '2 seconds' WHERE id = '${task2.id}'`, + ); + + // Second claim should NOT trigger cleanup (interval not passed) + await ds.claim({ kind: 'test', claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS }); + await waitForCleanup(); + + // task2 should still exist because cleanup interval hasn't passed + const result2 = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task2.id]); + expect(result2.rows.length).toBe(1); + }); + + test('should call onCleanupError when cleanup fails', async () => { + const onCleanupError = vi.fn(); + + // Create a datasource that works for claim but fails during cleanup's SELECT + const failingCleanupDataSource = { + ...mockDataSource, + createQueryBuilder: (...args: unknown[]) => { + const qb = mockDataSource.createQueryBuilder(...args); + // If called with entity and alias (cleanup SELECT), make getMany fail + if (args.length === 2) { + return { + ...qb, + select: () => ({ + where: () => ({ + andWhere: () => ({ + limit: () => ({ + getMany: () => Promise.reject(new Error('Cleanup failed')), + }), + }), + }), + }), + }; + } + return qb; + }, + }; + + const ds = new ChronoPostgresDatastore({ + cleanupIntervalSeconds: 0, + onCleanupError, + }); + await ds.initialize(failingCleanupDataSource as unknown as Parameters[0]); + + // Schedule task using the main datastore (shares same pglite) + await dataStore.schedule({ + kind: 'test', + data: { test: 'test' }, + priority: 1, + when: new Date(Date.now() - 1000), + }); + + // Trigger cleanup via claim + await ds.claim({ kind: 'test', claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS }); + await waitForCleanup(); + + expect(onCleanupError).toHaveBeenCalledWith(expect.any(Error)); + expect((onCleanupError.mock.calls[0][0] as Error).message).toBe('Cleanup failed'); + }); + + test('should respect cleanup batch size', async () => { + const ds = new ChronoPostgresDatastore({ + completedDocumentTTLSeconds: 1, + cleanupIntervalSeconds: 0, + cleanupBatchSize: 2, + }); + await ds.initialize(mockDataSource as unknown as Parameters[0]); + + // Create and complete 3 tasks + const tasks = await Promise.all( + [1, 2, 3].map((i) => + ds.schedule({ + kind: 'test', + data: { test: `test${i}` }, + priority: 1, + when: new Date(Date.now() - 1000), + }), + ), + ); + await Promise.all(tasks.map((t) => ds.complete(t.id))); + + // Backdate all tasks to be older than TTL + await pglite.exec( + `UPDATE ${TEST_TABLE_NAME} SET completed_at = NOW() - INTERVAL '2 seconds' WHERE status = '${TaskStatus.COMPLETED}'`, + ); + + // Trigger cleanup - should only delete 2 tasks (batch size) + await ds.claim({ kind: 'test', claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS }); + await waitForCleanup(); + + const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE status = $1`, [TaskStatus.COMPLETED]); + expect(result.rows.length).toBe(1); + }); + }); }); From c7b8ca4a6b79a42d7cd2b5e1b92839a281c1ab5e Mon Sep 17 00:00:00 2001 From: James Couch Date: Tue, 27 Jan 2026 18:52:19 -0700 Subject: [PATCH 07/21] Replace mock-based tests with real Postgres tests - Remove PGlite and mock helpers in favor of real Postgres integration tests - Fix RETURNING clause mapping: raw PostgreSQL rows use snake_case columns, added fromRaw() helper to convert to camelCase entity properties - Add CI workflow with Postgres 15 service container - Add testAccessor Symbol for testing query generation - Fix setLock syntax from array-based to method chaining Co-Authored-By: Claude (claude-4-opus) --- .github/workflows/ci.yml | 17 + .../chrono-postgres-datastore/package.json | 2 - .../src/chrono-postgres-datastore.ts | 83 ++- .../test/chrono-postgres-datastore.test.ts | 613 +++++++++++++++++ .../test/database-setup.ts | 117 ---- .../test/helpers/entity-mapper.ts | 64 -- .../test/helpers/index.ts | 44 -- .../test/helpers/mock-datasource.ts | 81 --- .../test/helpers/mock-entity-manager.ts | 132 ---- .../test/helpers/mock-query-builder.ts | 404 ----------- .../test/helpers/sql-utils.ts | 91 --- .../test/helpers/where-clause-builder.ts | 205 ------ .../unit/chrono-postgres-datastore.test.ts | 651 ------------------ .../vitest.config.ts | 9 +- pnpm-lock.yaml | 11 - 15 files changed, 697 insertions(+), 1827 deletions(-) create mode 100644 packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts delete mode 100644 packages/chrono-postgres-datastore/test/database-setup.ts delete mode 100644 packages/chrono-postgres-datastore/test/helpers/entity-mapper.ts delete mode 100644 packages/chrono-postgres-datastore/test/helpers/index.ts delete mode 100644 packages/chrono-postgres-datastore/test/helpers/mock-datasource.ts delete mode 100644 packages/chrono-postgres-datastore/test/helpers/mock-entity-manager.ts delete mode 100644 packages/chrono-postgres-datastore/test/helpers/mock-query-builder.ts delete mode 100644 packages/chrono-postgres-datastore/test/helpers/sql-utils.ts delete mode 100644 packages/chrono-postgres-datastore/test/helpers/where-clause-builder.ts delete mode 100644 packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 317ab6e..2a060bd 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:15 + 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-postgres-datastore/package.json b/packages/chrono-postgres-datastore/package.json index 92efd3f..3aabcea 100644 --- a/packages/chrono-postgres-datastore/package.json +++ b/packages/chrono-postgres-datastore/package.json @@ -40,8 +40,6 @@ "test": "NODE_ENV=test TZ=UTC vitest run" }, "devDependencies": { - "@electric-sql/pglite": "^0.2.0", - "@faker-js/faker": "^9.0.0", "@neofinancial/chrono": "workspace:*", "pg": "^8.13.1", "typeorm": "^0.3.20" diff --git a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts index 7cc2d98..83fb0cc 100644 --- a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts +++ b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts @@ -15,6 +15,9 @@ const DEFAULT_TTL_SECONDS = 60 * 60 * 24 * 30; const DEFAULT_CLEANUP_INTERVAL_SECONDS = 60; const DEFAULT_CLEANUP_BATCH_SIZE = 100; +/** @internal Symbol key for test-only access to query builders */ +export const testAccessor = Symbol('testAccessor'); + export type ChronoPostgresDatastoreConfig = { /** TTL (in seconds) for completed tasks. Tasks older than this are deleted during cleanup. */ completedDocumentTTLSeconds?: number; @@ -185,10 +188,10 @@ export class ChronoPostgresDatastore } const result = await qb.execute(); - const [row] = result.raw as ChronoTaskEntity[]; + const [rawRow] = result.raw as Record[]; - if (row) { - return this.toTask(row); + if (rawRow) { + return this.toTask(this.fromRaw(rawRow)); } if (options?.force) { @@ -232,7 +235,8 @@ export class ChronoPostgresDatastore .orderBy('task.priority', 'DESC') .addOrderBy('task.scheduledAt', 'ASC') .limit(1) - .setLock('pessimistic_write', undefined, ['skipLocked']); + .setLock('pessimistic_write') + .setOnLocked('skip_locked'); const taskToClaim = await taskToClaimQuery.getOne(); @@ -253,8 +257,8 @@ export class ChronoPostgresDatastore .returning('*') .execute(); - const [claimedTask] = updateResult.raw as ChronoTaskEntity[]; - return claimedTask ? this.toTask(claimedTask) : undefined; + const [rawRow] = updateResult.raw as Record[]; + return rawRow ? this.toTask(this.fromRaw(rawRow)) : undefined; }); // Opportunistic cleanup runs after claim completes, outside the transaction @@ -328,14 +332,14 @@ export class ChronoPostgresDatastore } private extractUpdatedTaskOrThrow( - raw: ChronoTaskEntity[], + raw: Record[], taskId: string, ): Task { - const [entity] = raw; - if (!entity) { + const [rawRow] = raw; + if (!rawRow) { throw new Error(`Task with ID ${taskId} not found`); } - return this.toTask(entity); + return this.toTask(this.fromRaw(rawRow)); } /** @@ -347,6 +351,29 @@ export class ChronoPostgresDatastore return isErrorObject && 'code' in error && error.code === PG_UNIQUE_VIOLATION; } + /** + * Maps a raw PostgreSQL row (snake_case columns) to an entity-like object (camelCase properties). + * TypeORM's RETURNING clause returns raw rows without column name mapping. + */ + private fromRaw(raw: Record): ChronoTaskEntity { + return { + id: raw.id as string, + kind: raw.kind as string, + status: raw.status as string, + data: raw.data as Record, + priority: raw.priority as number | null, + idempotencyKey: raw.idempotency_key as string | null, + originalScheduleDate: raw.original_schedule_date as Date, + scheduledAt: raw.scheduled_at as Date, + claimedAt: raw.claimed_at as Date | null, + completedAt: raw.completed_at as Date | null, + lastExecutedAt: raw.last_executed_at as Date | null, + retryCount: raw.retry_count as number, + createdAt: raw.created_at as Date, + updatedAt: raw.updated_at as Date, + }; + } + /** * Converts a ChronoTaskEntity to a Task object. */ @@ -395,24 +422,32 @@ export class ChronoPostgresDatastore const cutoffDate = new Date(Date.now() - this.config.completedDocumentTTLSeconds * 1000); - // Two-step cleanup: SELECT with LIMIT, then DELETE by IDs - const tasksToDelete = await this.dataSource - .createQueryBuilder(ChronoTaskEntity, 'task') - .select('task.id') - .where('task.status = :status', { status: TaskStatus.COMPLETED }) - .andWhere('task.completedAt < :cutoffDate', { cutoffDate }) - .limit(this.config.cleanupBatchSize) - .getMany(); + const tasksToDelete = await this.buildCleanupSelectQuery(cutoffDate).getMany(); if (tasksToDelete.length === 0) { return; } - await this.dataSource - .createQueryBuilder() - .delete() - .from(ChronoTaskEntity) - .whereInIds(tasksToDelete.map((t) => t.id)) - .execute(); + await this.buildCleanupDeleteQuery(tasksToDelete.map((t) => t.id)).execute(); + } + + private buildCleanupSelectQuery(cutoffDate: Date) { + return this.dataSource!.createQueryBuilder(ChronoTaskEntity, 'task') + .select('task.id') + .where('task.status = :status', { status: TaskStatus.COMPLETED }) + .andWhere('task.completedAt < :cutoffDate', { cutoffDate }) + .limit(this.config.cleanupBatchSize); + } + + private buildCleanupDeleteQuery(ids: string[]) { + return this.dataSource!.createQueryBuilder().delete().from(ChronoTaskEntity).whereInIds(ids); + } + + /** @internal Exposed for testing query generation only */ + get [testAccessor]() { + return { + buildCleanupSelectQuery: (cutoffDate: Date) => this.buildCleanupSelectQuery(cutoffDate), + buildCleanupDeleteQuery: (ids: string[]) => this.buildCleanupDeleteQuery(ids), + }; } } 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..41e0520 --- /dev/null +++ b/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts @@ -0,0 +1,613 @@ +import { TaskStatus } from '@neofinancial/chrono'; +import { DataSource } from 'typeorm'; +import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { ChronoPostgresDatastore, testAccessor } from '../src/chrono-postgres-datastore'; +import { ChronoTaskEntity } from '../src/chrono-task.entity'; + +const DATABASE_URL = process.env.DATABASE_URL; + +type TaskMapping = { + test: { value: string }; + other: { data: number }; +}; + +describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { + let dataSource: DataSource; + let dataStore: ChronoPostgresDatastore; + + beforeAll(async () => { + dataSource = new DataSource({ + type: 'postgres', + url: DATABASE_URL!, + entities: [ChronoTaskEntity], + synchronize: true, + dropSchema: true, + }); + await dataSource.initialize(); + + dataStore = new ChronoPostgresDatastore(); + await dataStore.initialize(dataSource); + }); + + afterAll(async () => { + await dataSource?.destroy(); + }); + + beforeEach(async () => { + await dataSource.getRepository(ChronoTaskEntity).clear(); + }); + + describe('initialize', () => { + test('throws if already initialized', async () => { + const ds = new ChronoPostgresDatastore(); + await ds.initialize(dataSource); + + await expect(ds.initialize(dataSource)).rejects.toThrow('DataSource already initialized'); + }); + }); + + 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 found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task.id }); + expect(found).not.toBeNull(); + expect(found!.kind).toBe('test'); + expect(found!.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 }); + }); + }); + + 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 dataSource.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(); + }); + }); + + 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`); + }); + }); + + 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 found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task.id }); + expect(found).toBeNull(); + }); + + 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(); + }); + }); + + describe('cleanup', () => { + const createDataStoreWithConfig = async (config: Parameters[0]) => { + const ds = new ChronoPostgresDatastore(config); + await ds.initialize(dataSource); + 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 dataSource.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 found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task.id }); + expect(found).toBeNull(); + }); + + 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 found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task.id }); + expect(found).not.toBeNull(); + }); + + 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 dataSource.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(); + + expect(await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task1.id })).toBeNull(); + + // 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 dataSource.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(); + + expect(await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task2.id })).not.toBeNull(); + }); + + 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 dataSource.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 remaining = await dataSource.getRepository(ChronoTaskEntity).count(); + expect(remaining).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); + }); + }); + + describe('entity metadata', () => { + test('table name is chrono_tasks', () => { + const metadata = dataSource.getMetadata(ChronoTaskEntity); + expect(metadata.tableName).toBe('chrono_tasks'); + }); + + test('column mappings are correct', () => { + const metadata = dataSource.getMetadata(ChronoTaskEntity); + + const columnMappings: Record = { + completedAt: 'completed_at', + scheduledAt: 'scheduled_at', + claimedAt: 'claimed_at', + idempotencyKey: 'idempotency_key', + originalScheduleDate: 'original_schedule_date', + lastExecutedAt: 'last_executed_at', + retryCount: 'retry_count', + createdAt: 'created_at', + updatedAt: 'updated_at', + }; + + for (const [property, dbColumn] of Object.entries(columnMappings)) { + const column = metadata.findColumnWithPropertyName(property); + expect(column?.databaseName, `${property} should map to ${dbColumn}`).toBe(dbColumn); + } + }); + + test('indexes exist', () => { + const metadata = dataSource.getMetadata(ChronoTaskEntity); + const indexNames = metadata.indices.map((i) => i.name); + + expect(indexNames).toContain('idx_chrono_tasks_claim'); + expect(indexNames).toContain('idx_chrono_tasks_cleanup'); + expect(indexNames).toContain('idx_chrono_tasks_idempotency'); + }); + }); + + describe('query generation', () => { + const getTestAccessor = (ds: ChronoPostgresDatastore) => ds[testAccessor]; + + test('cleanup SELECT query uses correct column names', () => { + const accessor = getTestAccessor(dataStore); + const qb = accessor.buildCleanupSelectQuery(new Date()); + const sql = qb.getQuery(); + + expect(sql).toContain('"task"."id"'); + expect(sql).toContain('"task"."status"'); + expect(sql).toContain('"task"."completed_at"'); + }); + + test('cleanup DELETE query targets correct table', () => { + const accessor = getTestAccessor(dataStore); + const qb = accessor.buildCleanupDeleteQuery(['id1', 'id2']); + const sql = qb.getQuery(); + + expect(sql).toContain('DELETE'); + expect(sql).toContain('"chrono_tasks"'); + }); + }); + + describe('getEntity', () => { + test('returns ChronoTaskEntity class', () => { + const entity = ChronoPostgresDatastore.getEntity(); + expect(entity).toBe(ChronoTaskEntity); + }); + }); +}); diff --git a/packages/chrono-postgres-datastore/test/database-setup.ts b/packages/chrono-postgres-datastore/test/database-setup.ts deleted file mode 100644 index c076661..0000000 --- a/packages/chrono-postgres-datastore/test/database-setup.ts +++ /dev/null @@ -1,117 +0,0 @@ -/* eslint-disable no-console */ -import { PGlite } from '@electric-sql/pglite'; -import { DataSource } from 'typeorm'; - -import { ChronoTaskEntity } from '../src/chrono-task.entity'; - -const formatMemoryUsage = (data: number) => `${Math.round((data / 1024 / 1024) * 100) / 100} MB`; - -export const TEST_TABLE_NAME = 'chrono_tasks'; - -// Global PGlite instance for tests -let pglite: PGlite | undefined; -let dataSource: DataSource | undefined; - -/** - * Creates a TypeORM-compatible DataSource using PGlite - */ -export async function createPGliteDataSource(): Promise { - if (!pglite) { - throw new Error('PGlite not initialized. Call setup() first.'); - } - - // Create a new DataSource that uses PGlite - // We use the 'postgres' type but provide a custom driver - const ds = new DataSource({ - type: 'postgres', - driver: pglite, - entities: [ChronoTaskEntity], - synchronize: true, - logging: false, - } as any); - - await ds.initialize(); - return ds; -} - -/** - * Gets the shared PGlite instance - */ -export function getPGlite(): PGlite { - if (!pglite) { - throw new Error('PGlite not initialized. Call setup() first.'); - } - return pglite; -} - -/** - * @remarks - * https://vitest.dev/config/#globalsetup - */ -export async function setup(): Promise { - const { heapTotal: heapTotalBefore, heapUsed: heapUsedBefore } = process.memoryUsage(); - - // Create in-memory PGlite instance - pglite = new PGlite(); - - console.table({ - database: 'PGlite (in-memory)', - heapTotal: formatMemoryUsage(heapTotalBefore), - heapUsed: formatMemoryUsage(heapUsedBefore), - }); - - // Create the table schema directly using PGlite - await pglite.exec(` - CREATE TABLE IF NOT EXISTS ${TEST_TABLE_NAME} ( - 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 TIMESTAMP WITH TIME ZONE NOT NULL, - scheduled_at TIMESTAMP WITH TIME ZONE NOT NULL, - claimed_at TIMESTAMP WITH TIME ZONE, - completed_at TIMESTAMP WITH TIME ZONE, - last_executed_at TIMESTAMP WITH TIME ZONE, - retry_count INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() - ) - `); - - // Create indexes - await pglite.exec(` - CREATE UNIQUE INDEX IF NOT EXISTS idx_chrono_tasks_idempotency_key - ON ${TEST_TABLE_NAME} (idempotency_key) - WHERE idempotency_key IS NOT NULL - `); - - await pglite.exec(` - CREATE INDEX IF NOT EXISTS idx_chrono_tasks_claim - ON ${TEST_TABLE_NAME} (kind, status, scheduled_at ASC, priority DESC, claimed_at) - `); - - console.log('PGlite initialized with chrono_tasks table'); -} - -/** - * @remarks - * https://vitest.dev/config/#globalsetup - */ -export async function teardown(): Promise { - const { heapTotal: heapTotalAfter, heapUsed: heapUsedAfter } = process.memoryUsage(); - - if (dataSource?.isInitialized) { - await dataSource.destroy(); - } - - if (pglite) { - await pglite.close(); - } - - console.log({ - heapTotal: formatMemoryUsage(heapTotalAfter), - heapUsed: formatMemoryUsage(heapUsedAfter), - }); -} diff --git a/packages/chrono-postgres-datastore/test/helpers/entity-mapper.ts b/packages/chrono-postgres-datastore/test/helpers/entity-mapper.ts deleted file mode 100644 index 8b3dc75..0000000 --- a/packages/chrono-postgres-datastore/test/helpers/entity-mapper.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Entity Mapper - * - * Converts raw PostgreSQL rows (with snake_case columns) to - * ChronoTaskEntity objects (with camelCase properties). - */ - -import type { ChronoTaskEntity } from '../../src/chrono-task.entity'; - -/** - * A raw database row with snake_case column names. - */ -type DatabaseRow = Record; - -/** - * Safely parses a value as a Date, or returns null if the value is falsy. - */ -function parseDate(value: unknown): Date | null { - if (!value) return null; - return new Date(value as string); -} - -/** - * Parses the JSON data field, handling both string and object formats. - * PGlite may return JSONB as either a string or an already-parsed object. - */ -function parseJsonData(data: unknown): Record { - if (typeof data === 'string') { - return JSON.parse(data); - } - return data as Record; -} - -/** - * Maps a raw PostgreSQL row to a ChronoTaskEntity object. - * - * This handles the conversion from: - * - snake_case column names to camelCase property names - * - String dates to Date objects - * - JSONB data that may be a string or object - * - * @example - * const row = { id: '123', scheduled_at: '2024-01-01', ... }; - * const entity = mapRowToEntity(row); - * // entity.scheduledAt is now a Date object - */ -export function mapRowToEntity(row: DatabaseRow): ChronoTaskEntity { - return { - id: row.id as string, - kind: row.kind as string, - status: row.status as string, - data: parseJsonData(row.data), - priority: row.priority as number | null, - idempotencyKey: row.idempotency_key as string | null, - originalScheduleDate: new Date(row.original_schedule_date as string), - scheduledAt: new Date(row.scheduled_at as string), - claimedAt: parseDate(row.claimed_at), - completedAt: parseDate(row.completed_at), - lastExecutedAt: parseDate(row.last_executed_at), - retryCount: row.retry_count as number, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - } as ChronoTaskEntity; -} diff --git a/packages/chrono-postgres-datastore/test/helpers/index.ts b/packages/chrono-postgres-datastore/test/helpers/index.ts deleted file mode 100644 index 7a51657..0000000 --- a/packages/chrono-postgres-datastore/test/helpers/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Test Helpers - * - * This module provides mock implementations of TypeORM's DataSource and - * related classes for testing ChronoPostgresDatastore without a real database. - * - * Architecture: - * ┌─────────────────────────────────────────────────────────────────┐ - * │ MockDataSource │ - * │ - Main entry point for tests │ - * │ - Provides manager, createQueryBuilder, transaction │ - * └─────────────────────────────────────────────────────────────────┘ - * │ - * ┌──────────────────┼──────────────────┐ - * ▼ ▼ - * ┌─────────────────────┐ ┌─────────────────────┐ - * │ MockEntityManager │ │ MockQueryBuilder │ - * │ - create, save │ │ - where, andWhere │ - * │ - findOne, query │ │ - set, orderBy │ - * └─────────────────────┘ │ - getOne, execute │ - * └─────────────────────┘ - * │ - * ▼ - * ┌─────────────────────┐ - * │ WhereClauseBuilder │ - * │ - Builds WHERE SQL │ - * │ - Handles Brackets │ - * └─────────────────────┘ - * │ - * ┌─────────────────────────────────────┼─────────────────┐ - * ▼ ▼ ▼ - * ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ - * │ SQL Utils │ │ Entity Mapper │ │ Database Setup │ - * │ - toSnakeCase │ │ - mapRowToEntity│ │ - TEST_TABLE │ - * │ - replaceParams │ └─────────────────┘ └─────────────────┘ - * └─────────────────┘ - */ - -export { mapRowToEntity } from './entity-mapper'; -// Main export - this is what tests should import -export { createMockDataSource, type MockDataSource } from './mock-datasource'; -// Lower-level exports for advanced usage or extension -export { createMockEntityManager, type MockEntityManager } from './mock-entity-manager'; -export { createMockQueryBuilder, type MockQueryBuilder } from './mock-query-builder'; diff --git a/packages/chrono-postgres-datastore/test/helpers/mock-datasource.ts b/packages/chrono-postgres-datastore/test/helpers/mock-datasource.ts deleted file mode 100644 index 1d00c9b..0000000 --- a/packages/chrono-postgres-datastore/test/helpers/mock-datasource.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Mock DataSource - * - * Simulates TypeORM's DataSource interface for testing ChronoPostgresDatastore. - * This is the main entry point for test mocking - it provides everything needed - * to test the datastore without a real PostgreSQL connection. - * - * Usage: - * ```ts - * const pglite = new PGlite(); - * const mockDataSource = createMockDataSource(pglite); - * await dataStore.initialize(mockDataSource); - * ``` - */ - -import type { PGlite } from '@electric-sql/pglite'; - -import type { ChronoTaskEntity } from '../../src/chrono-task.entity'; -import { createMockEntityManager, type MockEntityManager } from './mock-entity-manager'; -import { createMockQueryBuilder, type MockQueryBuilder } from './mock-query-builder'; - -/** - * Creates a mock DataSource that wraps PGlite. - * - * The DataSource provides: - * - `manager`: An EntityManager for CRUD operations - * - `createQueryBuilder`: For complex queries - * - `transaction`: For wrapping operations in a transaction - */ -export function createMockDataSource(pglite: PGlite) { - const manager = createMockEntityManager(pglite); - - return { - /** - * The EntityManager for this DataSource. - */ - manager, - - /** - * Whether the DataSource has been initialized. - */ - isInitialized: true, - - /** - * Closes the DataSource connection. - * No-op for PGlite since we manage its lifecycle separately. - */ - async destroy(): Promise { - // PGlite lifecycle is managed by the test setup - }, - - /** - * Creates a QueryBuilder for complex queries. - */ - createQueryBuilder(entity?: typeof ChronoTaskEntity, alias?: string): MockQueryBuilder { - const qb = createMockQueryBuilder(pglite); - - if (alias && entity) { - qb.from(entity, alias); - } - - return qb; - }, - - /** - * Executes a callback within a transaction. - * - * Note: PGlite doesn't support true transactions in the same way as - * a real PostgreSQL connection. For testing purposes, we simply - * execute the work directly. This is acceptable because: - * 1. Tests run sequentially within a single test file - * 2. We clean up the database between tests - * 3. We're testing logic, not transaction isolation - */ - async transaction(work: (transactionManager: MockEntityManager) => Promise): Promise { - return work(manager); - }, - }; -} - -export type MockDataSource = ReturnType; diff --git a/packages/chrono-postgres-datastore/test/helpers/mock-entity-manager.ts b/packages/chrono-postgres-datastore/test/helpers/mock-entity-manager.ts deleted file mode 100644 index 864ec61..0000000 --- a/packages/chrono-postgres-datastore/test/helpers/mock-entity-manager.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Mock Entity Manager - * - * Simulates TypeORM's EntityManager interface for testing. - * Provides methods for creating, saving, and finding entities. - */ - -import type { PGlite } from '@electric-sql/pglite'; -import { faker } from '@faker-js/faker'; - -import type { ChronoTaskEntity } from '../../src/chrono-task.entity'; -import { TEST_TABLE_NAME } from '../database-setup'; -import { mapRowToEntity } from './entity-mapper'; -import { createMockQueryBuilder, type MockQueryBuilder } from './mock-query-builder'; - -/** - * Options for the findOne method. - */ -type FindOneOptions = { - where: Partial; -}; - -/** - * Creates a mock EntityManager that executes operations against PGlite. - */ -export function createMockEntityManager(pglite: PGlite) { - return { - /** - * Executes a raw SQL query. - */ - async query(sql: string, params?: unknown[]): Promise { - const result = await pglite.query(sql, params); - return result.rows; - }, - - /** - * Creates an entity instance (doesn't persist it). - * In TypeORM, this initializes default values and sets up the entity. - * For testing, we just return the data as-is. - */ - create(_entityClass: typeof ChronoTaskEntity, data: Partial): Partial { - return data; - }, - - /** - * Saves an entity to the database. - * Generates an ID if not provided and sets timestamps. - */ - async save(_entityClass: typeof ChronoTaskEntity, data: Partial): Promise { - const id = faker.string.uuid(); - const now = new Date(); - - const result = await pglite.query( - `INSERT INTO ${TEST_TABLE_NAME} - (id, kind, status, data, priority, idempotency_key, - original_schedule_date, scheduled_at, retry_count, - created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING *`, - [ - id, - data.kind, - data.status, - JSON.stringify(data.data), - data.priority ?? 0, - data.idempotencyKey ?? null, - data.originalScheduleDate, - data.scheduledAt, - data.retryCount ?? 0, - now, - now, - ], - ); - - return mapRowToEntity(result.rows[0] as Record); - }, - - /** - * Finds a single entity matching the given criteria. - * Currently supports filtering by `id` and `idempotencyKey`. - */ - async findOne(_entityClass: typeof ChronoTaskEntity, options: FindOneOptions): Promise { - const { where } = options; - - // Build the query dynamically based on provided filters - const conditions: string[] = ['1=1']; // Start with always-true for easy AND appending - const params: unknown[] = []; - let paramIndex = 1; - - if (where.id) { - conditions.push(`id = $${paramIndex}`); - params.push(where.id); - paramIndex++; - } - - if (where.idempotencyKey) { - conditions.push(`idempotency_key = $${paramIndex}`); - params.push(where.idempotencyKey); - paramIndex++; - } - - const sql = ` - SELECT * FROM ${TEST_TABLE_NAME} - WHERE ${conditions.join(' AND ')} - LIMIT 1 - `; - - const result = await pglite.query(sql, params); - - if (result.rows.length === 0) { - return null; - } - - return mapRowToEntity(result.rows[0] as Record); - }, - - /** - * Creates a QueryBuilder for complex queries. - */ - createQueryBuilder(entity?: typeof ChronoTaskEntity, alias?: string): MockQueryBuilder { - const qb = createMockQueryBuilder(pglite); - - if (alias && entity) { - qb.from(entity, alias); - } - - return qb; - }, - }; -} - -export type MockEntityManager = ReturnType; diff --git a/packages/chrono-postgres-datastore/test/helpers/mock-query-builder.ts b/packages/chrono-postgres-datastore/test/helpers/mock-query-builder.ts deleted file mode 100644 index 297a420..0000000 --- a/packages/chrono-postgres-datastore/test/helpers/mock-query-builder.ts +++ /dev/null @@ -1,404 +0,0 @@ -/** - * Mock Query Builder - * - * Simulates TypeORM's QueryBuilder interface but executes queries against PGlite. - * This allows testing the datastore without needing a full TypeORM setup. - * - * The QueryBuilder uses a fluent interface where each method returns `this`, - * allowing method chaining like: - * ```ts - * qb.update(Entity).set({ status: 'DONE' }).where('id = :id', { id: '123' }).execute() - * ``` - */ - -import type { PGlite } from '@electric-sql/pglite'; - -import type { ChronoTaskEntity } from '../../src/chrono-task.entity'; -import { TEST_TABLE_NAME } from '../database-setup'; -import { mapRowToEntity } from './entity-mapper'; -import { removeAliasPrefix, toSnakeCase } from './sql-utils'; -import { createBracketsQueryBuilder, WhereClauseCollection } from './where-clause-builder'; - -/** - * The type of SQL operation being built. - */ -type OperationType = 'select' | 'update' | 'delete'; - -/** - * Defines the sort order for a column. - */ -type OrderByClause = { - field: string; - direction: 'ASC' | 'DESC'; -}; - -/** - * The result of executing a query. - */ -export type QueryResult = { - raw: ChronoTaskEntity[]; - affected: number; -}; - -/** - * Creates a mock QueryBuilder that translates TypeORM method calls into - * raw SQL executed against PGlite. - */ -export function createMockQueryBuilder(pglite: PGlite) { - // Query state - let operation: OperationType = 'select'; - let tableAlias = 'entity'; - let setValues: Record = {}; - let orderByClauses: OrderByClause[] = []; - let includeReturning = false; - let lockMode: string | undefined; - let limitValue: number | undefined; - let selectColumns: string[] = ['*']; - let whereInIdsValues: string[] = []; - - // WHERE clause builder - const whereBuilder = new WhereClauseCollection(tableAlias); - - // The query builder object with all chainable methods - const queryBuilder = { - // ───────────────────────────────────────────────────────────────── - // Operation Type Methods - // ───────────────────────────────────────────────────────────────── - - select(columns?: string | string[]) { - operation = 'select'; - if (columns) { - selectColumns = Array.isArray(columns) ? columns : [columns]; - } - return queryBuilder; - }, - - update(_entity: typeof ChronoTaskEntity) { - operation = 'update'; - return queryBuilder; - }, - - delete() { - operation = 'delete'; - return queryBuilder; - }, - - from(_entity: typeof ChronoTaskEntity, alias?: string) { - if (alias) { - tableAlias = alias; - whereBuilder.setAlias(alias); - } - return queryBuilder; - }, - - // ───────────────────────────────────────────────────────────────── - // WHERE Clause Methods - // ───────────────────────────────────────────────────────────────── - - /** - * Adds a WHERE condition. Handles three formats: - * 1. String condition: 'status = :status' - * 2. Callback function: (qb) => qb.where(...) - * 3. Brackets object: new Brackets((qb) => ...) - */ - where(condition: string | { getQuery: () => string } | unknown, params?: Record) { - if (typeof condition === 'function') { - // Direct callback function - const bracketsQb = createBracketsQueryBuilder(tableAlias); - (condition as (qb: ReturnType) => void)(bracketsQb); - whereBuilder.addBracketsCondition(bracketsQb); - } else if (isObjectWithProperty(condition, 'getQuery')) { - // Brackets object with getQuery method - const bracketsObj = condition as { getQuery: () => string }; - whereBuilder.addCondition(bracketsObj.getQuery(), params || {}); - } else if (typeof condition === 'string') { - // Simple string condition - whereBuilder.addCondition(condition, params || {}); - } - - return queryBuilder; - }, - - /** - * Adds an AND WHERE condition. - * Also handles TypeORM Brackets objects (which use 'whereFactory' property). - */ - andWhere(condition: string | { getQuery: () => string } | unknown, params?: Record) { - // TypeORM Brackets objects store their callback in 'whereFactory' - if (isObjectWithProperty(condition, 'whereFactory')) { - const bracketsQb = createBracketsQueryBuilder(tableAlias); - const brackets = condition as { - whereFactory: (qb: ReturnType) => void; - }; - brackets.whereFactory(bracketsQb); - whereBuilder.addBracketsCondition(bracketsQb); - return queryBuilder; - } - - // Otherwise, treat it as a regular where condition - return queryBuilder.where(condition, params); - }, - - /** - * Adds an OR condition to the previous WHERE clause. - */ - orWhere(condition: string, params?: Record) { - whereBuilder.addOrCondition(condition, params || {}); - return queryBuilder; - }, - - /** - * Adds a WHERE IN condition for IDs. - */ - whereInIds(ids: string[]) { - whereInIdsValues = ids; - return queryBuilder; - }, - - // ───────────────────────────────────────────────────────────────── - // UPDATE Methods - // ───────────────────────────────────────────────────────────────── - - /** - * Sets the values to update. - * Values can be primitives or functions that return raw SQL. - */ - set(values: Record) { - setValues = values; - return queryBuilder; - }, - - // ───────────────────────────────────────────────────────────────── - // SELECT Methods - // ───────────────────────────────────────────────────────────────── - - /** - * Sets the primary ORDER BY column (replaces any existing order). - */ - orderBy(field: string, direction: 'ASC' | 'DESC' = 'ASC') { - const cleanField = removeAliasPrefix(field, tableAlias); - orderByClauses = [{ field: cleanField, direction }]; - return queryBuilder; - }, - - /** - * Adds an additional ORDER BY column. - */ - addOrderBy(field: string, direction: 'ASC' | 'DESC' = 'ASC') { - const cleanField = removeAliasPrefix(field, tableAlias); - orderByClauses.push({ field: cleanField, direction }); - return queryBuilder; - }, - - /** - * Sets a LIMIT for the query. - */ - limit(limit: number) { - limitValue = limit; - return queryBuilder; - }, - - /** - * Enables RETURNING * for INSERT/UPDATE/DELETE queries. - */ - returning(_columns: string) { - includeReturning = true; - return queryBuilder; - }, - - /** - * Sets the lock mode for SELECT queries. - */ - setLock(mode: string, _version?: unknown, _tables?: string[]) { - lockMode = mode; - return queryBuilder; - }, - - // ───────────────────────────────────────────────────────────────── - // Execution Methods - // ───────────────────────────────────────────────────────────────── - - /** - * Executes a SELECT query and returns at most one result. - */ - async getOne(): Promise { - const sql = buildSelectSql(1); - const { values } = whereBuilder.build(); - - const result = await pglite.query(sql, values); - - if (result.rows.length === 0) { - return null; - } - - return mapRowToEntity(result.rows[0] as Record); - }, - - /** - * Executes a SELECT query and returns all matching results (respects limit). - */ - async getMany(): Promise { - const sql = buildSelectSql(limitValue); - const { values } = whereBuilder.build(); - - const result = await pglite.query(sql, values); - - return (result.rows as Record[]).map(mapRowToEntity); - }, - - /** - * Executes the query (UPDATE or DELETE) and returns affected rows. - */ - async execute(): Promise { - if (operation === 'delete') { - return executeDelete(); - } - - if (operation === 'update') { - return executeUpdate(); - } - - return { raw: [], affected: 0 }; - }, - }; - - // ───────────────────────────────────────────────────────────────── - // Helper Functions - // ───────────────────────────────────────────────────────────────── - - /** - * Builds a SELECT SQL statement with all clauses. - */ - function buildSelectSql(limit?: number): string { - const { sql: whereClause } = whereBuilder.build(); - - // Build column list from selectColumns - const columns = selectColumns - .map((col) => { - if (col === '*') return '*'; - // Remove alias prefix (e.g., 'task.id' -> 'id') - const cleanCol = removeAliasPrefix(col, tableAlias); - return toSnakeCase(cleanCol); - }) - .join(', '); - - let sql = `SELECT ${columns} FROM ${TEST_TABLE_NAME}`; - - if (whereClause) { - sql += ` WHERE ${whereClause}`; - } - - if (orderByClauses.length > 0) { - const orderByParts = orderByClauses.map((clause) => { - const snakeField = toSnakeCase(clause.field); - return `${snakeField} ${clause.direction}`; - }); - sql += ` ORDER BY ${orderByParts.join(', ')}`; - } - - if (limit !== undefined) { - sql += ` LIMIT ${limit}`; - } - - if (lockMode) { - sql += ' FOR UPDATE SKIP LOCKED'; - } - - return sql; - } - - /** - * Executes a DELETE statement. - */ - async function executeDelete(): Promise { - const { sql: whereClause, values: whereValues } = whereBuilder.build(); - - let sql = `DELETE FROM ${TEST_TABLE_NAME}`; - let values = whereValues; - - if (whereInIdsValues.length > 0) { - // Handle whereInIds - create parameterized IN clause - const placeholders = whereInIdsValues.map((_, i) => `$${i + 1}`).join(', '); - sql += ` WHERE id IN (${placeholders})`; - values = whereInIdsValues; - } else if (whereClause) { - sql += ` WHERE ${whereClause}`; - } - - if (includeReturning) { - sql += ' RETURNING *'; - } - - const result = await pglite.query(sql, values); - const entities = (result.rows as Record[]).map(mapRowToEntity); - - return { raw: entities, affected: result.rows.length }; - } - - /** - * Executes an UPDATE statement. - */ - async function executeUpdate(): Promise { - const { sql: whereClause, values: whereValues } = whereBuilder.build(); - - // Build SET clause - const { setClauses, setParamValues } = buildSetClause(whereValues.length); - - let sql = `UPDATE ${TEST_TABLE_NAME} SET ${setClauses.join(', ')}`; - - if (whereClause) { - sql += ` WHERE ${whereClause}`; - } - - if (includeReturning) { - sql += ' RETURNING *'; - } - - const allValues = [...whereValues, ...setParamValues]; - const result = await pglite.query(sql, allValues); - const entities = (result.rows as Record[]).map(mapRowToEntity); - - return { raw: entities, affected: result.rows.length }; - } - - /** - * Builds the SET clause for an UPDATE statement. - * Handles both regular values and raw SQL expressions (functions). - */ - function buildSetClause(startParamIndex: number): { - setClauses: string[]; - setParamValues: unknown[]; - } { - const setClauses: string[] = []; - const setParamValues: unknown[] = []; - let paramIndex = startParamIndex + 1; - - for (const [key, value] of Object.entries(setValues)) { - const snakeKey = toSnakeCase(key); - - if (typeof value === 'function') { - // Raw SQL expression, e.g., () => 'NULL' or () => 'retry_count + 1' - const rawSql = value(); - setClauses.push(`${snakeKey} = ${rawSql}`); - } else { - // Regular parameter value - setClauses.push(`${snakeKey} = $${paramIndex}`); - setParamValues.push(value); - paramIndex++; - } - } - - return { setClauses, setParamValues }; - } - - return queryBuilder; -} - -/** - * Type guard to check if a value is an object with a specific property. - */ -function isObjectWithProperty(value: unknown, property: string): boolean { - return typeof value === 'object' && value !== null && property in value; -} - -export type MockQueryBuilder = ReturnType; diff --git a/packages/chrono-postgres-datastore/test/helpers/sql-utils.ts b/packages/chrono-postgres-datastore/test/helpers/sql-utils.ts deleted file mode 100644 index 01680d4..0000000 --- a/packages/chrono-postgres-datastore/test/helpers/sql-utils.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * SQL Utility Functions - * - * Helpers for converting between TypeORM's conventions and raw PostgreSQL. - * TypeORM uses camelCase for column names, but PostgreSQL uses snake_case. - */ - -/** - * Converts a camelCase string to snake_case. - * - * @example - * toSnakeCase('scheduledAt') // returns 'scheduled_at' - * toSnakeCase('idempotencyKey') // returns 'idempotency_key' - */ -export function toSnakeCase(str: string): string { - return str.replace(/([A-Z])/g, '_$1').toLowerCase(); -} - -/** - * Converts camelCase column names in SQL to snake_case. - * Only converts "alias.columnName" patterns, leaving other text untouched. - * - * @example - * convertAliasedColumns('task.scheduledAt <= :now') - * // returns 'task.scheduled_at <= :now' - */ -export function convertAliasedColumns(sql: string): string { - return sql.replace(/(\w+)\.(\w+)/g, (_match, tableAlias, column) => { - return `${tableAlias}.${toSnakeCase(column)}`; - }); -} - -/** - * Converts camelCase identifiers to snake_case, but preserves :param placeholders. - * Uses negative lookbehind to avoid converting parameter names like :scheduledAt. - * - * @example - * convertIdentifiers('scheduledAt = :scheduledAt') - * // returns 'scheduled_at = :scheduledAt' - */ -export function convertCamelCaseIdentifiers(sql: string): string { - // Pattern matches camelCase words (lowercase followed by uppercase) - // Negative lookbehind (? toSnakeCase(match)); -} - -/** - * Removes a table alias prefix from a SQL fragment. - * - * @example - * removeAliasPrefix('task.status = :status', 'task') - * // returns 'status = :status' - */ -export function removeAliasPrefix(sql: string, alias: string): string { - return sql.replace(new RegExp(`${alias}\\.`, 'g'), ''); -} - -/** - * Result of replacing named parameters with positional parameters. - */ -export type ParameterReplacementResult = { - sql: string; - values: unknown[]; -}; - -/** - * Replaces TypeORM-style named parameters (:paramName) with PostgreSQL-style - * positional parameters ($1, $2, etc.). - * - * @example - * replaceNamedParameters('status = :status AND kind = :kind', { status: 'PENDING', kind: 'test' }) - * // returns { sql: 'status = $1 AND kind = $2', values: ['PENDING', 'test'] } - */ -export function replaceNamedParameters(sql: string, params: Record): ParameterReplacementResult { - const values: unknown[] = []; - let paramIndex = 1; - let result = convertAliasedColumns(sql); - - for (const [key, value] of Object.entries(params)) { - const placeholder = `:${key}`; - - if (result.includes(placeholder)) { - // Replace all occurrences of this placeholder - result = result.split(placeholder).join(`$${paramIndex}`); - values.push(value); - paramIndex++; - } - } - - return { sql: result, values }; -} diff --git a/packages/chrono-postgres-datastore/test/helpers/where-clause-builder.ts b/packages/chrono-postgres-datastore/test/helpers/where-clause-builder.ts deleted file mode 100644 index e409240..0000000 --- a/packages/chrono-postgres-datastore/test/helpers/where-clause-builder.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * WHERE Clause Builder - * - * Builds SQL WHERE clauses from TypeORM-style conditions. - * Handles simple conditions, AND/OR combinations, and nested Brackets. - */ - -import { - convertCamelCaseIdentifiers, - type ParameterReplacementResult, - removeAliasPrefix, - replaceNamedParameters, -} from './sql-utils'; - -/** - * A single WHERE condition with its SQL fragment and parameters. - */ -export type WhereCondition = { - sql: string; - params: Record; -}; - -/** - * A sub-query builder for handling TypeORM Brackets. - * - * TypeORM's Brackets class groups conditions with parentheses: - * ```ts - * new Brackets(qb => { - * qb.where('status = :pending', { pending: 'PENDING' }) - * .orWhere('status = :claimed', { claimed: 'CLAIMED' }) - * }) - * ``` - * - * This creates a mini query builder that collects the conditions - * and can output them as a grouped SQL string. - * - * @param tableAlias - The table alias to remove from column references (e.g., 'task') - */ -export function createBracketsQueryBuilder(tableAlias: string = 'task') { - const conditions: Array<{ - type: 'where' | 'or'; - sql: string; - params: Record; - }> = []; - - /** - * Cleans up a SQL fragment by removing the table alias and converting to snake_case. - */ - function cleanSql(sql: string): string { - let result = removeAliasPrefix(sql, tableAlias); - result = convertCamelCaseIdentifiers(result); - return result; - } - - return { - /** - * Adds a WHERE condition. The first condition added uses this. - */ - where(sql: string, params?: Record) { - conditions.push({ - type: 'where', - sql, - params: params || {}, - }); - return this; - }, - - /** - * Adds an OR condition to the group. - */ - orWhere(sql: string, params?: Record) { - conditions.push({ - type: 'or', - sql, - params: params || {}, - }); - return this; - }, - - /** - * Builds the SQL string for all conditions in this group. - * - * @returns SQL like: "status = :pending OR status = :claimed AND claimed_at <= :staleThreshold" - */ - getSql(): string { - return conditions - .map((condition, index) => { - const cleaned = cleanSql(condition.sql); - - // First condition doesn't need AND/OR prefix - if (index === 0) { - return cleaned; - } - - // Subsequent conditions get their appropriate connector - const connector = condition.type === 'or' ? 'OR' : 'AND'; - return `${connector} ${cleaned}`; - }) - .join(' '); - }, - - /** - * Collects all parameters from all conditions in this group. - */ - getParams(): Record { - const result: Record = {}; - for (const condition of conditions) { - Object.assign(result, condition.params); - } - return result; - }, - }; -} - -/** - * Manages a collection of WHERE clauses and builds the final SQL. - */ -export class WhereClauseCollection { - private clauses: WhereCondition[] = []; - private tableAlias: string; - - constructor(tableAlias: string = 'entity') { - this.tableAlias = tableAlias; - } - - /** - * Updates the table alias used for removing prefixes. - * Call this when the alias changes (e.g., after from() is called). - */ - setAlias(alias: string): void { - this.tableAlias = alias; - } - - /** - * Gets the current table alias. - */ - getAlias(): string { - return this.tableAlias; - } - - /** - * Adds a simple string condition. - */ - addCondition(sql: string, params: Record = {}): void { - // Remove table alias and convert column names to snake_case - let cleanSql = removeAliasPrefix(sql, this.tableAlias); - cleanSql = convertCamelCaseIdentifiers(cleanSql); - - this.clauses.push({ sql: cleanSql, params }); - } - - /** - * Adds a condition from a Brackets query builder. - * Wraps the result in parentheses to maintain grouping. - */ - addBracketsCondition(bracketsQb: ReturnType): void { - this.clauses.push({ - sql: `(${bracketsQb.getSql()})`, - params: bracketsQb.getParams(), - }); - } - - /** - * Adds an OR condition to the previous clause. - * Combines the last clause with the new condition using OR. - */ - addOrCondition(sql: string, params: Record = {}): void { - const cleanSql = removeAliasPrefix(sql, this.tableAlias); - const lastClause = this.clauses.pop(); - - if (lastClause) { - // Combine with previous clause using OR - this.clauses.push({ - sql: `(${lastClause.sql} OR ${cleanSql})`, - params: { ...lastClause.params, ...params }, - }); - } else { - // No previous clause, just add this one - this.clauses.push({ sql: cleanSql, params }); - } - } - - /** - * Builds the final WHERE clause SQL with positional parameters. - * - * @returns Object with the SQL string and array of parameter values - */ - build(): ParameterReplacementResult { - if (this.clauses.length === 0) { - return { sql: '', values: [] }; - } - - // Combine all parameters - const allParams: Record = {}; - for (const clause of this.clauses) { - Object.assign(allParams, clause.params); - } - - // Join clauses with AND - const combinedSql = this.clauses.map((c) => c.sql).join(' AND '); - - // Replace named parameters with positional ones - return replaceNamedParameters(combinedSql, allParams); - } -} diff --git a/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts b/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts deleted file mode 100644 index 4149da3..0000000 --- a/packages/chrono-postgres-datastore/test/unit/chrono-postgres-datastore.test.ts +++ /dev/null @@ -1,651 +0,0 @@ -import { PGlite } from '@electric-sql/pglite'; -import { faker } from '@faker-js/faker'; -import { TaskStatus } from '@neofinancial/chrono'; -import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; - -import { ChronoPostgresDatastore } from '../../src/chrono-postgres-datastore'; -import { TEST_TABLE_NAME } from '../database-setup'; -import { createMockDataSource, type MockDataSource } from '../helpers/mock-datasource'; - -type TaskMapping = { - test: { - test: string; - }; -}; - -const TEST_CLAIM_STALE_TIMEOUT_MS = 1_000; // 1 second - -describe('ChronoPostgresDatastore', () => { - let pglite: PGlite; - let mockDataSource: MockDataSource; - let dataStore: ChronoPostgresDatastore; - - beforeAll(async () => { - // Create in-memory PGlite instance - pglite = new PGlite(); - - // Create the table schema - await pglite.exec(` - CREATE TABLE IF NOT EXISTS ${TEST_TABLE_NAME} ( - 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 TIMESTAMP WITH TIME ZONE NOT NULL, - scheduled_at TIMESTAMP WITH TIME ZONE NOT NULL, - claimed_at TIMESTAMP WITH TIME ZONE, - completed_at TIMESTAMP WITH TIME ZONE, - last_executed_at TIMESTAMP WITH TIME ZONE, - retry_count INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() - ) - `); - - // Create indexes - await pglite.exec(` - CREATE UNIQUE INDEX IF NOT EXISTS idx_chrono_tasks_idempotency_key - ON ${TEST_TABLE_NAME} (idempotency_key) - WHERE idempotency_key IS NOT NULL - `); - - mockDataSource = createMockDataSource(pglite); - - dataStore = new ChronoPostgresDatastore({}); - - await dataStore.initialize(mockDataSource as unknown as Parameters[0]); - }); - - beforeEach(async () => { - await pglite.exec(`DELETE FROM ${TEST_TABLE_NAME}`); - }); - - afterAll(async () => { - await pglite.close(); - }); - - describe('initialize', () => { - test('should throw an error if the DataSource is already set', async () => { - await expect(() => - dataStore.initialize(mockDataSource as unknown as Parameters[0]), - ).rejects.toThrow('DataSource already initialized'); - }); - }); - - describe('schedule', () => { - const input = { - kind: 'test' as const, - data: { test: 'test' }, - priority: 1, - when: new Date(), - }; - - describe('when called with valid input', () => { - test('should return task with correct properties', async () => { - const task = await dataStore.schedule(input); - - expect(task).toEqual( - expect.objectContaining({ - kind: input.kind, - status: 'PENDING', - data: input.data, - priority: input.priority, - originalScheduleDate: expect.any(Date), - scheduledAt: expect.any(Date), - id: expect.any(String), - retryCount: 0, - }), - ); - }); - - test('should store task in the database', async () => { - const task = await dataStore.schedule(input); - - const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task.id]); - - expect(result.rows.length).toBe(1); - expect(result.rows[0]).toEqual( - expect.objectContaining({ - kind: input.kind, - status: 'PENDING', - }), - ); - }); - }); - - describe('idempotency', () => { - test('should return existing task if one exists with same idempotency key', async () => { - const idempotencyKey = faker.string.uuid(); - const inputWithIdempotency = { - kind: 'test' as const, - data: { test: 'test' }, - priority: 1, - when: new Date(), - idempotencyKey, - }; - - const task1 = await dataStore.schedule(inputWithIdempotency); - const task2 = await dataStore.schedule(inputWithIdempotency); - - expect(task1.id).toEqual(task2.id); - expect(task1.idempotencyKey).toEqual(task2.idempotencyKey); - }); - }); - }); - - describe('claim', () => { - const input = { - kind: 'test' as const, - data: { test: 'test' }, - priority: 1, - when: new Date(Date.now() - 1), - }; - - test('should return undefined when no tasks available', async () => { - const result = await dataStore.claim({ - kind: input.kind, - claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS, - }); - - expect(result).toBeUndefined(); - }); - - test('should claim task in PENDING state with scheduledAt in the past', async () => { - const task = await dataStore.schedule({ - ...input, - when: new Date(Date.now() - 1000), - }); - - const claimedTask = await dataStore.claim({ - kind: input.kind, - claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS, - }); - - expect(claimedTask).toEqual( - expect.objectContaining({ - id: task.id, - kind: task.kind, - status: 'CLAIMED', - }), - ); - }); - - test('should claim task in CLAIMED state with claimedAt in the past (stale)', async () => { - const scheduledTask = await dataStore.schedule(input); - - const claimedTask = await dataStore.claim({ - kind: input.kind, - claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS, - }); - - // Trying to claim again should return undefined (no stale tasks) - const claimedTaskAgain = await dataStore.claim({ - kind: input.kind, - claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS, - }); - - // Fast forward time to make the claim stale - const fakeTimer = vi.useFakeTimers(); - fakeTimer.setSystemTime( - new Date((claimedTask?.claimedAt?.getTime() as number) + TEST_CLAIM_STALE_TIMEOUT_MS + 1), - ); - - const claimedTaskAgainAgain = await dataStore.claim({ - kind: input.kind, - claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS, - }); - fakeTimer.useRealTimers(); - - expect(scheduledTask).toEqual( - expect.objectContaining({ - status: TaskStatus.PENDING, - }), - ); - expect(claimedTask).toEqual( - expect.objectContaining({ - id: scheduledTask.id, - kind: scheduledTask.kind, - status: TaskStatus.CLAIMED, - }), - ); - expect(claimedTaskAgain).toBeUndefined(); - expect(claimedTaskAgainAgain).toEqual( - expect.objectContaining({ - id: scheduledTask.id, - kind: scheduledTask.kind, - status: TaskStatus.CLAIMED, - }), - ); - }); - - test('should claim tasks in priority order (higher priority first)', async () => { - const lowPriorityTask = await dataStore.schedule({ - ...input, - priority: 1, - }); - const highPriorityTask = await dataStore.schedule({ - ...input, - priority: 10, - }); - - const firstClaimed = await dataStore.claim({ - kind: input.kind, - claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS, - }); - - expect(firstClaimed?.id).toEqual(highPriorityTask.id); - - const secondClaimed = await dataStore.claim({ - kind: input.kind, - claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS, - }); - - expect(secondClaimed?.id).toEqual(lowPriorityTask.id); - }); - }); - - describe('complete', () => { - test('should mark task as completed', async () => { - const task = await dataStore.schedule({ - kind: 'test', - data: { test: 'test' }, - priority: 1, - when: new Date(), - }); - - const completedTask = await dataStore.complete(task.id); - - expect(completedTask).toEqual( - expect.objectContaining({ - id: task.id, - kind: task.kind, - status: TaskStatus.COMPLETED, - completedAt: expect.any(Date), - }), - ); - - // Verify in database - const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task.id]); - expect(result.rows[0]).toEqual( - expect.objectContaining({ - status: TaskStatus.COMPLETED, - }), - ); - }); - - test('should throw an error if task is not found', async () => { - const taskId = faker.string.uuid(); - - await expect(() => dataStore.complete(taskId)).rejects.toThrow(`Task with ID ${taskId} not found`); - }); - }); - - describe('fail', () => { - test('should mark task as failed', async () => { - const task = await dataStore.schedule({ - kind: 'test', - data: { test: 'test' }, - priority: 1, - when: new Date(), - }); - - const failedTask = await dataStore.fail(task.id); - - expect(failedTask).toEqual( - expect.objectContaining({ - id: task.id, - kind: task.kind, - status: TaskStatus.FAILED, - }), - ); - - // Verify in database - const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task.id]); - expect(result.rows[0]).toEqual( - expect.objectContaining({ - status: TaskStatus.FAILED, - }), - ); - }); - - test('should throw an error if task is not found', async () => { - const taskId = faker.string.uuid(); - - await expect(() => dataStore.fail(taskId)).rejects.toThrow(`Task with ID ${taskId} not found`); - }); - }); - - describe('retry', () => { - test('should retry task', async () => { - const firstScheduleDate = faker.date.past(); - const secondScheduleDate = faker.date.future(); - - const task = await dataStore.schedule({ - kind: 'test', - data: { test: 'test' }, - priority: 1, - when: firstScheduleDate, - }); - - expect(task).toEqual( - expect.objectContaining({ - status: TaskStatus.PENDING, - retryCount: 0, - }), - ); - - const taskToRetry = await dataStore.retry(task.id, secondScheduleDate); - - expect(taskToRetry).toEqual( - expect.objectContaining({ - id: task.id, - kind: task.kind, - status: TaskStatus.PENDING, - retryCount: 1, - }), - ); - - // Verify scheduledAt was updated - expect(taskToRetry.scheduledAt.getTime()).toBeCloseTo(secondScheduleDate.getTime(), -3); - // Verify originalScheduleDate was preserved - expect(taskToRetry.originalScheduleDate.getTime()).toBeCloseTo(firstScheduleDate.getTime(), -3); - }); - - test('should throw an error if task is not found', async () => { - const taskId = faker.string.uuid(); - - await expect(() => dataStore.retry(taskId, new Date())).rejects.toThrow(`Task with ID ${taskId} not found`); - }); - }); - - describe('delete', () => { - test('deletes task by id removing from datastore', async () => { - const when = new Date(); - - const task = await dataStore.schedule({ - kind: 'test', - data: { test: 'test' }, - priority: 1, - when, - }); - - await dataStore.delete(task.id); - - const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task.id]); - expect(result.rows.length).toBe(0); - }); - - test('deletes task by task kind and idempotency key removing from datastore', async () => { - const when = new Date(); - - const task = await dataStore.schedule({ - idempotencyKey: 'test-idempotency-key', - kind: 'test', - data: { test: 'test' }, - priority: 1, - when, - }); - - await dataStore.delete({ kind: task.kind, idempotencyKey: task.idempotencyKey ?? 'undefined' }); - - const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task.id]); - expect(result.rows.length).toBe(0); - }); - - test('returns deleted task', async () => { - const when = new Date(); - - const task = await dataStore.schedule({ - kind: 'test', - data: { test: 'test' }, - priority: 1, - when, - }); - - const deletedTask = await dataStore.delete(task.id); - - expect(deletedTask?.id).toEqual(task.id); - expect(deletedTask?.kind).toEqual(task.kind); - }); - - test('throws when attempting to delete a task that is not PENDING', async () => { - const when = new Date(Date.now() - 1000); - - const task = await dataStore.schedule({ - kind: 'test', - data: { test: 'test' }, - priority: 1, - when, - }); - - await dataStore.claim({ kind: task.kind, claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS }); - - await expect(dataStore.delete(task.id)).rejects.toThrow( - `Task with id ${task.id} cannot be deleted as it may not exist or it's not in PENDING status.`, - ); - }); - - test('force deletes non-PENDING task removing from datastore', async () => { - const when = new Date(Date.now() - 1000); - - const task = await dataStore.schedule({ - kind: 'test', - data: { test: 'test' }, - priority: 1, - when, - }); - - await dataStore.claim({ kind: task.kind, claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS }); - - await dataStore.delete(task.id, { force: true }); - - const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task.id]); - expect(result.rows.length).toBe(0); - }); - - test('noops when force deleting a task that does not exist', async () => { - const result = await dataStore.delete(faker.string.uuid(), { force: true }); - expect(result).toBeUndefined(); - }); - }); - - describe('getEntity', () => { - test('should return the ChronoTaskEntity class', () => { - const entity = ChronoPostgresDatastore.getEntity(); - expect(entity.name).toBe('ChronoTaskEntity'); - }); - }); - - describe('cleanup', () => { - // Helper to wait for fire-and-forget cleanup to complete - const waitForCleanup = () => new Promise((resolve) => setTimeout(resolve, 50)); - - test('should delete completed tasks older than TTL after claim', async () => { - const ds = new ChronoPostgresDatastore({ - completedDocumentTTLSeconds: 1, - cleanupIntervalSeconds: 0, - }); - await ds.initialize(mockDataSource as unknown as Parameters[0]); - - // Create a task and complete it - const task = await ds.schedule({ - kind: 'test', - data: { test: 'test' }, - priority: 1, - when: new Date(Date.now() - 1000), - }); - await ds.complete(task.id); - - // Backdate completed_at to be older than TTL - await pglite.exec( - `UPDATE ${TEST_TABLE_NAME} SET completed_at = NOW() - INTERVAL '2 seconds' WHERE id = '${task.id}'`, - ); - - // Trigger cleanup via claim - await ds.claim({ kind: 'test', claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS }); - await waitForCleanup(); - - const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task.id]); - expect(result.rows.length).toBe(0); - }); - - test('should not delete completed tasks newer than TTL', async () => { - const ds = new ChronoPostgresDatastore({ - completedDocumentTTLSeconds: 3600, - cleanupIntervalSeconds: 0, - }); - await ds.initialize(mockDataSource as unknown as Parameters[0]); - - const task = await ds.schedule({ - kind: 'test', - data: { test: 'test' }, - priority: 1, - when: new Date(Date.now() - 1000), - }); - await ds.complete(task.id); - - // Trigger cleanup via claim - task should NOT be deleted (completed just now) - await ds.claim({ kind: 'test', claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS }); - await waitForCleanup(); - - const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task.id]); - expect(result.rows.length).toBe(1); - }); - - test('should respect cleanup interval', async () => { - const ds = new ChronoPostgresDatastore({ - completedDocumentTTLSeconds: 1, - cleanupIntervalSeconds: 3600, - }); - await ds.initialize(mockDataSource as unknown as Parameters[0]); - - // Create and complete first task - const task1 = await ds.schedule({ - kind: 'test', - data: { test: 'test1' }, - priority: 1, - when: new Date(Date.now() - 1000), - }); - await ds.complete(task1.id); - - // Backdate to be older than TTL - await pglite.exec( - `UPDATE ${TEST_TABLE_NAME} SET completed_at = NOW() - INTERVAL '2 seconds' WHERE id = '${task1.id}'`, - ); - - // First claim triggers cleanup (interval starts at epoch) - await ds.claim({ kind: 'test', claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS }); - await waitForCleanup(); - - const result1 = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task1.id]); - expect(result1.rows.length).toBe(0); - - // Create and complete second task - const task2 = await ds.schedule({ - kind: 'test', - data: { test: 'test2' }, - priority: 1, - when: new Date(Date.now() - 1000), - }); - await ds.complete(task2.id); - - // Backdate to be older than TTL - await pglite.exec( - `UPDATE ${TEST_TABLE_NAME} SET completed_at = NOW() - INTERVAL '2 seconds' WHERE id = '${task2.id}'`, - ); - - // Second claim should NOT trigger cleanup (interval not passed) - await ds.claim({ kind: 'test', claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS }); - await waitForCleanup(); - - // task2 should still exist because cleanup interval hasn't passed - const result2 = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE id = $1`, [task2.id]); - expect(result2.rows.length).toBe(1); - }); - - test('should call onCleanupError when cleanup fails', async () => { - const onCleanupError = vi.fn(); - - // Create a datasource that works for claim but fails during cleanup's SELECT - const failingCleanupDataSource = { - ...mockDataSource, - createQueryBuilder: (...args: unknown[]) => { - const qb = mockDataSource.createQueryBuilder(...args); - // If called with entity and alias (cleanup SELECT), make getMany fail - if (args.length === 2) { - return { - ...qb, - select: () => ({ - where: () => ({ - andWhere: () => ({ - limit: () => ({ - getMany: () => Promise.reject(new Error('Cleanup failed')), - }), - }), - }), - }), - }; - } - return qb; - }, - }; - - const ds = new ChronoPostgresDatastore({ - cleanupIntervalSeconds: 0, - onCleanupError, - }); - await ds.initialize(failingCleanupDataSource as unknown as Parameters[0]); - - // Schedule task using the main datastore (shares same pglite) - await dataStore.schedule({ - kind: 'test', - data: { test: 'test' }, - priority: 1, - when: new Date(Date.now() - 1000), - }); - - // Trigger cleanup via claim - await ds.claim({ kind: 'test', claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS }); - await waitForCleanup(); - - expect(onCleanupError).toHaveBeenCalledWith(expect.any(Error)); - expect((onCleanupError.mock.calls[0][0] as Error).message).toBe('Cleanup failed'); - }); - - test('should respect cleanup batch size', async () => { - const ds = new ChronoPostgresDatastore({ - completedDocumentTTLSeconds: 1, - cleanupIntervalSeconds: 0, - cleanupBatchSize: 2, - }); - await ds.initialize(mockDataSource as unknown as Parameters[0]); - - // Create and complete 3 tasks - const tasks = await Promise.all( - [1, 2, 3].map((i) => - ds.schedule({ - kind: 'test', - data: { test: `test${i}` }, - priority: 1, - when: new Date(Date.now() - 1000), - }), - ), - ); - await Promise.all(tasks.map((t) => ds.complete(t.id))); - - // Backdate all tasks to be older than TTL - await pglite.exec( - `UPDATE ${TEST_TABLE_NAME} SET completed_at = NOW() - INTERVAL '2 seconds' WHERE status = '${TaskStatus.COMPLETED}'`, - ); - - // Trigger cleanup - should only delete 2 tasks (batch size) - await ds.claim({ kind: 'test', claimStaleTimeoutMs: TEST_CLAIM_STALE_TIMEOUT_MS }); - await waitForCleanup(); - - const result = await pglite.query(`SELECT * FROM ${TEST_TABLE_NAME} WHERE status = $1`, [TaskStatus.COMPLETED]); - expect(result.rows.length).toBe(1); - }); - }); -}); diff --git a/packages/chrono-postgres-datastore/vitest.config.ts b/packages/chrono-postgres-datastore/vitest.config.ts index 166aed4..ab9857d 100644 --- a/packages/chrono-postgres-datastore/vitest.config.ts +++ b/packages/chrono-postgres-datastore/vitest.config.ts @@ -1,4 +1,11 @@ import { defineProject, mergeConfig } from 'vitest/config'; import baseConfig from '../../vitest.base.config.js'; -export default mergeConfig(baseConfig, defineProject({})); +export default mergeConfig( + baseConfig, + defineProject({ + test: { + include: ['test/**/*.test.ts'], + }, + }), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed4406e..d3bd379 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,12 +70,6 @@ importers: packages/chrono-postgres-datastore: devDependencies: - '@electric-sql/pglite': - specifier: ^0.2.0 - version: 0.2.17 - '@faker-js/faker': - specifier: ^9.0.0 - version: 9.9.0 '@neofinancial/chrono': specifier: workspace:* version: link:../chrono-core @@ -162,9 +156,6 @@ packages: cpu: [x64] os: [win32] - '@electric-sql/pglite@0.2.17': - resolution: {integrity: sha512-qEpKRT2oUaWDH6tjRxLHjdzMqRUGYDnGZlKrnL4dJ77JVMcP2Hpo3NYnOSPKdZdeec57B6QPprCUFg0picx5Pw==} - '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -1652,8 +1643,6 @@ snapshots: '@biomejs/cli-win32-x64@2.2.4': optional: true - '@electric-sql/pglite@0.2.17': {} - '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 From ac3ddfce8e01f4382f3c8ef825485c9de3b319df Mon Sep 17 00:00:00 2001 From: James Couch Date: Tue, 27 Jan 2026 19:09:09 -0700 Subject: [PATCH 08/21] Remove testAccessor hack now that we have real Postgres tests Query generation tests are no longer needed - actual cleanup behavior is already verified by the integration tests. Co-Authored-By: Claude (claude-4-opus) --- .../src/chrono-postgres-datastore.ts | 11 -------- .../test/chrono-postgres-datastore.test.ts | 25 +------------------ 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts index 83fb0cc..89f2642 100644 --- a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts +++ b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts @@ -15,9 +15,6 @@ const DEFAULT_TTL_SECONDS = 60 * 60 * 24 * 30; const DEFAULT_CLEANUP_INTERVAL_SECONDS = 60; const DEFAULT_CLEANUP_BATCH_SIZE = 100; -/** @internal Symbol key for test-only access to query builders */ -export const testAccessor = Symbol('testAccessor'); - export type ChronoPostgresDatastoreConfig = { /** TTL (in seconds) for completed tasks. Tasks older than this are deleted during cleanup. */ completedDocumentTTLSeconds?: number; @@ -442,12 +439,4 @@ export class ChronoPostgresDatastore private buildCleanupDeleteQuery(ids: string[]) { return this.dataSource!.createQueryBuilder().delete().from(ChronoTaskEntity).whereInIds(ids); } - - /** @internal Exposed for testing query generation only */ - get [testAccessor]() { - return { - buildCleanupSelectQuery: (cutoffDate: Date) => this.buildCleanupSelectQuery(cutoffDate), - buildCleanupDeleteQuery: (ids: string[]) => this.buildCleanupDeleteQuery(ids), - }; - } } diff --git a/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts b/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts index 41e0520..6a3ea59 100644 --- a/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts +++ b/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts @@ -2,7 +2,7 @@ import { TaskStatus } from '@neofinancial/chrono'; import { DataSource } from 'typeorm'; import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import { ChronoPostgresDatastore, testAccessor } from '../src/chrono-postgres-datastore'; +import { ChronoPostgresDatastore } from '../src/chrono-postgres-datastore'; import { ChronoTaskEntity } from '../src/chrono-task.entity'; const DATABASE_URL = process.env.DATABASE_URL; @@ -581,29 +581,6 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { }); }); - describe('query generation', () => { - const getTestAccessor = (ds: ChronoPostgresDatastore) => ds[testAccessor]; - - test('cleanup SELECT query uses correct column names', () => { - const accessor = getTestAccessor(dataStore); - const qb = accessor.buildCleanupSelectQuery(new Date()); - const sql = qb.getQuery(); - - expect(sql).toContain('"task"."id"'); - expect(sql).toContain('"task"."status"'); - expect(sql).toContain('"task"."completed_at"'); - }); - - test('cleanup DELETE query targets correct table', () => { - const accessor = getTestAccessor(dataStore); - const qb = accessor.buildCleanupDeleteQuery(['id1', 'id2']); - const sql = qb.getQuery(); - - expect(sql).toContain('DELETE'); - expect(sql).toContain('"chrono_tasks"'); - }); - }); - describe('getEntity', () => { test('returns ChronoTaskEntity class', () => { const entity = ChronoPostgresDatastore.getEntity(); From 51931fb5dd0be270b75c414cb1f6eea5fc3b8d0f Mon Sep 17 00:00:00 2001 From: James Couch Date: Tue, 27 Jan 2026 19:13:24 -0700 Subject: [PATCH 09/21] Simplify claimedAt reset to use null instead of raw SQL Co-Authored-By: Claude (claude-4-opus) --- .../chrono-postgres-datastore/src/chrono-postgres-datastore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts index 89f2642..aa856da 100644 --- a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts +++ b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts @@ -278,7 +278,7 @@ export class ChronoPostgresDatastore status: TaskStatus.PENDING, scheduledAt: retryAt, lastExecutedAt: now, - claimedAt: () => 'NULL', + claimedAt: null, updatedAt: now, retryCount: () => 'retry_count + 1', }) From 9ef562ad3bf7aec8c86fcf6802bf848a90017574 Mon Sep 17 00:00:00 2001 From: James Couch Date: Tue, 27 Jan 2026 19:18:57 -0700 Subject: [PATCH 10/21] Add comprehensive test coverage for postgres datastore New tests: - Concurrent claim safety (SKIP LOCKED verification) - Deferred initialization waiting behavior - EntityManager transaction participation - Claim skips COMPLETED/FAILED tasks - Claim skips non-stale claimed tasks - Retry clears claimedAt - Delete by kind+idempotencyKey edge cases (non-existent, with force) - Cleanup preserves PENDING and FAILED tasks Co-Authored-By: Claude (claude-4-opus) --- .../test/chrono-postgres-datastore.test.ts | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) diff --git a/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts b/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts index 6a3ea59..0f98603 100644 --- a/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts +++ b/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts @@ -45,6 +45,27 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { await expect(ds.initialize(dataSource)).rejects.toThrow('DataSource 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(dataSource); + + // The schedule should complete successfully + const task = await schedulePromise; + expect(task.id).toBeDefined(); + expect(task.data).toEqual({ value: 'deferred' }); + }); }); describe('schedule', () => { @@ -124,6 +145,35 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { }), ).resolves.toMatchObject({ id: task1.id }); }); + + test('uses provided entityManager for transaction participation', async () => { + let taskId: string | undefined; + + // Start a transaction, schedule a task, then rollback + await dataSource + .transaction(async (entityManager) => { + const task = await dataStore.schedule({ + kind: 'test', + data: { value: 'transactional' }, + priority: 1, + when: new Date(), + datastoreOptions: { entityManager }, + }); + taskId = task.id; + + // Task should be visible within the transaction + const found = await entityManager.findOne(ChronoTaskEntity, { where: { id: taskId } }); + expect(found).not.toBeNull(); + + // Rollback by throwing + throw new Error('Rollback'); + }) + .catch(() => {}); + + // Task should not exist after rollback + const found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: taskId! }); + expect(found).toBeNull(); + }); }); describe('claim', () => { @@ -232,6 +282,86 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { 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.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', () => { @@ -323,6 +453,27 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { 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 found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task.id }); + expect(found!.claimedAt).toBeNull(); + }); }); describe('delete', () => { @@ -386,6 +537,34 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { 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 found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task.id }); + expect(found).toBeNull(); + }); }); describe('cleanup', () => { @@ -542,6 +721,56 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { 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 dataSource.query(`UPDATE chrono_tasks SET created_at = NOW() - INTERVAL '2 seconds'`); + await dataSource.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 + expect(await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: pendingTask.id })).not.toBeNull(); + + // Failed task should still exist + expect(await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: failedTask.id })).not.toBeNull(); + + // Completed task should be deleted + expect(await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: completedTask.id })).toBeNull(); + }); }); describe('entity metadata', () => { From 10c1122e3d072484ee9f0f7eaf6cf0916b3d8496 Mon Sep 17 00:00:00 2001 From: James Couch Date: Tue, 27 Jan 2026 20:09:30 -0700 Subject: [PATCH 11/21] chore(chrono-postgres-datastore): move typeorm to peerDependencies only This fixes TypeORM version mismatch issues when the package is used as a dependency. The consumer's TypeORM version will be used instead of bundling a separate copy. Bump to 0.5.2-alpha.0 for testing. Co-Authored-By: Claude (claude-4-opus) --- packages/chrono-postgres-datastore/package.json | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/chrono-postgres-datastore/package.json b/packages/chrono-postgres-datastore/package.json index 3aabcea..0dea12f 100644 --- a/packages/chrono-postgres-datastore/package.json +++ b/packages/chrono-postgres-datastore/package.json @@ -1,6 +1,6 @@ { "name": "@neofinancial/chrono-postgres-datastore", - "version": "0.5.1", + "version": "0.5.2-alpha.0", "description": "PostgreSQL datastore implementation for Chrono task scheduling system", "private": false, "publishConfig": { @@ -41,11 +41,10 @@ }, "devDependencies": { "@neofinancial/chrono": "workspace:*", - "pg": "^8.13.1", - "typeorm": "^0.3.20" + "pg": "^8.13.1" }, "peerDependencies": { - "typeorm": "^0.3.20", - "@neofinancial/chrono": "workspace:*" + "@neofinancial/chrono": ">=0.5.0", + "typeorm": "^0.3.20" } } From 78c6c0df3fe213dca3cf889dbfd2f856814f1326 Mon Sep 17 00:00:00 2001 From: James Couch Date: Wed, 28 Jan 2026 03:56:28 -0700 Subject: [PATCH 12/21] feat(chrono-postgres-datastore): add initialization timeout to prevent silent failures Add Promise.race with configurable timeout to getDataSource() method. If the datastore is not initialized within the timeout period (default 10s), operations will throw an error instead of hanging indefinitely. This prevents silent failures when multiple datastore instances are created and some are never initialized (e.g., due to DI misconfiguration). Co-Authored-By: Claude (claude-4-opus) --- .../src/chrono-postgres-datastore.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts index aa856da..885c065 100644 --- a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts +++ b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts @@ -14,6 +14,7 @@ import { ChronoTaskEntity } from './chrono-task.entity'; 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. */ @@ -27,6 +28,13 @@ export type ChronoPostgresDatastoreConfig = { /** 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 = { @@ -54,6 +62,7 @@ export class ChronoPostgresDatastore cleanupIntervalSeconds: config?.cleanupIntervalSeconds ?? DEFAULT_CLEANUP_INTERVAL_SECONDS, cleanupBatchSize: config?.cleanupBatchSize ?? DEFAULT_CLEANUP_BATCH_SIZE, onCleanupError: config?.onCleanupError, + initializationTimeoutMs: config?.initializationTimeoutMs ?? DEFAULT_INITIALIZATION_TIMEOUT_MS, }; } @@ -87,16 +96,30 @@ export class ChronoPostgresDatastore /** * Asynchronously gets the DataSource. If not yet initialized, - * returns a promise that resolves when initialize() is called. + * waits for initialize() to be called with a timeout. + * @throws Error if initialization times out */ private async getDataSource(): Promise { if (this.dataSource) { return this.dataSource; } - return new Promise((resolve) => { + const initPromise = new Promise((resolve) => { this.dataSourceResolvers.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]); } /** From 52d37d948168c844e3ae64fdd8059c1e33a32022 Mon Sep 17 00:00:00 2001 From: James Couch Date: Wed, 28 Jan 2026 04:00:50 -0700 Subject: [PATCH 13/21] update package json --- packages/chrono-postgres-datastore/package.json | 2 +- pnpm-lock.yaml | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/chrono-postgres-datastore/package.json b/packages/chrono-postgres-datastore/package.json index 0dea12f..4eaaf97 100644 --- a/packages/chrono-postgres-datastore/package.json +++ b/packages/chrono-postgres-datastore/package.json @@ -1,6 +1,6 @@ { "name": "@neofinancial/chrono-postgres-datastore", - "version": "0.5.2-alpha.0", + "version": "0.5.2-alpha.1", "description": "PostgreSQL datastore implementation for Chrono task scheduling system", "private": false, "publishConfig": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3bd379..a692b38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,10 @@ importers: version: 10.1.4 packages/chrono-postgres-datastore: + dependencies: + typeorm: + specifier: ^0.3.20 + version: 0.3.28(mongodb@6.15.0)(pg@8.17.2) devDependencies: '@neofinancial/chrono': specifier: workspace:* @@ -76,9 +80,6 @@ importers: pg: specifier: ^8.13.1 version: 8.17.2 - typeorm: - specifier: ^0.3.20 - version: 0.3.28(mongodb@6.15.0)(pg@8.17.2) packages: From 0a05dd9cc772dd0a54418d41b00dfb1b82c033b6 Mon Sep 17 00:00:00 2001 From: James Couch Date: Wed, 28 Jan 2026 04:12:30 -0700 Subject: [PATCH 14/21] fix(chrono-postgres-datastore): resolve biome lint warnings - Replace non-null assertions with optional chaining in tests - Pass DataSource as parameter to cleanup methods to avoid non-null assertions - Apply biome formatting fixes Co-Authored-By: Claude (claude-4-opus) --- .../src/chrono-postgres-datastore.ts | 19 ++++++---- .../test/chrono-postgres-datastore.test.ts | 35 ++++++++++--------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts index 885c065..ecd9da0 100644 --- a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts +++ b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts @@ -436,30 +436,35 @@ export class ChronoPostgresDatastore } private async cleanupCompletedTasks(): Promise { - if (!this.dataSource) { + const dataSource = this.dataSource; + if (!dataSource) { return; } const cutoffDate = new Date(Date.now() - this.config.completedDocumentTTLSeconds * 1000); - const tasksToDelete = await this.buildCleanupSelectQuery(cutoffDate).getMany(); + const tasksToDelete = await this.buildCleanupSelectQuery(dataSource, cutoffDate).getMany(); if (tasksToDelete.length === 0) { return; } - await this.buildCleanupDeleteQuery(tasksToDelete.map((t) => t.id)).execute(); + await this.buildCleanupDeleteQuery( + dataSource, + tasksToDelete.map((t) => t.id), + ).execute(); } - private buildCleanupSelectQuery(cutoffDate: Date) { - return this.dataSource!.createQueryBuilder(ChronoTaskEntity, 'task') + private buildCleanupSelectQuery(dataSource: DataSource, cutoffDate: Date) { + return dataSource + .createQueryBuilder(ChronoTaskEntity, 'task') .select('task.id') .where('task.status = :status', { status: TaskStatus.COMPLETED }) .andWhere('task.completedAt < :cutoffDate', { cutoffDate }) .limit(this.config.cleanupBatchSize); } - private buildCleanupDeleteQuery(ids: string[]) { - return this.dataSource!.createQueryBuilder().delete().from(ChronoTaskEntity).whereInIds(ids); + private buildCleanupDeleteQuery(dataSource: DataSource, ids: string[]) { + return dataSource.createQueryBuilder().delete().from(ChronoTaskEntity).whereInIds(ids); } } diff --git a/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts b/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts index 0f98603..3a89af4 100644 --- a/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts +++ b/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts @@ -19,7 +19,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { beforeAll(async () => { dataSource = new DataSource({ type: 'postgres', - url: DATABASE_URL!, + url: DATABASE_URL, entities: [ChronoTaskEntity], synchronize: true, dropSchema: true, @@ -100,8 +100,8 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { const found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task.id }); expect(found).not.toBeNull(); - expect(found!.kind).toBe('test'); - expect(found!.data).toEqual({ value: 'stored' }); + expect(found?.kind).toBe('test'); + expect(found?.data).toEqual({ value: 'stored' }); }); test('returns existing task for duplicate idempotency key', async () => { @@ -171,7 +171,8 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { .catch(() => {}); // Task should not exist after rollback - const found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: taskId! }); + expect(taskId).toBeDefined(); + const found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: taskId as string }); expect(found).toBeNull(); }); }); @@ -196,7 +197,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { id: task.id, status: TaskStatus.CLAIMED, }); - expect(claimed!.claimedAt).toBeDefined(); + expect(claimed?.claimedAt).toBeDefined(); }); test('does not claim task scheduled in the future', async () => { @@ -228,8 +229,8 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { 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); + expect(first?.id).toBe(high.id); + expect(second?.id).toBe(low.id); }); test('claims tasks in scheduledAt order (earlier first) when same priority', async () => { @@ -247,7 +248,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { }); const first = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); - expect(first!.id).toBe(earlier.id); + expect(first?.id).toBe(earlier.id); }); test('reclaims stale claimed task', async () => { @@ -268,7 +269,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { // Should be able to reclaim const reclaimed = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 5000 }); - expect(reclaimed!.id).toBe(task.id); + expect(reclaimed?.id).toBe(task.id); }); test('only claims tasks of specified kind', async () => { @@ -319,7 +320,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { // First claim succeeds const first = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 60000 }); - expect(first!.id).toBe(task.id); + 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 }); @@ -349,7 +350,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { ]); // All claims should succeed - const claimedIds = claims.map((c) => c!.id); + const claimedIds = claims.map((c) => c?.id); expect(claimedIds).toHaveLength(5); // All claimed IDs should be unique (no duplicates) @@ -464,7 +465,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { // Claim the task const claimed = await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 60000 }); - expect(claimed!.claimedAt).toBeDefined(); + expect(claimed?.claimedAt).toBeDefined(); // Retry should clear claimedAt const retried = await dataStore.retry(task.id, new Date(Date.now() + 5000)); @@ -472,7 +473,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { // Verify in database const found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task.id }); - expect(found!.claimedAt).toBeNull(); + expect(found?.claimedAt).toBeNull(); }); }); @@ -487,7 +488,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { const deleted = await dataStore.delete(task.id); - expect(deleted!.id).toBe(task.id); + expect(deleted?.id).toBe(task.id); const found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task.id }); expect(found).toBeNull(); }); @@ -503,7 +504,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { const deleted = await dataStore.delete({ kind: 'test', idempotencyKey: 'delete-key' }); - expect(deleted!.id).toBe(task.id); + expect(deleted?.id).toBe(task.id); }); test('throws when deleting non-pending task without force', async () => { @@ -529,7 +530,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { const deleted = await dataStore.delete(task.id, { force: true }); - expect(deleted!.id).toBe(task.id); + expect(deleted?.id).toBe(task.id); }); test('returns undefined for non-existent task with force option', async () => { @@ -561,7 +562,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { const deleted = await dataStore.delete({ kind: 'test', idempotencyKey: 'force-key' }, { force: true }); - expect(deleted!.id).toBe(task.id); + expect(deleted?.id).toBe(task.id); const found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task.id }); expect(found).toBeNull(); }); From 66cd962b0982c2deb30627cb1e99ee6ff5166ca8 Mon Sep 17 00:00:00 2001 From: James Couch Date: Fri, 30 Jan 2026 06:04:56 -0700 Subject: [PATCH 15/21] fix(chrono-postgres-datastore): remove lastExecutedAt from retry() lastExecutedAt should only be set when task execution finishes (complete/fail), not when scheduling a retry. This aligns with the mongo datastore behavior. Co-Authored-By: Claude (claude-4-opus) --- .../chrono-postgres-datastore/src/chrono-postgres-datastore.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts index ecd9da0..5a48459 100644 --- a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts +++ b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts @@ -300,7 +300,6 @@ export class ChronoPostgresDatastore .set({ status: TaskStatus.PENDING, scheduledAt: retryAt, - lastExecutedAt: now, claimedAt: null, updatedAt: now, retryCount: () => 'retry_count + 1', From 30d0a62b8a3b05258f585346c8cbf35e85892afa Mon Sep 17 00:00:00 2001 From: James Couch Date: Fri, 30 Jan 2026 06:06:01 -0700 Subject: [PATCH 16/21] declaration setting moved up --- packages/chrono-postgres-datastore/tsconfig.json | 2 -- tsconfig.base.json | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/chrono-postgres-datastore/tsconfig.json b/packages/chrono-postgres-datastore/tsconfig.json index 83d5159..8a4ac6a 100644 --- a/packages/chrono-postgres-datastore/tsconfig.json +++ b/packages/chrono-postgres-datastore/tsconfig.json @@ -3,8 +3,6 @@ "compilerOptions": { "outDir": "./build", "rootDir": "./src", - "declaration": true, - "declarationMap": true, "emitDecoratorMetadata": true, "experimentalDecorators": true }, 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, From 41b5ebb3963546ff3f6e02e483bca4d5ea3b898b Mon Sep 17 00:00:00 2001 From: James Couch Date: Fri, 30 Jan 2026 06:42:48 -0700 Subject: [PATCH 17/21] refactor(chrono-postgres-datastore): replace TypeORM with raw pg library BREAKING CHANGE: Replace TypeORM DataSource with pg Pool interface - Replace initialize(dataSource: DataSource) with initialize(pool: Pool) - Replace datastoreOptions.entityManager with datastoreOptions.client (PoolClient) - Remove ChronoTaskEntity, add ChronoTaskRow type - Export migration SQL (MIGRATION_UP_SQL, MIGRATION_DOWN_SQL) and helpers - Use prepared statements for all queries - Remove typeorm peer dependency, add pg as direct dependency This simplifies the implementation and removes the heavy TypeORM dependency in favor of the lightweight pg library with explicit SQL queries. Co-Authored-By: Claude (claude-4-opus) --- .../chrono-postgres-datastore/package.json | 8 +- .../src/chrono-postgres-datastore.ts | 387 +++++------ .../src/chrono-task.entity.ts | 49 -- .../chrono-postgres-datastore/src/index.ts | 3 +- .../src/migration.ts | 61 ++ .../chrono-postgres-datastore/src/queries.ts | 95 +++ .../chrono-postgres-datastore/src/types.ts | 20 + .../test/chrono-postgres-datastore.test.ts | 204 +++--- .../chrono-postgres-datastore/tsconfig.json | 4 +- pnpm-lock.yaml | 656 +++--------------- 10 files changed, 522 insertions(+), 965 deletions(-) delete mode 100644 packages/chrono-postgres-datastore/src/chrono-task.entity.ts create mode 100644 packages/chrono-postgres-datastore/src/migration.ts create mode 100644 packages/chrono-postgres-datastore/src/queries.ts create mode 100644 packages/chrono-postgres-datastore/src/types.ts diff --git a/packages/chrono-postgres-datastore/package.json b/packages/chrono-postgres-datastore/package.json index 4eaaf97..242df93 100644 --- a/packages/chrono-postgres-datastore/package.json +++ b/packages/chrono-postgres-datastore/package.json @@ -39,12 +39,14 @@ "typecheck": "tsc -p ./tsconfig.json --noEmit", "test": "NODE_ENV=test TZ=UTC vitest run" }, + "dependencies": { + "pg": "^8.13.1" + }, "devDependencies": { "@neofinancial/chrono": "workspace:*", - "pg": "^8.13.1" + "@types/pg": "^8.11.0" }, "peerDependencies": { - "@neofinancial/chrono": ">=0.5.0", - "typeorm": "^0.3.20" + "@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 index 5a48459..efaac4f 100644 --- a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts +++ b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts @@ -8,8 +8,23 @@ import { type TaskMappingBase, TaskStatus, } from '@neofinancial/chrono'; -import { Brackets, type DataSource, type EntityManager } from 'typeorm'; -import { ChronoTaskEntity } from './chrono-task.entity'; +import type { Pool, PoolClient } from 'pg'; +import { + CLAIM_SELECT_QUERY, + CLAIM_UPDATE_QUERY, + CLEANUP_DELETE_QUERY, + CLEANUP_SELECT_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; @@ -39,10 +54,10 @@ export type ChronoPostgresDatastoreConfig = { export type PostgresDatastoreOptions = { /** - * Optional EntityManager for participating in external transactions. - * When provided, all operations will use this manager instead of creating new queries. + * Optional PoolClient for participating in external transactions. + * When provided, all operations will use this client instead of acquiring from the pool. */ - entityManager?: EntityManager; + client?: PoolClient; }; type ResolvedConfig = Required> & @@ -52,8 +67,8 @@ export class ChronoPostgresDatastore implements Datastore { private config: ResolvedConfig; - private dataSource: DataSource | undefined; - private dataSourceResolvers: Array<(ds: DataSource) => void> = []; + private pool: Pool | undefined; + private poolResolvers: Array<(pool: Pool) => void> = []; private lastCleanupTime: Date = new Date(0); constructor(config?: ChronoPostgresDatastoreConfig) { @@ -67,45 +82,37 @@ export class ChronoPostgresDatastore } /** - * Initializes the datastore with a TypeORM DataSource. + * Initializes the datastore with a pg Pool. * Must be called before any operations can be performed. * - * @param dataSource - The TypeORM DataSource connected to PostgreSQL + * @param pool - The pg Pool connected to PostgreSQL */ - async initialize(dataSource: DataSource): Promise { - if (this.dataSource) { - throw new Error('DataSource already initialized'); + async initialize(pool: Pool): Promise { + if (this.pool) { + throw new Error('Pool already initialized'); } - this.dataSource = dataSource; + this.pool = pool; - // Resolve any pending operations waiting for the dataSource - const resolvers = this.dataSourceResolvers.splice(0); + // Resolve any pending operations waiting for the pool + const resolvers = this.poolResolvers.splice(0); for (const resolve of resolvers) { - resolve(dataSource); + resolve(pool); } } /** - * Returns the entity class for use with TypeORM. - * Useful for registering the entity with a DataSource. - */ - static getEntity(): typeof ChronoTaskEntity { - return ChronoTaskEntity; - } - - /** - * Asynchronously gets the DataSource. If not yet initialized, + * 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 getDataSource(): Promise { - if (this.dataSource) { - return this.dataSource; + private async getPool(): Promise { + if (this.pool) { + return this.pool; } - const initPromise = new Promise((resolve) => { - this.dataSourceResolvers.push(resolve); + const initPromise = new Promise((resolve) => { + this.poolResolvers.push(resolve); }); const timeoutPromise = new Promise((_, reject) => { @@ -123,95 +130,104 @@ export class ChronoPostgresDatastore } /** - * Gets the EntityManager to use for operations. - * Uses the provided manager from options if available, otherwise uses the DataSource's manager. + * Gets the client to use for operations. + * Uses the provided client from options if available, otherwise uses the pool. */ - private getManager(options?: PostgresDatastoreOptions): EntityManager { - if (options?.entityManager) { - return options.entityManager; + private getQueryable(options?: PostgresDatastoreOptions): Pool | PoolClient { + if (options?.client) { + return options.client; } - if (!this.dataSource) { - throw new Error('DataSource not initialized'); + if (!this.pool) { + throw new Error('Pool not initialized'); } - return this.dataSource.manager; + return this.pool; } async schedule( input: ScheduleInput, ): Promise> { - await this.getDataSource(); - const manager = this.getManager(input.datastoreOptions); - - const entity = manager.create(ChronoTaskEntity, { - kind: String(input.kind), - status: TaskStatus.PENDING, - data: input.data as Record, - priority: input.priority ?? 0, - idempotencyKey: input.idempotencyKey ?? null, - originalScheduleDate: input.when, - scheduledAt: input.when, - retryCount: 0, - }); + await this.getPool(); + const queryable = 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 saved = await manager.save(ChronoTaskEntity, entity); - return this.toTask(saved); + 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, manager); + return this.handleScheduleError(error, input.idempotencyKey, queryable); } } private async handleScheduleError( error: unknown, idempotencyKey: string | undefined, - manager: EntityManager, + queryable: Pool | PoolClient, ): Promise> { const isIdempotencyConflict = this.isUniqueViolation(error) && idempotencyKey; if (!isIdempotencyConflict) { throw error; } - const existing = await manager.findOne(ChronoTaskEntity, { - where: { idempotencyKey }, - }); + const result = await queryable.query(FIND_BY_IDEMPOTENCY_KEY_QUERY, [idempotencyKey]); + const row = result.rows[0]; - if (!existing) { + if (!row) { throw new Error( `Failed to find existing task with idempotency key ${idempotencyKey} despite unique constraint error`, ); } - return this.toTask(existing); + return this.toTask(row); } async delete>( key: DeleteInput, options?: DeleteOptions, ): Promise | undefined> { - const dataSource = await this.getDataSource(); + const pool = await this.getPool(); - const qb = dataSource.createQueryBuilder().delete().from(ChronoTaskEntity).returning('*'); + let query: string; + let values: unknown[]; if (typeof key === 'string') { - qb.where('id = :id', { id: key }); + if (options?.force) { + query = DELETE_BY_ID_FORCE_QUERY; + values = [key]; + } else { + query = DELETE_BY_ID_QUERY; + values = [key, TaskStatus.PENDING]; + } } else { - qb.where('kind = :kind AND idempotency_key = :idempotencyKey', { - kind: String(key.kind), - idempotencyKey: key.idempotencyKey, - }); - } - - if (!options?.force) { - qb.andWhere('status = :status', { status: TaskStatus.PENDING }); + 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 qb.execute(); - const [rawRow] = result.raw as Record[]; + const result = await pool.query(query, values); + const row = result.rows[0]; - if (rawRow) { - return this.toTask(this.fromRaw(rawRow)); + if (row) { + return this.toTask(row); } if (options?.force) { @@ -233,53 +249,47 @@ export class ChronoPostgresDatastore async claim>( input: ClaimTaskInput, ): Promise | undefined> { - const dataSource = await this.getDataSource(); + const pool = await this.getPool(); const now = new Date(); const staleThreshold = new Date(now.getTime() - input.claimStaleTimeoutMs); // Use a transaction to atomically select and update - const result = await dataSource.transaction(async (manager) => { + const client = await pool.connect(); + let result: Task | undefined; + + try { + await client.query('BEGIN'); + // Find and lock the next claimable task - const taskToClaimQuery = manager - .createQueryBuilder(ChronoTaskEntity, 'task') - .where('task.kind = :kind', { kind: String(input.kind) }) - .andWhere('task.scheduledAt <= :now', { now }) - .andWhere( - new Brackets((qb) => { - qb.where('task.status = :pending', { pending: TaskStatus.PENDING }).orWhere( - 'task.status = :claimed AND task.claimedAt <= :staleThreshold', - { claimed: TaskStatus.CLAIMED, staleThreshold }, - ); - }), - ) - .orderBy('task.priority', 'DESC') - .addOrderBy('task.scheduledAt', 'ASC') - .limit(1) - .setLock('pessimistic_write') - .setOnLocked('skip_locked'); - - const taskToClaim = await taskToClaimQuery.getOne(); - - if (!taskToClaim) { - return undefined; + const selectResult = await client.query(CLAIM_SELECT_QUERY, [ + String(input.kind), + now, + TaskStatus.PENDING, + TaskStatus.CLAIMED, + staleThreshold, + ]); + + const taskToClaim = selectResult.rows[0]; + if (taskToClaim) { + // Update the task to claim it + const updateResult = await client.query(CLAIM_UPDATE_QUERY, [ + TaskStatus.CLAIMED, + now, + now, + taskToClaim.id, + ]); + + const updatedRow = updateResult.rows[0]; + result = updatedRow ? this.toTask(updatedRow) : undefined; } - // Update the task to claim it - const updateResult = await manager - .createQueryBuilder() - .update(ChronoTaskEntity) - .set({ - status: TaskStatus.CLAIMED, - claimedAt: now, - updatedAt: now, - }) - .where('id = :id', { id: taskToClaim.id }) - .returning('*') - .execute(); - - const [rawRow] = updateResult.raw as Record[]; - return rawRow ? this.toTask(this.fromRaw(rawRow)) : undefined; - }); + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } // Opportunistic cleanup runs after claim completes, outside the transaction this.maybeCleanupCompletedTasks(); @@ -291,74 +301,41 @@ export class ChronoPostgresDatastore taskId: string, retryAt: Date, ): Promise> { - const dataSource = await this.getDataSource(); + const pool = await this.getPool(); const now = new Date(); - const result = await dataSource - .createQueryBuilder() - .update(ChronoTaskEntity) - .set({ - status: TaskStatus.PENDING, - scheduledAt: retryAt, - claimedAt: null, - updatedAt: now, - retryCount: () => 'retry_count + 1', - }) - .where('id = :id', { id: taskId }) - .returning('*') - .execute(); - - return this.extractUpdatedTaskOrThrow(result.raw, taskId); + const result = await pool.query(RETRY_QUERY, [TaskStatus.PENDING, retryAt, now, taskId]); + + return this.extractUpdatedTaskOrThrow(result.rows, taskId); } async complete(taskId: string): Promise> { - const dataSource = await this.getDataSource(); + const pool = await this.getPool(); const now = new Date(); - const result = await dataSource - .createQueryBuilder() - .update(ChronoTaskEntity) - .set({ - status: TaskStatus.COMPLETED, - completedAt: now, - lastExecutedAt: now, - updatedAt: now, - }) - .where('id = :id', { id: taskId }) - .returning('*') - .execute(); - - return this.extractUpdatedTaskOrThrow(result.raw, taskId); + const result = await pool.query(COMPLETE_QUERY, [TaskStatus.COMPLETED, now, now, now, taskId]); + + return this.extractUpdatedTaskOrThrow(result.rows, taskId); } async fail(taskId: string): Promise> { - const dataSource = await this.getDataSource(); + const pool = await this.getPool(); const now = new Date(); - const result = await dataSource - .createQueryBuilder() - .update(ChronoTaskEntity) - .set({ - status: TaskStatus.FAILED, - lastExecutedAt: now, - updatedAt: now, - }) - .where('id = :id', { id: taskId }) - .returning('*') - .execute(); - - return this.extractUpdatedTaskOrThrow(result.raw, taskId); + const result = await pool.query(FAIL_QUERY, [TaskStatus.FAILED, now, now, taskId]); + + return this.extractUpdatedTaskOrThrow(result.rows, taskId); } private extractUpdatedTaskOrThrow( - raw: Record[], + rows: ChronoTaskRow[], taskId: string, ): Task { - const [rawRow] = raw; - if (!rawRow) { + const row = rows[0]; + if (!row) { throw new Error(`Task with ID ${taskId} not found`); } - return this.toTask(this.fromRaw(rawRow)); + return this.toTask(row); } /** @@ -371,45 +348,22 @@ export class ChronoPostgresDatastore } /** - * Maps a raw PostgreSQL row (snake_case columns) to an entity-like object (camelCase properties). - * TypeORM's RETURNING clause returns raw rows without column name mapping. - */ - private fromRaw(raw: Record): ChronoTaskEntity { - return { - id: raw.id as string, - kind: raw.kind as string, - status: raw.status as string, - data: raw.data as Record, - priority: raw.priority as number | null, - idempotencyKey: raw.idempotency_key as string | null, - originalScheduleDate: raw.original_schedule_date as Date, - scheduledAt: raw.scheduled_at as Date, - claimedAt: raw.claimed_at as Date | null, - completedAt: raw.completed_at as Date | null, - lastExecutedAt: raw.last_executed_at as Date | null, - retryCount: raw.retry_count as number, - createdAt: raw.created_at as Date, - updatedAt: raw.updated_at as Date, - }; - } - - /** - * Converts a ChronoTaskEntity to a Task object. + * Converts a database row to a Task object. */ - private toTask(entity: ChronoTaskEntity): Task { + private toTask(row: ChronoTaskRow): Task { return { - id: entity.id, - kind: entity.kind as TaskKind, - status: entity.status as TaskStatus, - data: entity.data as TaskMapping[TaskKind], - priority: entity.priority ?? undefined, - idempotencyKey: entity.idempotencyKey ?? undefined, - originalScheduleDate: entity.originalScheduleDate, - scheduledAt: entity.scheduledAt, - claimedAt: entity.claimedAt ?? undefined, - completedAt: entity.completedAt ?? undefined, - lastExecutedAt: entity.lastExecutedAt ?? undefined, - retryCount: entity.retryCount, + 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, }; } @@ -435,35 +389,24 @@ export class ChronoPostgresDatastore } private async cleanupCompletedTasks(): Promise { - const dataSource = this.dataSource; - if (!dataSource) { + const pool = this.pool; + if (!pool) { return; } const cutoffDate = new Date(Date.now() - this.config.completedDocumentTTLSeconds * 1000); - const tasksToDelete = await this.buildCleanupSelectQuery(dataSource, cutoffDate).getMany(); + const selectResult = await pool.query<{ id: string }>(CLEANUP_SELECT_QUERY, [ + TaskStatus.COMPLETED, + cutoffDate, + this.config.cleanupBatchSize, + ]); - if (tasksToDelete.length === 0) { + if (selectResult.rows.length === 0) { return; } - await this.buildCleanupDeleteQuery( - dataSource, - tasksToDelete.map((t) => t.id), - ).execute(); - } - - private buildCleanupSelectQuery(dataSource: DataSource, cutoffDate: Date) { - return dataSource - .createQueryBuilder(ChronoTaskEntity, 'task') - .select('task.id') - .where('task.status = :status', { status: TaskStatus.COMPLETED }) - .andWhere('task.completedAt < :cutoffDate', { cutoffDate }) - .limit(this.config.cleanupBatchSize); - } - - private buildCleanupDeleteQuery(dataSource: DataSource, ids: string[]) { - return dataSource.createQueryBuilder().delete().from(ChronoTaskEntity).whereInIds(ids); + const ids = selectResult.rows.map((row) => row.id); + await pool.query(CLEANUP_DELETE_QUERY, [ids]); } } diff --git a/packages/chrono-postgres-datastore/src/chrono-task.entity.ts b/packages/chrono-postgres-datastore/src/chrono-task.entity.ts deleted file mode 100644 index d0dd322..0000000 --- a/packages/chrono-postgres-datastore/src/chrono-task.entity.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; - -@Entity('chrono_tasks') -@Index('idx_chrono_tasks_claim', ['kind', 'status', 'scheduledAt', 'priority', 'claimedAt']) -@Index('idx_chrono_tasks_cleanup', ['status', 'completedAt']) -@Index('idx_chrono_tasks_idempotency', ['idempotencyKey'], { unique: true, where: '"idempotency_key" IS NOT NULL' }) -export class ChronoTaskEntity { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ type: 'varchar', length: 255 }) - kind!: string; - - @Column({ type: 'varchar', length: 20, default: 'PENDING' }) - status!: string; - - @Column({ type: 'jsonb' }) - data!: Record; - - @Column({ type: 'integer', nullable: true, default: 0 }) - priority!: number | null; - - @Column({ name: 'idempotency_key', type: 'varchar', length: 255, nullable: true }) - idempotencyKey!: string | null; - - @Column({ name: 'original_schedule_date', type: 'timestamptz' }) - originalScheduleDate!: Date; - - @Column({ name: 'scheduled_at', type: 'timestamptz' }) - scheduledAt!: Date; - - @Column({ name: 'claimed_at', type: 'timestamptz', nullable: true }) - claimedAt!: Date | null; - - @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) - completedAt!: Date | null; - - @Column({ name: 'last_executed_at', type: 'timestamptz', nullable: true }) - lastExecutedAt!: Date | null; - - @Column({ name: 'retry_count', type: 'integer', default: 0 }) - retryCount!: number; - - @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) - createdAt!: Date; - - @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) - updatedAt!: Date; -} diff --git a/packages/chrono-postgres-datastore/src/index.ts b/packages/chrono-postgres-datastore/src/index.ts index 340ec1e..d6cd0ae 100644 --- a/packages/chrono-postgres-datastore/src/index.ts +++ b/packages/chrono-postgres-datastore/src/index.ts @@ -3,4 +3,5 @@ export { type ChronoPostgresDatastoreConfig, type PostgresDatastoreOptions, } from './chrono-postgres-datastore'; -export { ChronoTaskEntity } from './chrono-task.entity'; +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..1b849d1 --- /dev/null +++ b/packages/chrono-postgres-datastore/src/queries.ts @@ -0,0 +1,95 @@ +/** + * 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_SELECT_QUERY = ` + SELECT * FROM chrono_tasks + WHERE kind = $1 + AND scheduled_at <= $2 + AND (status = $3 OR (status = $4 AND claimed_at <= $5)) + ORDER BY priority DESC, scheduled_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED +`; + +export const CLAIM_UPDATE_QUERY = ` + UPDATE chrono_tasks + SET status = $1, claimed_at = $2, updated_at = $3 + WHERE id = $4 + 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: find old completed tasks +export const CLEANUP_SELECT_QUERY = ` + SELECT id FROM chrono_tasks + WHERE status = $1 AND completed_at < $2 + LIMIT $3 +`; + +// Cleanup: delete tasks by IDs +export const CLEANUP_DELETE_QUERY = ` + DELETE FROM chrono_tasks + WHERE id = ANY($1) +`; 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 index 3a89af4..24ebf10 100644 --- a/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts +++ b/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts @@ -1,9 +1,10 @@ import { TaskStatus } from '@neofinancial/chrono'; -import { DataSource } from 'typeorm'; +import { Pool } from 'pg'; import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { ChronoPostgresDatastore } from '../src/chrono-postgres-datastore'; -import { ChronoTaskEntity } from '../src/chrono-task.entity'; +import { MIGRATION_DOWN_SQL, MIGRATION_UP_SQL } from '../src/migration'; +import type { ChronoTaskRow } from '../src/types'; const DATABASE_URL = process.env.DATABASE_URL; @@ -13,37 +14,34 @@ type TaskMapping = { }; describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { - let dataSource: DataSource; + let pool: Pool; let dataStore: ChronoPostgresDatastore; beforeAll(async () => { - dataSource = new DataSource({ - type: 'postgres', - url: DATABASE_URL, - entities: [ChronoTaskEntity], - synchronize: true, - dropSchema: true, - }); - await dataSource.initialize(); + 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(dataSource); + await dataStore.initialize(pool); }); afterAll(async () => { - await dataSource?.destroy(); + await pool?.end(); }); beforeEach(async () => { - await dataSource.getRepository(ChronoTaskEntity).clear(); + await pool.query('DELETE FROM chrono_tasks'); }); describe('initialize', () => { test('throws if already initialized', async () => { const ds = new ChronoPostgresDatastore(); - await ds.initialize(dataSource); + await ds.initialize(pool); - await expect(ds.initialize(dataSource)).rejects.toThrow('DataSource already initialized'); + await expect(ds.initialize(pool)).rejects.toThrow('Pool already initialized'); }); test('operations wait for deferred initialization', async () => { @@ -59,7 +57,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { // Initialize after a small delay await new Promise((resolve) => setTimeout(resolve, 10)); - await ds.initialize(dataSource); + await ds.initialize(pool); // The schedule should complete successfully const task = await schedulePromise; @@ -98,10 +96,10 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { when: new Date(), }); - const found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task.id }); - expect(found).not.toBeNull(); - expect(found?.kind).toBe('test'); - expect(found?.data).toEqual({ value: 'stored' }); + const result = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [task.id]); + expect(result.rows.length).toBe(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 () => { @@ -146,34 +144,37 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { ).resolves.toMatchObject({ id: task1.id }); }); - test('uses provided entityManager for transaction participation', async () => { + test('uses provided client for transaction participation', async () => { let taskId: string | undefined; // Start a transaction, schedule a task, then rollback - await dataSource - .transaction(async (entityManager) => { - const task = await dataStore.schedule({ - kind: 'test', - data: { value: 'transactional' }, - priority: 1, - when: new Date(), - datastoreOptions: { entityManager }, - }); - taskId = task.id; - - // Task should be visible within the transaction - const found = await entityManager.findOne(ChronoTaskEntity, { where: { id: taskId } }); - expect(found).not.toBeNull(); + const client = await pool.connect(); + try { + await client.query('BEGIN'); - // Rollback by throwing - throw new Error('Rollback'); - }) - .catch(() => {}); + 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 found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: taskId as string }); - expect(found).toBeNull(); + const result = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [taskId]); + expect(result.rows.length).toBe(0); }); }); @@ -263,9 +264,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { await dataStore.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); // Backdate claimedAt to make it stale - await dataSource.query(`UPDATE chrono_tasks SET claimed_at = NOW() - INTERVAL '10 seconds' WHERE id = $1`, [ - task.id, - ]); + 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 }); @@ -472,8 +471,8 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { expect(retried.claimedAt).toBeUndefined(); // Verify in database - const found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task.id }); - expect(found?.claimedAt).toBeNull(); + const result = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [task.id]); + expect(result.rows[0].claimed_at).toBeNull(); }); }); @@ -489,8 +488,8 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { const deleted = await dataStore.delete(task.id); expect(deleted?.id).toBe(task.id); - const found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task.id }); - expect(found).toBeNull(); + 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 () => { @@ -563,15 +562,15 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { const deleted = await dataStore.delete({ kind: 'test', idempotencyKey: 'force-key' }, { force: true }); expect(deleted?.id).toBe(task.id); - const found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task.id }); - expect(found).toBeNull(); + 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: Parameters[0]) => { const ds = new ChronoPostgresDatastore(config); - await ds.initialize(dataSource); + await ds.initialize(pool); return ds; }; @@ -592,15 +591,13 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { await ds.complete(task.id); // Backdate to be older than TTL - await dataSource.query(`UPDATE chrono_tasks SET completed_at = NOW() - INTERVAL '2 seconds' WHERE id = $1`, [ - task.id, - ]); + 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 found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task.id }); - expect(found).toBeNull(); + 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 () => { @@ -620,8 +617,8 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { await ds.claim({ kind: 'test', claimStaleTimeoutMs: 1000 }); await waitForCleanup(); - const found = await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task.id }); - expect(found).not.toBeNull(); + 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 () => { @@ -638,14 +635,13 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { when: new Date(Date.now() - 1000), }); await ds.complete(task1.id); - await dataSource.query(`UPDATE chrono_tasks SET completed_at = NOW() - INTERVAL '2 seconds' WHERE id = $1`, [ - 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(); - expect(await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task1.id })).toBeNull(); + 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({ @@ -655,14 +651,13 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { when: new Date(Date.now() - 1000), }); await ds.complete(task2.id); - await dataSource.query(`UPDATE chrono_tasks SET completed_at = NOW() - INTERVAL '2 seconds' WHERE id = $1`, [ - 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(); - expect(await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: task2.id })).not.toBeNull(); + 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 () => { @@ -686,15 +681,15 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { await Promise.all(tasks.map((t) => ds.complete(t.id))); // Backdate all - await dataSource.query(`UPDATE chrono_tasks SET completed_at = NOW() - INTERVAL '2 seconds' WHERE status = $1`, [ + 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 remaining = await dataSource.getRepository(ChronoTaskEntity).count(); - expect(remaining).toBe(2); // Only 2 deleted (batch size), 2 remain + 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 () => { @@ -754,8 +749,8 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { await ds.complete(completedTask.id); // Backdate all tasks to be older than TTL - await dataSource.query(`UPDATE chrono_tasks SET created_at = NOW() - INTERVAL '2 seconds'`); - await dataSource.query(`UPDATE chrono_tasks SET completed_at = NOW() - INTERVAL '2 seconds' WHERE status = $1`, [ + 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, ]); @@ -764,57 +759,40 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { await waitForCleanup(); // Pending task should still exist - expect(await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: pendingTask.id })).not.toBeNull(); + 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 - expect(await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: failedTask.id })).not.toBeNull(); + 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 - expect(await dataSource.getRepository(ChronoTaskEntity).findOneBy({ id: completedTask.id })).toBeNull(); + const completedResult = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [completedTask.id]); + expect(completedResult.rows.length).toBe(0); }); }); - describe('entity metadata', () => { - test('table name is chrono_tasks', () => { - const metadata = dataSource.getMetadata(ChronoTaskEntity); - expect(metadata.tableName).toBe('chrono_tasks'); - }); - - test('column mappings are correct', () => { - const metadata = dataSource.getMetadata(ChronoTaskEntity); - - const columnMappings: Record = { - completedAt: 'completed_at', - scheduledAt: 'scheduled_at', - claimedAt: 'claimed_at', - idempotencyKey: 'idempotency_key', - originalScheduleDate: 'original_schedule_date', - lastExecutedAt: 'last_executed_at', - retryCount: 'retry_count', - createdAt: 'created_at', - updatedAt: 'updated_at', - }; - - for (const [property, dbColumn] of Object.entries(columnMappings)) { - const column = metadata.findColumnWithPropertyName(property); - expect(column?.databaseName, `${property} should map to ${dbColumn}`).toBe(dbColumn); - } - }); - - test('indexes exist', () => { - const metadata = dataSource.getMetadata(ChronoTaskEntity); - const indexNames = metadata.indices.map((i) => i.name); - + 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'); }); }); - - describe('getEntity', () => { - test('returns ChronoTaskEntity class', () => { - const entity = ChronoPostgresDatastore.getEntity(); - expect(entity).toBe(ChronoTaskEntity); - }); - }); }); diff --git a/packages/chrono-postgres-datastore/tsconfig.json b/packages/chrono-postgres-datastore/tsconfig.json index 8a4ac6a..426c048 100644 --- a/packages/chrono-postgres-datastore/tsconfig.json +++ b/packages/chrono-postgres-datastore/tsconfig.json @@ -2,9 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./build", - "rootDir": "./src", - "emitDecoratorMetadata": true, - "experimentalDecorators": true + "rootDir": "./src" }, "include": ["src/**/*"], "exclude": ["node_modules", "build", "test"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a692b38..1e59d67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,16 +70,16 @@ importers: packages/chrono-postgres-datastore: dependencies: - typeorm: - specifier: ^0.3.20 - version: 0.3.28(mongodb@6.15.0)(pg@8.17.2) + pg: + specifier: ^8.13.1 + version: 8.17.2 devDependencies: '@neofinancial/chrono': specifier: workspace:* version: link:../chrono-core - pg: - specifier: ^8.13.1 - version: 8.17.2 + '@types/pg': + specifier: ^8.11.0 + version: 8.16.0 packages: @@ -346,95 +346,91 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} - '@oxc-project/types@0.110.0': - resolution: {integrity: sha512-6Ct21OIlrEnFEJk5LT4e63pk3btsI6/TusD/GStLi7wYlGJNOl1GI9qvXAnRAxQU9zqA2Oz+UwhfTOU2rPZVow==} - - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} + '@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-rc.1': - resolution: {integrity: sha512-He6ZoCfv5D7dlRbrhNBkuMVIHd0GDnjJwbICE1OWpG7G3S2gmJ+eXkcNLJjzjNDpeI2aRy56ou39AJM9AD8YFA==} + '@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-rc.1': - resolution: {integrity: sha512-YzJdn08kSOXnj85ghHauH2iHpOJ6eSmstdRTLyaziDcUxe9SyQJgGyx/5jDIhDvtOcNvMm2Ju7m19+S/Rm1jFg==} + '@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-rc.1': - resolution: {integrity: sha512-cIvAbqM+ZVV6lBSKSBtlNqH5iCiW933t1q8j0H66B3sjbe8AxIRetVqfGgcHcJtMzBIkIALlL9fcDrElWLJQcQ==} + '@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-rc.1': - resolution: {integrity: sha512-rVt+B1B/qmKwCl1XD02wKfgh3vQPXRXdB/TicV2w6g7RVAM1+cZcpigwhLarqiVCxDObFZ7UgXCxPC7tpDoRog==} + '@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-rc.1': - resolution: {integrity: sha512-69YKwJJBOFprQa1GktPgbuBOfnn+EGxu8sBJ1TjPER+zhSpYeaU4N07uqmyBiksOLGXsMegymuecLobfz03h8Q==} + '@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-rc.1': - resolution: {integrity: sha512-9JDhHUf3WcLfnViFWm+TyorqUtnSAHaCzlSNmMOq824prVuuzDOK91K0Hl8DUcEb9M5x2O+d2/jmBMsetRIn3g==} + '@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-rc.1': - resolution: {integrity: sha512-UvApLEGholmxw/HIwmUnLq3CwdydbhaHHllvWiCTNbyGom7wTwOtz5OAQbAKZYyiEOeIXZNPkM7nA4Dtng7CLw==} + '@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-rc.1': - resolution: {integrity: sha512-uVctNgZHiGnJx5Fij7wHLhgw4uyZBVi6mykeWKOqE7bVy9Hcxn0fM/IuqdMwk6hXlaf9fFShDTFz2+YejP+x0A==} + '@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-rc.1': - resolution: {integrity: sha512-T6Eg0xWwcxd/MzBcuv4Z37YVbUbJxy5cMNnbIt/Yr99wFwli30O4BPlY8hKeGyn6lWNtU0QioBS46lVzDN38bg==} + '@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-rc.1': - resolution: {integrity: sha512-PuGZVS2xNJyLADeh2F04b+Cz4NwvpglbtWACgrDOa5YDTEHKwmiTDjoD5eZ9/ptXtcpeFrMqD2H4Zn33KAh1Eg==} + '@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-rc.1': - resolution: {integrity: sha512-2mOxY562ihHlz9lEXuaGEIDCZ1vI+zyFdtsoa3M62xsEunDXQE+DVPO4S4x5MPK9tKulG/aFcA/IH5eVN257Cw==} + '@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-rc.1': - resolution: {integrity: sha512-oQVOP5cfAWZwRD0Q3nGn/cA9FW3KhMMuQ0NIndALAe6obqjLhqYVYDiGGRGrxvnjJsVbpLwR14gIUYnpIcHR1g==} + '@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-x64-msvc@1.0.0-rc.1': - resolution: {integrity: sha512-Ydsxxx++FNOuov3wCBPaYjZrEvKOOGq3k+BF4BPridhg2pENfitSRD2TEuQ8i33bp5VptuNdC9IzxRKU031z5A==} + '@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-rc.1': - resolution: {integrity: sha512-UTBjtTxVOhodhzFVp/ayITaTETRHPUPYZPXQe0WU0wOgxghMojXxYjOiPOauKIYNWJAWS2fd7gJgGQK8GU8vDA==} + '@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==} @@ -531,9 +527,6 @@ packages: cpu: [x64] os: [win32] - '@sqltools/formatter@1.2.5': - resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==} - '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -552,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==} @@ -611,10 +607,6 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} - app-root-path@3.1.0: - resolution: {integrity: sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==} - engines: {node: '>= 6.0.0'} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -626,10 +618,6 @@ packages: async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - b4a@1.6.7: resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} @@ -639,9 +627,6 @@ packages: bare-events@2.5.4: resolution: {integrity: sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - birpc@2.6.1: resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==} @@ -655,25 +640,10 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - buffer@6.0.3: - resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -690,10 +660,6 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -708,9 +674,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - dayjs@1.11.19: - resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} - debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -729,22 +692,10 @@ packages: supports-color: optional: true - dedent@1.7.1: - resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} @@ -752,10 +703,6 @@ packages: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} engines: {node: '>=0.3.1'} - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} - engines: {node: '>=12'} - dts-resolver@2.1.2: resolution: {integrity: sha512-xeXHBQkn2ISSXxbJWD828PFjtyg+/UrMDo7W4Ffcs7+YWCquxU8YjV1KoxuiL+eJ5pg3ll+bC6flVv61L3LKZg==} engines: {node: '>=20.18.0'} @@ -765,10 +712,6 @@ packages: oxc-resolver: optional: true - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -782,30 +725,14 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - esbuild@0.25.1: resolution: {integrity: sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==} engines: {node: '>=18'} hasBin: true - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -845,10 +772,6 @@ packages: debug: optional: true - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} - foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -858,52 +781,14 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - hasBin: true - glob@11.0.1: resolution: {integrity: sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==} engines: {node: 20 || >=22} hasBin: true - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -916,33 +801,13 @@ packages: engines: {node: '>=18'} hasBin: true - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jackspeak@4.1.0: resolution: {integrity: sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==} engines: {node: 20 || >=22} @@ -972,9 +837,6 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.0.2: resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==} engines: {node: 20 || >=22} @@ -989,10 +851,6 @@ packages: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - memory-pager@1.5.0: resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} @@ -1000,10 +858,6 @@ packages: resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} engines: {node: 20 || >=22} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} - minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -1081,10 +935,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.0: resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} engines: {node: 20 || >=22} @@ -1144,10 +994,6 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - postcss@8.5.3: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} @@ -1179,13 +1025,6 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} - reflect-metadata@0.2.2: - resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -1213,8 +1052,8 @@ packages: vue-tsc: optional: true - rolldown@1.0.0-rc.1: - resolution: {integrity: sha512-M3AeZjYE6UclblEf531Hch0WfVC/NOL43Cc+WdF3J50kk5/fvouHhDumSGTh0oRjbZ8C4faaVr5r6Nx1xMqDGg==} + rolldown@1.0.0-rc.2: + resolution: {integrity: sha512-1g/8Us9J8sgJGn3hZfBecX1z4U3y5KO7V/aV2U1M/9UUzLNqHA8RfFQ/NPT7HLxOIldyIgrcjaYTRvA81KhJIg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1223,9 +1062,6 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -1240,15 +1076,6 @@ packages: engines: {node: '>=10'} hasBin: true - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - sha.js@2.4.12: - resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} - engines: {node: '>= 0.10'} - hasBin: true - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1275,10 +1102,6 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - sql-highlight@6.1.0: - resolution: {integrity: sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==} - engines: {node: '>=14'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1338,10 +1161,6 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} - to-buffer@1.2.2: - resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} - engines: {node: '>= 0.4'} - tr46@5.1.0: resolution: {integrity: sha512-IUWnUK7ADYR5Sl1fZlO1INDUhVhatWl7BtJWsIhwJ0UAK7ilzzIa8uIqOO/aYVWHZPJkKbEL+362wrzoeRF7bw==} engines: {node: '>=18'} @@ -1383,65 +1202,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - - typeorm@0.3.28: - resolution: {integrity: sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==} - engines: {node: '>=16.13.0'} - hasBin: true - peerDependencies: - '@google-cloud/spanner': ^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@sap/hana-client': ^2.14.22 - better-sqlite3: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 - ioredis: ^5.0.4 - mongodb: ^5.8.0 || ^6.0.0 - mssql: ^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0 - mysql2: ^2.2.5 || ^3.0.1 - oracledb: ^6.3.0 - pg: ^8.5.1 - pg-native: ^3.0.0 - pg-query-stream: ^4.0.0 - redis: ^3.1.1 || ^4.0.0 || ^5.0.14 - sql.js: ^1.4.0 - sqlite3: ^5.0.3 - ts-node: ^10.7.0 - typeorm-aurora-data-api-driver: ^2.0.0 || ^3.0.0 - peerDependenciesMeta: - '@google-cloud/spanner': - optional: true - '@sap/hana-client': - optional: true - better-sqlite3: - optional: true - ioredis: - optional: true - mongodb: - optional: true - mssql: - optional: true - mysql2: - optional: true - oracledb: - optional: true - pg: - optional: true - pg-native: - optional: true - pg-query-stream: - optional: true - redis: - optional: true - sql.js: - optional: true - sqlite3: - optional: true - ts-node: - optional: true - typeorm-aurora-data-api-driver: - optional: true - typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} @@ -1453,10 +1213,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} - hasBin: true - vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1544,10 +1300,6 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} - which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} - engines: {node: '>= 0.4'} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1570,18 +1322,6 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - yauzl@3.2.0: resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==} engines: {node: '>=12'} @@ -1773,57 +1513,54 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@oxc-project/types@0.110.0': {} - - '@pkgjs/parseargs@0.11.0': - optional: true + '@oxc-project/types@0.111.0': {} '@quansync/fs@0.1.5': dependencies: quansync: 0.2.11 - '@rolldown/binding-android-arm64@1.0.0-rc.1': + '@rolldown/binding-android-arm64@1.0.0-rc.2': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.1': + '@rolldown/binding-darwin-arm64@1.0.0-rc.2': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.1': + '@rolldown/binding-darwin-x64@1.0.0-rc.2': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.1': + '@rolldown/binding-freebsd-x64@1.0.0-rc.2': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.1': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.2': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.1': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.2': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.1': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.2': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.1': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.2': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.1': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.2': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.1': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.2': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.1': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.2': dependencies: '@napi-rs/wasm-runtime': 1.1.1 optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.1': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.2': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.1': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.2': optional: true - '@rolldown/pluginutils@1.0.0-rc.1': {} + '@rolldown/pluginutils@1.0.0-rc.2': {} '@rollup/rollup-android-arm-eabi@4.35.0': optional: true @@ -1882,8 +1619,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.35.0': optional: true - '@sqltools/formatter@1.2.5': {} - '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -1903,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': @@ -1965,8 +1706,6 @@ snapshots: ansis@4.2.0: {} - app-root-path@3.1.0: {} - assertion-error@2.0.1: {} ast-kit@2.1.2: @@ -1978,10 +1717,6 @@ snapshots: dependencies: tslib: 2.8.1 - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 - b4a@1.6.7: {} balanced-match@1.0.2: {} @@ -1989,8 +1724,6 @@ snapshots: bare-events@2.5.4: optional: true - base64-js@1.5.1: {} - birpc@2.6.1: {} brace-expansion@2.0.1: @@ -2001,30 +1734,8 @@ snapshots: buffer-crc32@0.2.13: {} - buffer@6.0.3: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - cac@6.7.14: {} - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bind@1.0.8: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - camelcase@6.3.0: {} chai@5.2.0: @@ -2041,12 +1752,6 @@ snapshots: dependencies: readdirp: 4.1.2 - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2061,8 +1766,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - dayjs@1.11.19: {} - debug@4.4.0: dependencies: ms: 2.1.3 @@ -2071,30 +1774,14 @@ snapshots: dependencies: ms: 2.1.3 - dedent@1.7.1: {} - deep-eql@5.0.2: {} - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - defu@6.1.4: {} diff@8.0.2: {} - dotenv@16.6.1: {} - dts-resolver@2.1.2: {} - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - eastasianwidth@0.2.0: {} emoji-regex@8.0.0: {} @@ -2103,16 +1790,8 @@ snapshots: empathic@2.0.0: {} - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - esbuild@0.25.1: optionalDependencies: '@esbuild/aix-ppc64': 0.25.1 @@ -2141,8 +1820,6 @@ snapshots: '@esbuild/win32-ia32': 0.25.1 '@esbuild/win32-x64': 0.25.1 - escalade@3.2.0: {} - estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -2174,10 +1851,6 @@ snapshots: optionalDependencies: debug: 4.4.0 - for-each@0.3.5: - dependencies: - is-callable: 1.2.7 - foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -2186,41 +1859,10 @@ snapshots: fsevents@2.3.3: optional: true - function-bind@1.1.2: {} - - get-caller-file@2.0.5: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - get-tsconfig@4.10.1: dependencies: resolve-pkg-maps: 1.0.0 - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.5 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - glob@11.0.1: dependencies: foreground-child: 3.3.1 @@ -2230,22 +1872,6 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.0 - gopd@1.2.0: {} - - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - hookable@5.5.3: {} https-proxy-agent@7.0.6: @@ -2257,28 +1883,10 @@ snapshots: husky@9.1.7: {} - ieee754@1.2.1: {} - - inherits@2.0.4: {} - - is-callable@1.2.7: {} - is-fullwidth-code-point@3.0.0: {} - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.20 - - isarray@2.0.5: {} - isexe@2.0.0: {} - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - jackspeak@4.1.0: dependencies: '@isaacs/cliui': 8.0.2 @@ -2299,8 +1907,6 @@ snapshots: loupe@3.2.1: {} - lru-cache@10.4.3: {} - lru-cache@11.0.2: {} magic-string@0.30.17: @@ -2315,18 +1921,12 @@ snapshots: dependencies: semver: 6.3.1 - math-intrinsics@1.1.0: {} - memory-pager@1.5.0: {} minimatch@10.0.1: dependencies: brace-expansion: 2.0.1 - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.1 - minipass@7.1.2: {} mongodb-connection-string-url@3.0.2: @@ -2404,11 +2004,6 @@ snapshots: path-key@3.1.1: {} - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.2 - path-scurry@2.0.0: dependencies: lru-cache: 11.0.2 @@ -2463,8 +2058,6 @@ snapshots: dependencies: find-up: 4.1.0 - possible-typed-array-names@1.1.0: {} - postcss@8.5.3: dependencies: nanoid: 3.3.9 @@ -2487,10 +2080,6 @@ snapshots: readdirp@4.1.2: {} - reflect-metadata@0.2.2: {} - - require-directory@2.1.1: {} - resolve-pkg-maps@1.0.0: {} rimraf@6.0.1: @@ -2498,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-rc.1)(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 @@ -2509,31 +2098,31 @@ snapshots: dts-resolver: 2.1.2 get-tsconfig: 4.10.1 magic-string: 0.30.19 - rolldown: 1.0.0-rc.1 + rolldown: 1.0.0-rc.2 optionalDependencies: typescript: 5.9.2 transitivePeerDependencies: - oxc-resolver - supports-color - rolldown@1.0.0-rc.1: + rolldown@1.0.0-rc.2: dependencies: - '@oxc-project/types': 0.110.0 - '@rolldown/pluginutils': 1.0.0-rc.1 + '@oxc-project/types': 0.111.0 + '@rolldown/pluginutils': 1.0.0-rc.2 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.1 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.1 - '@rolldown/binding-darwin-x64': 1.0.0-rc.1 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.1 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.1 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.1 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.1 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.1 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.1 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.1 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.1 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.1 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.1 + '@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: @@ -2560,29 +2149,12 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.35.0 fsevents: 2.3.3 - safe-buffer@5.2.1: {} - semver@6.3.1: {} semver@7.7.1: {} semver@7.7.2: {} - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - sha.js@2.4.12: - dependencies: - inherits: 2.0.4 - safe-buffer: 5.2.1 - to-buffer: 1.2.2 - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2601,8 +2173,6 @@ snapshots: split2@4.2.0: {} - sql-highlight@6.1.0: {} - stackback@0.0.2: {} std-env@3.9.0: {} @@ -2665,12 +2235,6 @@ snapshots: tinyspy@4.0.4: {} - to-buffer@1.2.2: - dependencies: - isarray: 2.0.5 - safe-buffer: 5.2.1 - typed-array-buffer: 1.0.3 - tr46@5.1.0: dependencies: punycode: 2.3.1 @@ -2690,8 +2254,8 @@ snapshots: diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 - rolldown: 1.0.0-rc.1 - rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-rc.1)(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 @@ -2708,36 +2272,6 @@ snapshots: tslib@2.8.1: {} - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - - typeorm@0.3.28(mongodb@6.15.0)(pg@8.17.2): - dependencies: - '@sqltools/formatter': 1.2.5 - ansis: 4.2.0 - app-root-path: 3.1.0 - buffer: 6.0.3 - dayjs: 1.11.19 - debug: 4.4.3 - dedent: 1.7.1 - dotenv: 16.6.1 - glob: 10.5.0 - reflect-metadata: 0.2.2 - sha.js: 2.4.12 - sql-highlight: 6.1.0 - tslib: 2.8.1 - uuid: 11.1.0 - yargs: 17.7.2 - optionalDependencies: - mongodb: 6.15.0 - pg: 8.17.2 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - typescript@5.9.2: {} unconfig@7.3.3: @@ -2749,8 +2283,6 @@ snapshots: undici-types@6.21.0: {} - uuid@11.1.0: {} - vite-node@3.2.4(@types/node@20.19.17)(jiti@2.6.1): dependencies: cac: 6.7.14 @@ -2836,16 +2368,6 @@ snapshots: tr46: 5.1.0 webidl-conversions: 7.0.0 - which-typed-array@1.1.20: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - which@2.0.2: dependencies: isexe: 2.0.0 @@ -2869,20 +2391,6 @@ snapshots: xtend@4.0.2: {} - y18n@5.0.8: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - yauzl@3.2.0: dependencies: buffer-crc32: 0.2.13 From 0896d13ab4581767499d899a0a9303569a62710c Mon Sep 17 00:00:00 2001 From: James Couch Date: Fri, 30 Jan 2026 06:52:09 -0700 Subject: [PATCH 18/21] refactor(chrono-postgres-datastore): simplify queries with subqueries Combine SELECT+UPDATE and SELECT+DELETE into single atomic statements: - claim(): Single UPDATE with subquery replaces transaction with separate SELECT FOR UPDATE SKIP LOCKED + UPDATE - cleanup(): Single DELETE with subquery replaces SELECT IDs + DELETE This reduces round trips and removes explicit transaction management for claim operations. Co-Authored-By: Claude (claude-4-opus) --- .../src/chrono-postgres-datastore.ts | 70 ++++--------------- .../chrono-postgres-datastore/src/queries.ts | 41 +++++------ 2 files changed, 33 insertions(+), 78 deletions(-) diff --git a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts index efaac4f..1b0143f 100644 --- a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts +++ b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts @@ -10,10 +10,8 @@ import { } from '@neofinancial/chrono'; import type { Pool, PoolClient } from 'pg'; import { - CLAIM_SELECT_QUERY, - CLAIM_UPDATE_QUERY, - CLEANUP_DELETE_QUERY, - CLEANUP_SELECT_QUERY, + CLAIM_QUERY, + CLEANUP_QUERY, COMPLETE_QUERY, DELETE_BY_ID_FORCE_QUERY, DELETE_BY_ID_QUERY, @@ -253,48 +251,20 @@ export class ChronoPostgresDatastore const now = new Date(); const staleThreshold = new Date(now.getTime() - input.claimStaleTimeoutMs); - // Use a transaction to atomically select and update - const client = await pool.connect(); - let result: Task | undefined; - - try { - await client.query('BEGIN'); - - // Find and lock the next claimable task - const selectResult = await client.query(CLAIM_SELECT_QUERY, [ - String(input.kind), - now, - TaskStatus.PENDING, - TaskStatus.CLAIMED, - staleThreshold, - ]); - - const taskToClaim = selectResult.rows[0]; - if (taskToClaim) { - // Update the task to claim it - const updateResult = await client.query(CLAIM_UPDATE_QUERY, [ - TaskStatus.CLAIMED, - now, - now, - taskToClaim.id, - ]); - - const updatedRow = updateResult.rows[0]; - result = updatedRow ? this.toTask(updatedRow) : undefined; - } - - await client.query('COMMIT'); - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } + // Single atomic query: SELECT FOR UPDATE SKIP LOCKED + UPDATE in one statement + const result = await pool.query(CLAIM_QUERY, [ + TaskStatus.CLAIMED, + now, + String(input.kind), + TaskStatus.PENDING, + staleThreshold, + ]); - // Opportunistic cleanup runs after claim completes, outside the transaction + // Opportunistic cleanup runs after claim completes this.maybeCleanupCompletedTasks(); - return result; + const row = result.rows[0]; + return row ? this.toTask(row) : undefined; } async retry( @@ -396,17 +366,7 @@ export class ChronoPostgresDatastore const cutoffDate = new Date(Date.now() - this.config.completedDocumentTTLSeconds * 1000); - const selectResult = await pool.query<{ id: string }>(CLEANUP_SELECT_QUERY, [ - TaskStatus.COMPLETED, - cutoffDate, - this.config.cleanupBatchSize, - ]); - - if (selectResult.rows.length === 0) { - return; - } - - const ids = selectResult.rows.map((row) => row.id); - await pool.query(CLEANUP_DELETE_QUERY, [ids]); + // 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/queries.ts b/packages/chrono-postgres-datastore/src/queries.ts index 1b849d1..bfd23f4 100644 --- a/packages/chrono-postgres-datastore/src/queries.ts +++ b/packages/chrono-postgres-datastore/src/queries.ts @@ -15,20 +15,18 @@ export const FIND_BY_IDEMPOTENCY_KEY_QUERY = ` SELECT * FROM chrono_tasks WHERE idempotency_key = $1 `; -export const CLAIM_SELECT_QUERY = ` - SELECT * FROM chrono_tasks - WHERE kind = $1 - AND scheduled_at <= $2 - AND (status = $3 OR (status = $4 AND claimed_at <= $5)) - ORDER BY priority DESC, scheduled_at ASC - LIMIT 1 - FOR UPDATE SKIP LOCKED -`; - -export const CLAIM_UPDATE_QUERY = ` +export const CLAIM_QUERY = ` UPDATE chrono_tasks - SET status = $1, claimed_at = $2, updated_at = $3 - WHERE id = $4 + 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 * `; @@ -81,15 +79,12 @@ export const DELETE_BY_KEY_FORCE_QUERY = ` RETURNING * `; -// Cleanup: find old completed tasks -export const CLEANUP_SELECT_QUERY = ` - SELECT id FROM chrono_tasks - WHERE status = $1 AND completed_at < $2 - LIMIT $3 -`; - -// Cleanup: delete tasks by IDs -export const CLEANUP_DELETE_QUERY = ` +// Cleanup: delete old completed tasks with limit +export const CLEANUP_QUERY = ` DELETE FROM chrono_tasks - WHERE id = ANY($1) + WHERE id IN ( + SELECT id FROM chrono_tasks + WHERE status = $1 AND completed_at < $2 + LIMIT $3 + ) `; From 4c05a35b895e90d163b4d97cf323974396f18df1 Mon Sep 17 00:00:00 2001 From: James Couch Date: Fri, 30 Jan 2026 06:55:45 -0700 Subject: [PATCH 19/21] chore(chrono-postgres-datastore): add test tsconfig for typecheck - Add test/tsconfig.json to enable typechecking test files - Update package.json typecheck script to include test directory - Fix TypeScript strict mode errors in tests Co-Authored-By: Claude (claude-4-opus) --- packages/chrono-postgres-datastore/package.json | 2 +- .../test/chrono-postgres-datastore.test.ts | 12 ++++++------ .../chrono-postgres-datastore/test/tsconfig.json | 8 ++++++++ 3 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 packages/chrono-postgres-datastore/test/tsconfig.json diff --git a/packages/chrono-postgres-datastore/package.json b/packages/chrono-postgres-datastore/package.json index 242df93..413cc5b 100644 --- a/packages/chrono-postgres-datastore/package.json +++ b/packages/chrono-postgres-datastore/package.json @@ -36,7 +36,7 @@ "scripts": { "clean": "rimraf ./build", "build": "tsdown", - "typecheck": "tsc -p ./tsconfig.json --noEmit", + "typecheck": "tsc -p ./tsconfig.json --noEmit && tsc -p ./test/tsconfig.json --noEmit", "test": "NODE_ENV=test TZ=UTC vitest run" }, "dependencies": { diff --git a/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts b/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts index 24ebf10..e505fcc 100644 --- a/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts +++ b/packages/chrono-postgres-datastore/test/chrono-postgres-datastore.test.ts @@ -97,9 +97,9 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { }); const result = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [task.id]); - expect(result.rows.length).toBe(1); - expect(result.rows[0].kind).toBe('test'); - expect(result.rows[0].data).toEqual({ value: 'stored' }); + 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 () => { @@ -349,7 +349,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { ]); // All claims should succeed - const claimedIds = claims.map((c) => c?.id); + const claimedIds = claims.filter((c) => c !== undefined).map((c) => c.id); expect(claimedIds).toHaveLength(5); // All claimed IDs should be unique (no duplicates) @@ -472,7 +472,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { // Verify in database const result = await pool.query('SELECT * FROM chrono_tasks WHERE id = $1', [task.id]); - expect(result.rows[0].claimed_at).toBeNull(); + expect(result.rows[0]?.claimed_at).toBeNull(); }); }); @@ -568,7 +568,7 @@ describe.skipIf(!DATABASE_URL)('ChronoPostgresDatastore', () => { }); describe('cleanup', () => { - const createDataStoreWithConfig = async (config: Parameters[0]) => { + const createDataStoreWithConfig = async (config: ConstructorParameters[0]) => { const ds = new ChronoPostgresDatastore(config); await ds.initialize(pool); return ds; 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"] +} From 512606ec644732042975020b6862517df657761f Mon Sep 17 00:00:00 2001 From: James Couch Date: Mon, 2 Feb 2026 14:20:02 -0700 Subject: [PATCH 20/21] refactor: use getQueryable consistently for all database operations - Made getQueryable async so it handles initialization internally - Updated all public methods (schedule, delete, claim, retry, complete, fail) to use getQueryable consistently instead of mixing getPool() + pool.query() - Simplified schedule() by removing redundant getPool() call - getQueryable now properly awaits pool initialization via getPool() Co-Authored-By: Claude (claude-4-opus) --- .../src/chrono-postgres-datastore.ts | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts index 1b0143f..3f03268 100644 --- a/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts +++ b/packages/chrono-postgres-datastore/src/chrono-postgres-datastore.ts @@ -130,24 +130,20 @@ export class ChronoPostgresDatastore /** * 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 getQueryable(options?: PostgresDatastoreOptions): Pool | PoolClient { + private async getQueryable(options?: PostgresDatastoreOptions): Promise { if (options?.client) { return options.client; } - if (!this.pool) { - throw new Error('Pool not initialized'); - } - - return this.pool; + return this.getPool(); } async schedule( input: ScheduleInput, ): Promise> { - await this.getPool(); - const queryable = this.getQueryable(input.datastoreOptions); + const queryable = await this.getQueryable(input.datastoreOptions); const values = [ String(input.kind), @@ -198,7 +194,7 @@ export class ChronoPostgresDatastore key: DeleteInput, options?: DeleteOptions, ): Promise | undefined> { - const pool = await this.getPool(); + const queryable = await this.getQueryable(); let query: string; let values: unknown[]; @@ -221,7 +217,7 @@ export class ChronoPostgresDatastore } } - const result = await pool.query(query, values); + const result = await queryable.query(query, values); const row = result.rows[0]; if (row) { @@ -247,12 +243,12 @@ export class ChronoPostgresDatastore async claim>( input: ClaimTaskInput, ): Promise | undefined> { - const pool = await this.getPool(); + 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 pool.query(CLAIM_QUERY, [ + const result = await queryable.query(CLAIM_QUERY, [ TaskStatus.CLAIMED, now, String(input.kind), @@ -271,28 +267,28 @@ export class ChronoPostgresDatastore taskId: string, retryAt: Date, ): Promise> { - const pool = await this.getPool(); + const queryable = await this.getQueryable(); const now = new Date(); - const result = await pool.query(RETRY_QUERY, [TaskStatus.PENDING, retryAt, now, taskId]); + const result = await queryable.query(RETRY_QUERY, [TaskStatus.PENDING, retryAt, now, taskId]); return this.extractUpdatedTaskOrThrow(result.rows, taskId); } async complete(taskId: string): Promise> { - const pool = await this.getPool(); + const queryable = await this.getQueryable(); const now = new Date(); - const result = await pool.query(COMPLETE_QUERY, [TaskStatus.COMPLETED, now, now, now, taskId]); + 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 pool = await this.getPool(); + const queryable = await this.getQueryable(); const now = new Date(); - const result = await pool.query(FAIL_QUERY, [TaskStatus.FAILED, now, now, taskId]); + const result = await queryable.query(FAIL_QUERY, [TaskStatus.FAILED, now, now, taskId]); return this.extractUpdatedTaskOrThrow(result.rows, taskId); } From b9c11fdba9e1e7ffcd98fd520b2f2d379931dec0 Mon Sep 17 00:00:00 2001 From: James Couch Date: Tue, 24 Feb 2026 13:30:56 -0700 Subject: [PATCH 21/21] chore: update CI postgres image to 17 and bump alpha version Address PR review feedback to use a more current postgres version in CI. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 2 +- packages/chrono-postgres-datastore/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a060bd..96c54cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: services: postgres: - image: postgres:15 + image: postgres:17 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres diff --git a/packages/chrono-postgres-datastore/package.json b/packages/chrono-postgres-datastore/package.json index 413cc5b..5da89ba 100644 --- a/packages/chrono-postgres-datastore/package.json +++ b/packages/chrono-postgres-datastore/package.json @@ -1,6 +1,6 @@ { "name": "@neofinancial/chrono-postgres-datastore", - "version": "0.5.2-alpha.1", + "version": "0.5.2-alpha.2", "description": "PostgreSQL datastore implementation for Chrono task scheduling system", "private": false, "publishConfig": {