Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions .claude/settings.local.json

This file was deleted.

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,4 @@ paradox.log
.keys/

__trace
.claude/settings.local.json
88 changes: 88 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# CLAUDE.md

## Architecture

- Modular monolith: enforce strict module boundaries. Services interact through public APIs only — never reach into another module's internals.
- No circular dependencies between modules. If A depends on B, B must not depend on A. Use eventing/queues to decouple when bidirectional communication is needed.
- Never leak third-party IDs (Stripe, banking cores, partner systems) into the domain. Create internal IDs that map to external ones — this makes partner migration a config change, not a rewrite.
- Abstract frameworks (HTTP, ORM, queue) behind interfaces. Always leave an escape hatch (e.g. raw SQL) for when the abstraction limits performance.
- Wrap third-party clients (AWS SDK, Stripe, etc.) in service classes with better developer ergonomics. This gives a single place to add logging, metrics, retries, and error normalization instead of scattering SDK calls throughout the codebase.
- Time is a dependency. Never call `Date.now()` or `new Date()` directly — inject a `TimeProvider` so tests can travel forward and backward deterministically.
- Soft deletes only (`deleted_at` timestamp). Hard deletes are forbidden on domain entities.
- Build artifacts once. The same image/bundle moves Dev → Stage → Prod. Configuration is injected at runtime (env vars), never baked in at build time.
- Identify one-way doors (database schema, public API contracts, IaC deletions) and deliberate. Move fast on everything else.

## Observability

- Generate a Trace ID at ingress and propagate it through every function call, async queue message, and third-party request.
- Structured JSON logs only. Use contextual loggers: `log.with({ userId, traceId }).info("Action")`.
- Auto-redact PII and secrets via middleware — never rely on developers remembering to omit sensitive fields.
- Avoid high-cardinality metric tags (user IDs, request IDs) to prevent billing explosions in metrics backends.

## Debugging

- Always root-cause an issue. Do not take shortcuts, suppress errors, or work around symptoms.
- If the root cause looks complex or touches multiple systems, surface a plan before making changes.

## TypeScript

- After any TypeScript change, run the linter. Do not consider the change done until it passes.
- Never use synchronous I/O (`readFileSync`, `writeFileSync`, `execSync`, etc.). Always use the async equivalents (`readFile`, `writeFile`, `execFile`, etc.).
- Always use branded types from `@taxbit-private/type-wrappers` (e.g. `StringType`) instead of bare primitives. A function that accepts `string` where it means `AccountId` is a bug waiting to happen.
- Prefer immutable data. Use `readonly`, `Readonly<T>`, and `as const`. Mutate only when performance demands it and the scope is small.
- Prefer a functional style — pure functions, composition, and expressions over statements. Avoid side effects outside of explicit boundaries (constructors, handlers).
- Use classes instead of loose top-level functions. Group related behavior behind a class with a clear single responsibility.
- Accept dependencies in the constructor via IoC. Use `@Inject` when working within the NestJS DI workflow.
- No global variables or module-level mutable state. Configuration must be explicit and passed as structured objects, never read from `process.env` inline. The top-level caller (main, CLI entry point, test harness) parses environment into a typed config object and passes it down.
- Use descriptive variable names. Characters are free — prefer clarity over brevity. Avoid single-letter variables except in trivial arrow functions. When an expression is deeply nested, capture intermediate values into named `const`s.
- Functions must have at most 3 positional parameters. Beyond that, use a single options object: `doThing({ catalog, schema, dryRun })`.
- Keep functions small and composable. If a function does two things, split it into two functions. Never name a function `fooAndBar()` — that is two functions. Each function has one job.

## Testing

- Test hierarchy: unit tests for logic, integration tests with real dependencies (Dockerized/LocalStack).
- Tests must own their state. Each test sets up exactly what it needs — avoid broad `beforeEach` blocks that create shared mutable fixtures.
- Optimize for readability. A reader should scan a test and instantly see what is being tested. Extract utility functions and base classes that hide boilerplate (file resolvers, fake builders, assertion helpers) so test methods contain only the signal: input, action, expected outcome.
- Reuse test infrastructure. When multiple test classes share the same setup pattern (fake resolvers, builders, content scanners), extract a shared helper or base class. Duplicated test scaffolding is just as bad as duplicated production code.
- Never test against production. Never connect local dev environments to production services.

