Skip to content

Add ChronoPostgresDatastore implementation#59

Open
tjamescouch wants to merge 21 commits intomasterfrom
postgres-datastore
Open

Add ChronoPostgresDatastore implementation#59
tjamescouch wants to merge 21 commits intomasterfrom
postgres-datastore

Conversation

@tjamescouch
Copy link
Collaborator

@tjamescouch tjamescouch commented Jan 27, 2026

Description

  • 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

Tested in service:

- 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) <noreply@anthropic.com>
@wiz-a38520980d
Copy link

wiz-a38520980d bot commented Jan 27, 2026

Wiz Scan Summary

Scanner Findings
Vulnerability Finding Vulnerabilities 2 High 5 Medium 4 Low
Data Finding Sensitive Data -
Secret Finding Secrets -
IaC Misconfiguration IaC Misconfigurations 1 Info
SAST Finding SAST Findings -
Total 2 High 5 Medium 4 Low 1 Info

View scan details in Wiz

To detect these findings earlier in the dev lifecycle, try using Wiz Code VS Code Extension.

@@ -0,0 +1,550 @@
import { PGlite } from '@electric-sql/pglite';
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New dev dependency

tjamescouch and others added 4 commits January 27, 2026 02:38
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@@ -0,0 +1,64 @@
/**
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm using PGLite for in memory postgres testing.
AI generated test magic ahead. This is significantly more complicated due to supporting the ORMs query builder. The other option is allowing string interpolation in the source, pick your poison, this seems better to me.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditched PGLite. Doesn't really work with typeORM

- 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) <noreply@anthropic.com>
@@ -13,6 +13,21 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into alternatives to testing with real postgres. I tried PGLite but it was not good with TypeORM and defeated the purpose of the tests.

tjamescouch and others added 7 commits January 27, 2026 19:09
Query generation tests are no longer needed - actual cleanup behavior
is already verified by the integration tests.

Co-Authored-By: Claude (claude-4-opus) <noreply@anthropic.com>
Co-Authored-By: Claude (claude-4-opus) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…t 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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
@tjamescouch tjamescouch marked this pull request as ready for review January 28, 2026 12:17
Copy link
Collaborator

@darrenpicard25 darrenpicard25 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good. just some discussion points

const entity = manager.create(ChronoTaskEntity, {
kind: String(input.kind),
status: TaskStatus.PENDING,
data: input.data as Record<string, unknown>,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

booo to cast despite mongo package doing same thing

}

const result = await qb.execute();
const [rawRow] = result.raw as Record<string, unknown>[];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

booo

});

// Opportunistic cleanup runs after claim completes, outside the transaction
this.maybeCleanupCompletedTasks();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i get it but slightly dislike this clean up logic being apart of claim flow

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we solve this with postgres built in pub/sub model

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opportunistic cleanup is simpler. LISTEN/NOTIFY adds complexity for marginal benefit.

The AI agrees with me here, but we can discuss more with the group.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah lets discuss today

tjamescouch and others added 3 commits January 30, 2026 06:04
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
tjamescouch and others added 2 commits January 30, 2026 06:52
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
Comment on lines +116 to +128
const timeoutPromise = new Promise<never>((_, 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]);
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@darrenpicard25 As we are doing something similar in chrono-mongo-datastore, is there a race condition between the initialization promise resolving and the first messages that the service starts to publish? Because in ALWAYS mode this would mean losing messages right?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not really a race problem. just more a problem with the datastore never being initialized

Copy link
Collaborator

@darrenpicard25 darrenpicard25 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking good. few minor things and we will see what people thing in todays meeting

@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would recommend just extending the chrono-postgres-datastore tsconfig not the base

},
"require": {
"types": "./build/index.d.ts",
"default": "./build/index.js"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these seem wrong

"@types/pg": "^8.11.0"
},
"peerDependencies": {
"@neofinancial/chrono": ">=0.5.0"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"@neofinancial/chrono": ">=0.5.0"
"@neofinancial/chrono": "workspace:*"

for now.

"isolatedModules": true,
"verbatimModuleSyntax": false,
"declaration": true,
"declarationMap": true,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

type PostgresDatastoreOptions,
} from './chrono-postgres-datastore';
export { MIGRATION_DOWN_SQL, MIGRATION_UP_SQL, migrateDown, migrateUp } from './migration';
export type { ChronoTaskRow } from './types';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be exported? maybe. dont actually know. but this would be the equivalent of mongo-datastore exporting the Document type

input: ScheduleInput<TaskKind, TaskMapping[TaskKind], PostgresDatastoreOptions>,
): Promise<Task<TaskKind, TaskMapping[TaskKind]>> {
await this.getPool();
const queryable = this.getQueryable(input.datastoreOptions);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

huh? we are doing the same things twice but with different behaviour

query = DELETE_BY_KEY_QUERY;
values = [String(key.kind), key.idempotencyKey, TaskStatus.PENDING];
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would prefer to see in some sort of "getDeleteTaskQuery" private method over if blocks with let variables

});

// Opportunistic cleanup runs after claim completes, outside the transaction
this.maybeCleanupCompletedTasks();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah lets discuss today

id: row.id,
kind: row.kind as TaskKind,
status: row.status as TaskStatus,
data: row.data as TaskMapping[TaskKind],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

booo to casting. Feel like this should not be needed based on everything ive seen

];

try {
const result = await queryable.query<ChronoTaskRow>(SCHEDULE_QUERY, values);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a huge deal but would be nice to enforce that values aligns with the expected types for the query. might not be possible but would be nice

tjamescouch and others added 2 commits February 2, 2026 14:20
- 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) <noreply@anthropic.com>
Address PR review feedback to use a more current postgres version in CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High Vulnerability Finding on line 0

More Details

Vulnerabilities [minimatch:10.0.1]

Name Severity Source Fixed version CVSS score CVSS exploitability score Has public exploit Has CISA KEV exploit
CVE-2026-26996 High GHSA-3ppc-4f35-3m26 10.2.1 8.7 3.9 true false

To ignore this finding as an exception, reply to this conversation with #wiz_ignore reason

If you'd like to ignore this finding in all future scans, add an exception in the .wiz file (learn more) or create an Ignore Rule (learn more).


To get more details on how to remediate this issue using AI, reply to this conversation with #wiz remediate

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High Vulnerability Finding on line 0

More Details

Vulnerabilities [glob:11.0.1]

Name Severity Source Fixed version CVSS score CVSS exploitability score Has public exploit Has CISA KEV exploit
CVE-2025-64756 High GHSA-5j98-mcp5-4vw2 11.1.0 7.5 1.6 true false

To ignore this finding as an exception, reply to this conversation with #wiz_ignore reason

If you'd like to ignore this finding in all future scans, add an exception in the .wiz file (learn more) or create an Ignore Rule (learn more).


To get more details on how to remediate this issue using AI, reply to this conversation with #wiz remediate

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants