Skip to content

Commit 48d8a4f

Browse files
feat: add cache sidecar and source-metronome connector
Adds a speculative balance cache sidecar (apps/cache-sidecar) and a Metronome source connector (packages/source-metronome) for real-time usage-based entitlement enforcement. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Committed-By-Agent: claude
1 parent 965b132 commit 48d8a4f

19 files changed

Lines changed: 1422 additions & 206 deletions

File tree

apps/cache-sidecar/package.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "@stripe/sync-cache-sidecar",
3+
"version": "0.1.0",
4+
"private": true,
5+
"description": "HTTP sidecar for optimistic balance enforcement over synced Metronome data",
6+
"type": "module",
7+
"exports": {
8+
".": {
9+
"bun": "./src/index.ts",
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.js"
12+
}
13+
},
14+
"scripts": {
15+
"build": "tsc",
16+
"dev": "tsx --watch --conditions bun src/index.ts",
17+
"start": "node dist/index.js",
18+
"test": "vitest run",
19+
"test:watch": "vitest"
20+
},
21+
"dependencies": {
22+
"@hono/node-server": "^1",
23+
"hono": "^4",
24+
"ioredis": "^5",
25+
"zod": "^4.3.6"
26+
},
27+
"devDependencies": {
28+
"@types/node": "^24.10.1",
29+
"tsx": "^4",
30+
"vitest": "^3.2.4"
31+
}
32+
}

apps/cache-sidecar/src/config.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { z } from 'zod'
2+
3+
const ConfigSchema = z.object({
4+
REDIS_URL: z.string().default('redis://localhost:56379'),
5+
CHECKPOINT_PREFIX: z.string().default('sync:'),
6+
OPTIMISTIC_PREFIX: z.string().default('optimistic:'),
7+
CREDIT_TYPE_ID: z.string(),
8+
PORT: z.coerce.number().default(4100),
9+
WATERMARK_BUFFER_MS: z.coerce.number().default(10000),
10+
FIXED_EVENT_COST: z.coerce.number().positive().default(1),
11+
})
12+
13+
export type Config = z.infer<typeof ConfigSchema>
14+
15+
export function loadConfig(): Config {
16+
const result = ConfigSchema.safeParse(process.env)
17+
if (!result.success) {
18+
console.error('Invalid configuration:', result.error.format())
19+
process.exit(1)
20+
}
21+
return result.data
22+
}

apps/cache-sidecar/src/index.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { serve } from '@hono/node-server'
2+
import { loadConfig } from './config.js'
3+
import { FixedCostPricing } from './pricing.js'
4+
import { createRedisClient } from './redis.js'
5+
import { createApp } from './server.js'
6+
7+
const config = loadConfig()
8+
const redis = createRedisClient(config)
9+
const pricing = new FixedCostPricing(config.FIXED_EVENT_COST)
10+
11+
const app = createApp({ redis, config, pricing })
12+
13+
const server = serve({ fetch: app.fetch, port: config.PORT }, (info) => {
14+
console.log(
15+
JSON.stringify({
16+
msg: 'cache-sidecar started',
17+
port: info.port,
18+
redis_url: config.REDIS_URL,
19+
})
20+
)
21+
})
22+
23+
function shutdown() {
24+
console.log(JSON.stringify({ msg: 'shutting down' }))
25+
redis.disconnect()
26+
server.close()
27+
process.exit(0)
28+
}
29+
30+
process.on('SIGTERM', shutdown)
31+
process.on('SIGINT', shutdown)

apps/cache-sidecar/src/pricing.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export interface PricingStrategy {
2+
estimateCost(eventType: string, properties?: Record<string, unknown>): number
3+
}
4+
5+
/**
6+
* Fixed-cost pricing: every event costs the same amount of credits.
7+
* Good enough for MVP — swap in a lookup-based strategy later.
8+
*/
9+
export class FixedCostPricing implements PricingStrategy {
10+
constructor(private readonly cost: number) {}
11+
12+
estimateCost(_eventType: string, _properties?: Record<string, unknown>): number {
13+
return this.cost
14+
}
15+
}

apps/cache-sidecar/src/redis.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Redis } from 'ioredis'
2+
import type { Config } from './config.js'
3+
4+
export interface CheckpointData {
5+
balance: number
6+
customer_id: string
7+
credit_type_id: string
8+
_synced_at: number
9+
}
10+
11+
export interface PendingEvent {
12+
event_id: string
13+
event_type: string
14+
estimated_cost: number
15+
timestamp: number
16+
properties?: Record<string, unknown>
17+
}
18+
19+
export type { Redis }
20+
21+
export function createRedisClient(config: Config): Redis {
22+
const client = new Redis(config.REDIS_URL, { maxRetriesPerRequest: 3 })
23+
client.on('error', (err) => {
24+
console.error(JSON.stringify({ msg: 'redis error', error: err.message }))
25+
})
26+
return client
27+
}
28+
29+
export function checkpointKey(config: Config, customerId: string): string {
30+
return `${config.CHECKPOINT_PREFIX}net_balance:${customerId}:${config.CREDIT_TYPE_ID}`
31+
}
32+
33+
export function pendingSetKey(config: Config, customerId: string): string {
34+
return `${config.OPTIMISTIC_PREFIX}pending:${customerId}`
35+
}
36+
37+
export async function getCheckpoint(
38+
redis: Redis,
39+
config: Config,
40+
customerId: string
41+
): Promise<CheckpointData | null> {
42+
const raw = await redis.get(checkpointKey(config, customerId))
43+
if (!raw) return null
44+
return JSON.parse(raw) as CheckpointData
45+
}
46+
47+
export async function getPendingEvents(
48+
redis: Redis,
49+
config: Config,
50+
customerId: string
51+
): Promise<PendingEvent[]> {
52+
const members = await redis.zrange(pendingSetKey(config, customerId), 0, -1)
53+
return members.map((m) => JSON.parse(m) as PendingEvent)
54+
}
55+
56+
export async function sumPendingCosts(
57+
redis: Redis,
58+
config: Config,
59+
customerId: string
60+
): Promise<{ total: number; count: number }> {
61+
const events = await getPendingEvents(redis, config, customerId)
62+
// Sub-cent precision; round to avoid IEEE-754 drift
63+
const total = Math.round(events.reduce((sum, e) => sum + e.estimated_cost, 0) * 100) / 100
64+
return { total, count: events.length }
65+
}

0 commit comments

Comments
 (0)