## Developer Experience

- Guiding principle: simplicity and correctness. The easy thing should be the right thing. A developer should rarely be able to do the wrong thing — as platform developers, build the guardrails that enforce correctness.
- Let tools and guardrails enforce correct behavior so developers don't have to guess. Type checkers, linters, generated code, frozen classes, and validation at build time all beat documentation that says "please remember to."
- When adding a new capability, ask: can a developer use this correctly without reading a README? If not, simplify the API or add compile-time/test-time enforcement.
- Prefer failing fast and loudly over silent misbehavior. A clear error at `generate` or `tofu plan` time is worth more than a subtle bug in production.
- If you do it twice, script it. Centralize developer tasks in repo-scoped CLI tooling.
- A feature is not done until it has metrics, alerts, and support tooling. Code that runs but can't be observed or operated is incomplete.

### Enforcing invariants

Every invariant should be enforced automatically. Use the strongest mechanism available, in this priority order:

1. **Type system** — Make illegal states unrepresentable. Branded types, enums, frozen dataclasses, and `Literal` types catch errors at compile time with zero runtime cost.
2. **Tests** — When the type system can't express a constraint, write a test that fails if the invariant is violated. Schema drift tests, round-trip serialization checks, and contract tests belong here.
3. **Code generation validators** — When an invariant spans config and code (e.g. "Lakebase source tables must have CDC enabled"), add validation to `just generate` so it fails before anything is deployed. This is the right layer for cross-cutting checks that can't live in a single type signature.
4. **CI checks** — Lint rules, generated-code-up-to-date checks, and PR scope validation catch what slips past local tooling.
5. **Runtime validation** — `@enforce` decorators, DLT expectations, and input validation at system boundaries. Use these as a last line of defense, not the primary mechanism.

When introducing a new convention or constraint, don't document it and hope people follow it. Instead, ask: "How do I make it impossible (or at least loud and obvious) to violate this?" Build the check, wire it into the developer workflow, and then document why it exists.

## Configuration

- Keep YAML and configuration to a minimum. Prefer code over config when logic is involved.
- Reuse and componentize shared definitions (schemas, table lists, environment settings) rather than duplicating them across files.
- Minimize cognitive complexity — a reader should understand what a config does without cross-referencing multiple files.

## Documentation

After adding a new feature, CLI tool, pipeline abstraction, or Terraform resource, update the relevant README or doc. Documentation is not done until the change is reflected.

### Doc hierarchy (progressive disclosure)

Documentation follows progressive disclosure — the top level is the simplest, each layer adds detail:

1. **`README.md`** (root) — Quickstart only. Minimal cognitive load: install, run, and links to everything else. No deep explanations.
2. **`docs/*.md`** — Guides and operational docs. Development setup, deployment, architecture, debugging workflows.
3. **`databricks/README.md`**, **`terraform/README.md`** — Component-level detail. Bundle conventions, schema enforcement, Kinesis sinks, Lakebase, Terraform modules.

