Skip to content
Closed
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
30 changes: 8 additions & 22 deletions packages/sync-engine/src/resourceRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Stripe from 'stripe'
import type { ResourceConfig } from './types'
import type { SigmaSyncProcessor } from './sigma/sigmaSyncProcessor'
import { computeResourceOrder } from './utils/computeResourceOrder'

export type StripeObject =
| 'product'
Expand All @@ -25,11 +26,10 @@ export type StripeObject =

// Resource registry - maps SyncObject → list/retrieve operations
// Upsert is handled universally via StripeSync.upsertAny()
// Order field determines sync sequence - parents before children for FK dependencies
// Order field [computed] determines sync sequence - parents before children for FK dependencies
export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, ResourceConfig> {
const core: Record<StripeObject, ResourceConfig> = {
const core: Record<StripeObject, Omit<ResourceConfig, 'order'>> = {
product: {
order: 1,
tableName: 'products',
dependencies: [],
listFn: (p) => stripe.products.list(p),
Expand All @@ -38,7 +38,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
sync: true,
},
price: {
order: 2,
tableName: 'prices',
dependencies: ['product'],
listFn: (p) => stripe.prices.list(p),
Expand All @@ -47,7 +46,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
sync: true,
},
plan: {
order: 3,
tableName: 'plans',
dependencies: ['product'],
listFn: (p) => stripe.plans.list(p),
Expand All @@ -56,7 +54,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
sync: true,
},
customer: {
order: 4,
tableName: 'customers',
dependencies: [],
listFn: (p) => stripe.customers.list(p),
Expand All @@ -67,7 +64,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
'deleted' in customer && customer.deleted === true,
},
subscription: {
order: 5,
tableName: 'subscriptions',
dependencies: ['customer', 'price'],
listFn: (p) => stripe.subscriptions.list(p),
Expand All @@ -81,7 +77,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
subscription.status === 'canceled' || subscription.status === 'incomplete_expired',
},
subscription_schedules: {
order: 6,
tableName: 'subscription_schedules',
dependencies: ['customer'],
listFn: (p) => stripe.subscriptionSchedules.list(p),
Expand All @@ -92,7 +87,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
schedule.status === 'canceled' || schedule.status === 'completed',
},
invoice: {
order: 7,
tableName: 'invoices',
dependencies: ['customer', 'subscription'],
listFn: (p) => stripe.invoices.list(p),
Expand All @@ -103,7 +97,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
isFinalState: (invoice: Stripe.Invoice) => invoice.status === 'void',
},
charge: {
order: 8,
tableName: 'charges',
dependencies: ['customer', 'invoice'],
listFn: (p) => stripe.charges.list(p),
Expand All @@ -115,7 +108,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
charge.status === 'failed' || charge.status === 'succeeded',
},
setup_intent: {
order: 9,
tableName: 'setup_intents',
dependencies: ['customer'],
listFn: (p) => stripe.setupIntents.list(p),
Expand All @@ -126,7 +118,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
setupIntent.status === 'canceled' || setupIntent.status === 'succeeded',
},
payment_method: {
order: 10,
tableName: 'payment_methods',
dependencies: ['customer'],
listFn: (p) => stripe.paymentMethods.list(p),
Expand All @@ -135,7 +126,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
sync: true,
},
payment_intent: {
order: 11,
tableName: 'payment_intents',
dependencies: ['customer', 'invoice'],
listFn: (p) => stripe.paymentIntents.list(p),
Expand All @@ -146,7 +136,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
paymentIntent.status === 'canceled' || paymentIntent.status === 'succeeded',
},
tax_id: {
order: 12,
tableName: 'tax_ids',
dependencies: ['customer'],
listFn: (p) => stripe.taxIds.list(p),
Expand All @@ -155,7 +144,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
sync: true,
},
credit_note: {
order: 13,
tableName: 'credit_notes',
dependencies: ['customer', 'invoice'],
listFn: (p) => stripe.creditNotes.list(p),
Expand All @@ -166,7 +154,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
isFinalState: (creditNote: Stripe.CreditNote) => creditNote.status === 'void',
},
dispute: {
order: 14,
tableName: 'disputes',
dependencies: ['charge'],
listFn: (p) => stripe.disputes.list(p),
Expand All @@ -177,7 +164,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
dispute.status === 'won' || dispute.status === 'lost',
},
early_fraud_warning: {
order: 15,
tableName: 'early_fraud_warnings',
dependencies: ['payment_intent', 'charge'],
listFn: (p) => stripe.radar.earlyFraudWarnings.list(p),
Expand All @@ -186,7 +172,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
sync: true,
},
refund: {
order: 16,
tableName: 'refunds',
dependencies: ['payment_intent', 'charge'],
listFn: (p) => stripe.refunds.list(p),
Expand All @@ -195,7 +180,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
sync: true,
},
checkout_sessions: {
order: 17,
tableName: 'checkout_sessions',
dependencies: ['customer', 'subscription', 'payment_intent', 'invoice'],
listFn: (p) => stripe.checkout.sessions.list(p),
Expand All @@ -205,7 +189,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
listExpands: [{ lines: (id) => stripe.checkout.sessions.listLineItems(id, { limit: 100 }) }],
},
active_entitlements: {
order: 18,
tableName: 'active_entitlements',
dependencies: ['customer'],
listFn: (p) =>
Expand All @@ -217,7 +200,6 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
sync: false,
},
review: {
order: 19,
tableName: 'reviews',
dependencies: ['payment_intent', 'charge'],
listFn: (p) => stripe.reviews.list(p),
Expand All @@ -227,7 +209,11 @@ export function buildResourceRegistry(stripe: Stripe): Record<StripeObject, Reso
},
}

return core
const orderMap = computeResourceOrder(core)

return Object.fromEntries(
Object.entries(core).map(([key, config]) => [key, { ...config, order: orderMap.get(key)! }])
) as Record<StripeObject, ResourceConfig>
}

/**
Expand Down
96 changes: 96 additions & 0 deletions packages/sync-engine/src/tests/unit/computeResourceOrder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest'
import { computeResourceOrder } from '../../utils/computeResourceOrder'

describe('computeResourceOrder', () => {
it('returns empty map for empty input', () => {
const result = computeResourceOrder({})
expect(result.size).toBe(0)
})

it('places dependencies before their dependents', () => {
const result = computeResourceOrder({
price: { dependencies: ['product'] },
product: {},
})
expect(result.get('product')).toBeLessThan(result.get('price')!)
})

it('handles a linear dependency chain', () => {
const result = computeResourceOrder({
subscription: { dependencies: ['price'] },
price: { dependencies: ['product'] },
product: {},
})
expect(result.get('product')).toBe(1)
expect(result.get('price')).toBe(2)
expect(result.get('subscription')).toBe(3)
})

it('handles diamond-shaped dependencies', () => {
const result = computeResourceOrder({
product: {},
price: { dependencies: ['product'] },
coupon: { dependencies: ['product'] },
subscription: { dependencies: ['price', 'coupon'] },
})
expect(result.get('product')).toBe(1)
expect(result.get('price')).toBeLessThan(result.get('subscription')!)
expect(result.get('coupon')).toBeLessThan(result.get('subscription')!)
})

it('ignores dependencies on unknown resources', () => {
const result = computeResourceOrder({
price: { dependencies: ['nonexistent'] },
product: {},
})
expect(result.size).toBe(2)
expect(result.has('price')).toBe(true)
expect(result.has('product')).toBe(true)
})

it('handles resources with empty dependency arrays', () => {
const result = computeResourceOrder({
product: { dependencies: [] },
customer: { dependencies: [] },
})
expect(result.size).toBe(2)
})

it('throws on circular dependency between two resources', () => {
expect(() =>
computeResourceOrder({
a: { dependencies: ['b'] },
b: { dependencies: ['a'] },
})
).toThrow('Circular dependency detected among: a, b')
})

it('throws on circular dependency in a cycle of three', () => {
expect(() =>
computeResourceOrder({
a: { dependencies: ['c'] },
b: { dependencies: ['a'] },
c: { dependencies: ['b'] },
})
).toThrow('Circular dependency detected among: a, b, c')
})

it('throws on self-referencing dependency', () => {
expect(() =>
computeResourceOrder({
a: { dependencies: ['a'] },
})
).toThrow('Circular dependency detected among: a')
})

it('handles multiple independent dependency chains', () => {
const result = computeResourceOrder({
product: {},
price: { dependencies: ['product'] },
customer: {},
subscription: { dependencies: ['customer'] },
})
expect(result.get('product')).toBeLessThan(result.get('price')!)
expect(result.get('customer')).toBeLessThan(result.get('subscription')!)
})
})
49 changes: 49 additions & 0 deletions packages/sync-engine/src/utils/computeResourceOrder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Compute sync order via topological sort (Kahn's algorithm).
*/
export function computeResourceOrder(
resources: Record<string, { dependencies?: string[] }>
): Map<string, number> {
const names = Object.keys(resources)
const inDegree = new Map<string, number>()
const dependents = new Map<string, string[]>()

for (const name of names) {
inDegree.set(name, 0)
dependents.set(name, [])
}

for (const name of names) {
for (const dep of resources[name].dependencies ?? []) {
if (dependents.has(dep)) {
inDegree.set(name, (inDegree.get(name) ?? 0) + 1)
dependents.get(dep)!.push(name)
}
}
}

const queue: string[] = names.filter((n) => inDegree.get(n) === 0)
const orderMap = new Map<string, number>()
let order = 1

let front = 0
while (front < queue.length) {
const current = queue[front++]
orderMap.set(current, order++)

for (const dependent of dependents.get(current) ?? []) {
const newDeg = (inDegree.get(dependent) ?? 1) - 1
inDegree.set(dependent, newDeg)
if (newDeg === 0) {
queue.push(dependent)
}
}
}

if (orderMap.size !== names.length) {
const missing = names.filter((n) => !orderMap.has(n))
throw new Error(`Circular dependency detected among: ${missing.join(', ')}`)
}

return orderMap
}