Skip to content
Draft
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
32 changes: 32 additions & 0 deletions apps/cache-sidecar/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@stripe/sync-cache-sidecar",
"version": "0.1.0",
"private": true,
"description": "HTTP sidecar for optimistic balance enforcement over synced Metronome data",
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsx --watch --conditions bun src/index.ts",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@hono/node-server": "^1",
"hono": "^4",
"ioredis": "^5",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^24.10.1",
"tsx": "^4",
"vitest": "^3.2.4"
}
}
22 changes: 22 additions & 0 deletions apps/cache-sidecar/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { z } from 'zod'

const ConfigSchema = z.object({
REDIS_URL: z.string().default('redis://localhost:56379'),
CHECKPOINT_PREFIX: z.string().default('sync:'),
OPTIMISTIC_PREFIX: z.string().default('optimistic:'),
CREDIT_TYPE_ID: z.string(),
PORT: z.coerce.number().default(4100),
WATERMARK_BUFFER_MS: z.coerce.number().default(10000),
FIXED_EVENT_COST: z.coerce.number().positive().default(1),
})

export type Config = z.infer<typeof ConfigSchema>

export function loadConfig(): Config {
const result = ConfigSchema.safeParse(process.env)
if (!result.success) {
console.error('Invalid configuration:', result.error.format())
process.exit(1)
}
return result.data
}
31 changes: 31 additions & 0 deletions apps/cache-sidecar/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { serve } from '@hono/node-server'
import { loadConfig } from './config.js'
import { FixedCostPricing } from './pricing.js'
import { createRedisClient } from './redis.js'
import { createApp } from './server.js'

const config = loadConfig()
const redis = createRedisClient(config)
const pricing = new FixedCostPricing(config.FIXED_EVENT_COST)

const app = createApp({ redis, config, pricing })

const server = serve({ fetch: app.fetch, port: config.PORT }, (info) => {
console.log(
JSON.stringify({
msg: 'cache-sidecar started',
port: info.port,
redis_url: config.REDIS_URL,
})
)
})

function shutdown() {
console.log(JSON.stringify({ msg: 'shutting down' }))
redis.disconnect()
server.close()
process.exit(0)
}

process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
15 changes: 15 additions & 0 deletions apps/cache-sidecar/src/pricing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface PricingStrategy {
estimateCost(eventType: string, properties?: Record<string, unknown>): number
}

/**
* Fixed-cost pricing: every event costs the same amount of credits.
* Good enough for MVP — swap in a lookup-based strategy later.
*/
export class FixedCostPricing implements PricingStrategy {
constructor(private readonly cost: number) {}

estimateCost(_eventType: string, _properties?: Record<string, unknown>): number {
return this.cost
}
}
65 changes: 65 additions & 0 deletions apps/cache-sidecar/src/redis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Redis } from 'ioredis'
import type { Config } from './config.js'

export interface CheckpointData {
balance: number
customer_id: string
credit_type_id: string
_synced_at: number
}

export interface PendingEvent {
event_id: string
event_type: string
estimated_cost: number
timestamp: number
properties?: Record<string, unknown>
}

export type { Redis }

export function createRedisClient(config: Config): Redis {
const client = new Redis(config.REDIS_URL, { maxRetriesPerRequest: 3 })
client.on('error', (err) => {
console.error(JSON.stringify({ msg: 'redis error', error: err.message }))
})
return client
}

export function checkpointKey(config: Config, customerId: string): string {
return `${config.CHECKPOINT_PREFIX}net_balance:${customerId}:${config.CREDIT_TYPE_ID}`
}

export function pendingSetKey(config: Config, customerId: string): string {
return `${config.OPTIMISTIC_PREFIX}pending:${customerId}`
}

export async function getCheckpoint(
redis: Redis,
config: Config,
customerId: string
): Promise<CheckpointData | null> {
const raw = await redis.get(checkpointKey(config, customerId))
if (!raw) return null
return JSON.parse(raw) as CheckpointData
}

export async function getPendingEvents(
redis: Redis,
config: Config,
customerId: string
): Promise<PendingEvent[]> {
const members = await redis.zrange(pendingSetKey(config, customerId), 0, -1)
return members.map((m) => JSON.parse(m) as PendingEvent)
}

export async function sumPendingCosts(
redis: Redis,
config: Config,
customerId: string
): Promise<{ total: number; count: number }> {
const events = await getPendingEvents(redis, config, customerId)
// Sub-cent precision; round to avoid IEEE-754 drift
const total = Math.round(events.reduce((sum, e) => sum + e.estimated_cost, 0) * 100) / 100
return { total, count: events.length }
}
Loading
Loading