When adding content, put it at the lowest level that makes sense. The root README should link to detail, never contain it. If a section in the root README grows beyond a short paragraph + code snippet, move the detail downstream and replace it with a link.
2 changes: 1 addition & 1 deletion packages/common-aws/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@paradoxical-io/common-aws",
"version": "2.7.0",
"version": "2.8.0",
"description": "Common aws for paradox services",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/common-hapi/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@paradoxical-io/common-hapi",
"version": "2.7.0",
"version": "2.8.0",
"description": "Common hapi code for paradoxical services",
"files": [
"dist/**/*.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/common-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@paradoxical-io/common-server",
"version": "2.7.0",
"version": "2.8.0",
"description": "Common code for paradox services",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
7 changes: 3 additions & 4 deletions packages/common-sql/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@paradoxical-io/common-sql",
"version": "2.7.0",
"version": "2.8.0",
"files": [
"dist/**/*.js",
"dist/**/*.d.ts",
Expand All @@ -19,13 +19,12 @@
"author": "Anton Kropp",
"license": "MIT",
"devDependencies": {
"@types/mysql": "^2.15.8",
"sqlite3": "5.1.6"
},
"dependencies": {
"@paradoxical-io/common-server": "workspace:packages/common-server",
"mysql": "^2.17.1",
"typeorm": "^0.2.45"
"mysql2": "^3.11.0",
"typeorm": "^0.3.20"
},
"lint-staged": {
"*.ts": [
Expand Down
28 changes: 15 additions & 13 deletions packages/common-sql/src/sql/connectionFactory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { currentEnvironment, isLocal, isRemote, log, Metrics } from '@paradoxical-io/common-server';
import { Pool } from 'mysql';
import { Connection, createConnection } from 'typeorm';
import { DataSource } from 'typeorm';
import { MysqlDriver } from 'typeorm/driver/mysql/MysqlDriver';
import { LoggerOptions } from 'typeorm/logger/LoggerOptions';

Expand Down Expand Up @@ -40,7 +39,7 @@ export class ConnectionFactory {
*/
constructor(private entities?: Array<{ new (): CrudBase }>) {}

async mysql(options: MySQLOptions): Promise<Connection> {
async mysql(options: MySQLOptions): Promise<DataSource> {
const defaultConnectionSize = currentEnvironment() === 'prod' ? 20 : 5;

const envVar = process.env.PARADOX_MYSQL_CONNECTION_POOL_SIZE;
Expand Down Expand Up @@ -106,7 +105,7 @@ export class ConnectionFactory {
}

try {
const conn = await createConnection({
const dataSource = new DataSource({
type: 'mysql',
host: opts.hostname,
port: opts.port,
Expand All @@ -132,28 +131,31 @@ export class ConnectionFactory {
maxQueryExecutionTime: 2000,

// these get passed to the underlying driver
// https://github.com/mysqljs/mysql#pooling-connections
// https://github.com/sidorares/node-mysql2#using-connection-pools
extra: {
connectionLimit: opts.connectionCount,
},
});

const conn = await dataSource.initialize();

if (conn.driver instanceof MysqlDriver) {
const tags = { schema: opts.database || 'unknown' };
const pool: Pool = (conn.driver as MysqlDriver).pool;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pool: any = (conn.driver as MysqlDriver).pool;

const idMap: { [id: string]: number } = {};

// attach listeners on the mysql driver for metrics on pool activity
pool.on('acquire', conn => {
// attach listeners on the mysql2 driver for metrics on pool activity
pool.on('acquire', (conn: { threadId?: number }) => {
if (conn.threadId) {
idMap[conn.threadId] = new Date().getTime();
}

Metrics.instance.increment('mysql.connection.active', tags);
});

pool.on('release', conn => {
pool.on('release', (conn: { threadId?: number }) => {
Metrics.instance.increment('mysql.connection.active', -1, tags);

if (conn.threadId && idMap[conn.threadId]) {
Expand All @@ -164,7 +166,6 @@ export class ConnectionFactory {

pool.on('enqueue', () => Metrics.instance.increment('mysql.connection.enqueue', tags));
pool.on('connection', () => Metrics.instance.increment('mysql.connection.new_connection', tags));
pool.on('error', () => Metrics.instance.increment('mysql.connection.error', tags));
}
return conn;
} catch (err) {
Expand All @@ -176,7 +177,7 @@ export class ConnectionFactory {
}
}

async sqlite(synchronize = true): Promise<Connection> {
async sqlite(synchronize = true): Promise<DataSource> {
const id = Math.random().toString();

const name = process.env.JEST_TEST ? `${expect.getState().currentTestName}_${new Date().getTime()}_${id}` : id;
Expand All @@ -186,16 +187,17 @@ export class ConnectionFactory {
// if the env var is set will dump the sqlite db to disk, otherwise will use it in memory
const dbName = process.env.PARADOX_DEBUG_SQLITE_DB ? path : ':memory:';

const conn = await createConnection({
const dataSource = new DataSource({
type: 'sqlite',
name: id,
database: dbName,
logging: process.env.PARADOX_SQLITE_LOGGING === undefined ? ['warn', 'info', 'log'] : 'all',
synchronize: false,
maxQueryExecutionTime: 1000,
entities: this.entities,
});

const conn = await dataSource.initialize();

if (synchronize) {
await conn.synchronize();
}
Expand Down
6 changes: 3 additions & 3 deletions packages/common-sql/src/sql/dataAccessBase.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { xpath } from '@paradoxical-io/common';
import { safeExpect } from '@paradoxical-io/common-test';
import { Column, Connection, Entity, PrimaryColumn, PrimaryGeneratedColumn, Repository } from 'typeorm';
import { Column, DataSource, Entity, PrimaryColumn, PrimaryGeneratedColumn, Repository } from 'typeorm';

import { ConnectionFactory } from './connectionFactory';
import { ColumnNames, CrudBase } from './crudBase';
Expand Down Expand Up @@ -48,7 +48,7 @@ interface Data {
}

class TestRepo extends SqlDataAccessBase {
public constructor(conn: Connection) {
public constructor(conn: DataSource) {
super(conn);
}

Expand All @@ -60,7 +60,7 @@ class TestRepo extends SqlDataAccessBase {
return this.getRepo<TestModelNumericColumn>(TestModelNumericColumn);
}

field1Xpath(value: string): Promise<TestModel | undefined> {
field1Xpath(value: string): Promise<TestModel | null> {
const columns = getColumnNameFunction<TestModel>(TestModel);
return this.testModel()
.createQueryBuilder()
Expand Down
13 changes: 7 additions & 6 deletions packages/common-sql/src/sql/dataAccessBase.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Limiter } from '@paradoxical-io/common';
import { ErrorCode, ErrorWithCode } from '@paradoxical-io/types';
import { Connection, EntityManager, ObjectLiteral, QueryRunner, Repository } from 'typeorm';
import { DataSource, EntityManager, ObjectLiteral, QueryRunner, Repository } from 'typeorm';
import { DatabaseType } from 'typeorm/driver/types/DatabaseType';
import { EntityNotFoundError } from 'typeorm/error/EntityNotFoundError';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
Expand All @@ -25,16 +25,16 @@ export abstract class SqlDataAccessBase {

private readonly driver: DatabaseType;

protected constructor(protected conn: Connection) {
this.driver = conn.driver.options.type;
protected constructor(protected conn: DataSource) {
this.driver = conn.options.type;

// sqlite only supports a single connection
// so parallel transactions will always fail
// wrap the transaction provider in a limiter that only allows 1 concurrent request
// so we can queue off promises under the hood
// basically its implementing https://github.com/typeorm/typeorm/issues/307
// create the wrapper here so we can have a single closed over instance of the limiter
if (conn.driver.options.type === 'sqlite') {
if (conn.options.type === 'sqlite') {
const limiter = new Limiter({ maxConcurrent: 1 });
this.transactionWrapper = <T>(p: () => Promise<T>) => limiter.wrap(p);
} else {
Expand All @@ -45,7 +45,7 @@ export abstract class SqlDataAccessBase {
}

async close() {
await this.conn.close();
await this.conn.destroy();
}

protected getRepo = <T extends ObjectLiteral>(f: Function): Repository<T> => {
Expand Down Expand Up @@ -150,7 +150,8 @@ export abstract class SqlDataAccessBase {
const repo = this.getRepo<T>(modelType);
await this.ignoreDuplicates(() => repo.insert(model));

return repo.findOneOrFail(model.id);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return repo.findOneOrFail({ where: { id: model.id } as any });
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/common-sql/src/sql/health.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { HealthCheck } from '@paradoxical-io/common-server';
import { Connection } from 'typeorm';
import { DataSource } from 'typeorm';

export class DbHealth implements HealthCheck {
constructor(private conn: Connection) {}
constructor(private conn: DataSource) {}

async healthCheck(): Promise<boolean> {
// if the migrations query succeeds, we are connected and passing
Expand Down
50 changes: 5 additions & 45 deletions packages/common-sql/src/sql/ssl/profiles.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,8 @@
const sslProfiles: { 'Amazon RDS': { ca: string[] } } = require('mysql/lib/protocol/constants/ssl_profiles');

// Taken from https://github.com/mysqljs/mysql/pull/2280
// Related to https://aws.amazon.com/blogs/database/amazon-rds-customers-update-your-ssl-tls-certificates-by-february-5-2020/

const updatedProfile =
/**
* Amazon RDS global root CA 2019 to 2024
*
* CN = Amazon RDS Root 2019 CA
* OU = Amazon RDS
* O = Amazon Web Services, Inc.
* L = Seattle
* ST = Washington
* C = US
* P = 2019-08-22T17:08:50Z/2024-08-22T17:08:50Z
* F = D4:0D:DB:29:E3:75:0D:FF:A6:71:C3:14:0B:BF:5F:47:8D:1C:80:96
*/
`-----BEGIN CERTIFICATE-----
MIIEBjCCAu6gAwIBAgIJAMc0ZzaSUK51MA0GCSqGSIb3DQEBCwUAMIGPMQswCQYD
VQQGEwJVUzEQMA4GA1UEBwwHU2VhdHRsZTETMBEGA1UECAwKV2FzaGluZ3RvbjEi
MCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1h
em9uIFJEUzEgMB4GA1UEAwwXQW1hem9uIFJEUyBSb290IDIwMTkgQ0EwHhcNMTkw
ODIyMTcwODUwWhcNMjQwODIyMTcwODUwWjCBjzELMAkGA1UEBhMCVVMxEDAOBgNV
BAcMB1NlYXR0bGUxEzARBgNVBAgMCldhc2hpbmd0b24xIjAgBgNVBAoMGUFtYXpv
biBXZWIgU2VydmljZXMsIEluYy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxIDAeBgNV
BAMMF0FtYXpvbiBSRFMgUm9vdCAyMDE5IENBMIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEArXnF/E6/Qh+ku3hQTSKPMhQQlCpoWvnIthzX6MK3p5a0eXKZ
oWIjYcNNG6UwJjp4fUXl6glp53Jobn+tWNX88dNH2n8DVbppSwScVE2LpuL+94vY
0EYE/XxN7svKea8YvlrqkUBKyxLxTjh+U/KrGOaHxz9v0l6ZNlDbuaZw3qIWdD/I
6aNbGeRUVtpM6P+bWIoxVl/caQylQS6CEYUk+CpVyJSkopwJlzXT07tMoDL5WgX9
O08KVgDNz9qP/IGtAcRduRcNioH3E9v981QO1zt/Gpb2f8NqAjUUCUZzOnij6mx9
McZ+9cWX88CRzR0vQODWuZscgI08NvM69Fn2SQIDAQABo2MwYTAOBgNVHQ8BAf8E
BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUc19g2LzLA5j0Kxc0LjZa
pmD/vB8wHwYDVR0jBBgwFoAUc19g2LzLA5j0Kxc0LjZapmD/vB8wDQYJKoZIhvcN
AQELBQADggEBAHAG7WTmyjzPRIM85rVj+fWHsLIvqpw6DObIjMWokpliCeMINZFV
ynfgBKsf1ExwbvJNzYFXW6dihnguDG9VMPpi2up/ctQTN8tm9nDKOy08uNZoofMc
NUZxKCEkVKZv+IL4oHoeayt8egtv3ujJM6V14AstMQ6SwvwvA93EP/Ug2e4WAXHu
cbI1NAbUgVDqp+DRdfvZkgYKryjTWd/0+1fS8X1bBZVWzl7eirNVnHbSH2ZDpNuY
0SBd8dj5F6ld3t58ydZbrTHze7JJOd8ijySAp4/kiu9UfZWuTPABzDa/DSdz9Dk/
zPW4CXXvhLmE02TA9/HeCw3KEHIwicNuEfw=
-----END CERTIFICATE-----

`;
// aws-ssl-profiles is a transitive dependency of mysql2 that provides
// up-to-date Amazon RDS CA certificate bundles
// eslint-disable-next-line @typescript-eslint/no-var-requires
const awsSslProfiles: { ca: string[] } = require('aws-ssl-profiles');

export const AWS_RDS_PROFILE = {
ca: sslProfiles['Amazon RDS'].ca.concat(updatedProfile),
ca: awsSslProfiles.ca,
};
Loading