From 169418ce2800ad19189a69becf246ec77d4a2630 Mon Sep 17 00:00:00 2001 From: Yostra Date: Fri, 27 Feb 2026 13:33:33 +0100 Subject: [PATCH 1/3] compute order based on deps --- packages/sync-engine/src/resourceRegistry.ts | 30 +++--------- .../src/utils/computeResourceOrder.ts | 49 +++++++++++++++++++ 2 files changed, 57 insertions(+), 22 deletions(-) create mode 100644 packages/sync-engine/src/utils/computeResourceOrder.ts diff --git a/packages/sync-engine/src/resourceRegistry.ts b/packages/sync-engine/src/resourceRegistry.ts index f5b8bd99..f61042ef 100644 --- a/packages/sync-engine/src/resourceRegistry.ts +++ b/packages/sync-engine/src/resourceRegistry.ts @@ -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' @@ -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 { - const core: Record = { + const core: Record> = { product: { - order: 1, tableName: 'products', dependencies: [], listFn: (p) => stripe.products.list(p), @@ -38,7 +38,6 @@ export function buildResourceRegistry(stripe: Stripe): Record stripe.prices.list(p), @@ -47,7 +46,6 @@ export function buildResourceRegistry(stripe: Stripe): Record stripe.plans.list(p), @@ -56,7 +54,6 @@ export function buildResourceRegistry(stripe: Stripe): Record stripe.customers.list(p), @@ -67,7 +64,6 @@ export function buildResourceRegistry(stripe: Stripe): Record stripe.subscriptions.list(p), @@ -81,7 +77,6 @@ export function buildResourceRegistry(stripe: Stripe): Record stripe.subscriptionSchedules.list(p), @@ -92,7 +87,6 @@ export function buildResourceRegistry(stripe: Stripe): Record stripe.invoices.list(p), @@ -103,7 +97,6 @@ export function buildResourceRegistry(stripe: Stripe): Record invoice.status === 'void', }, charge: { - order: 8, tableName: 'charges', dependencies: ['customer', 'invoice'], listFn: (p) => stripe.charges.list(p), @@ -115,7 +108,6 @@ export function buildResourceRegistry(stripe: Stripe): Record stripe.setupIntents.list(p), @@ -126,7 +118,6 @@ export function buildResourceRegistry(stripe: Stripe): Record stripe.paymentMethods.list(p), @@ -135,7 +126,6 @@ export function buildResourceRegistry(stripe: Stripe): Record stripe.paymentIntents.list(p), @@ -146,7 +136,6 @@ export function buildResourceRegistry(stripe: Stripe): Record stripe.taxIds.list(p), @@ -155,7 +144,6 @@ export function buildResourceRegistry(stripe: Stripe): Record stripe.creditNotes.list(p), @@ -166,7 +154,6 @@ export function buildResourceRegistry(stripe: Stripe): Record creditNote.status === 'void', }, dispute: { - order: 14, tableName: 'disputes', dependencies: ['charge'], listFn: (p) => stripe.disputes.list(p), @@ -177,7 +164,6 @@ export function buildResourceRegistry(stripe: Stripe): Record stripe.radar.earlyFraudWarnings.list(p), @@ -186,7 +172,6 @@ export function buildResourceRegistry(stripe: Stripe): Record stripe.refunds.list(p), @@ -195,7 +180,6 @@ export function buildResourceRegistry(stripe: Stripe): Record stripe.checkout.sessions.list(p), @@ -205,7 +189,6 @@ export function buildResourceRegistry(stripe: Stripe): Record stripe.checkout.sessions.listLineItems(id, { limit: 100 }) }], }, active_entitlements: { - order: 18, tableName: 'active_entitlements', dependencies: ['customer'], listFn: (p) => @@ -217,7 +200,6 @@ export function buildResourceRegistry(stripe: Stripe): Record stripe.reviews.list(p), @@ -227,7 +209,11 @@ export function buildResourceRegistry(stripe: Stripe): Record [key, { ...config, order: orderMap.get(key)! }]) + ) as Record } /** diff --git a/packages/sync-engine/src/utils/computeResourceOrder.ts b/packages/sync-engine/src/utils/computeResourceOrder.ts new file mode 100644 index 00000000..478c3c3e --- /dev/null +++ b/packages/sync-engine/src/utils/computeResourceOrder.ts @@ -0,0 +1,49 @@ +/** + * Compute sync order via topological sort (Kahn's algorithm). + */ +export function computeResourceOrder( + resources: Record +): Map { + const names = Object.keys(resources) + const inDegree = new Map() + const dependents = new Map() + + 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() + 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 +} From 7e5b892d752b19e839204b340372e704987f217d Mon Sep 17 00:00:00 2001 From: Yostra Date: Mon, 2 Mar 2026 18:00:26 +0100 Subject: [PATCH 2/3] add tests --- .../src/utils/computeResourceOrder.test.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 packages/sync-engine/src/utils/computeResourceOrder.test.ts diff --git a/packages/sync-engine/src/utils/computeResourceOrder.test.ts b/packages/sync-engine/src/utils/computeResourceOrder.test.ts new file mode 100644 index 00000000..8beebae3 --- /dev/null +++ b/packages/sync-engine/src/utils/computeResourceOrder.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest' +import { computeResourceOrder } from './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')!) + }) +}) From 9c9a1b17dcc6bbb1f5cd23907f1529057241c082 Mon Sep 17 00:00:00 2001 From: Yostra Date: Mon, 2 Mar 2026 18:03:19 +0100 Subject: [PATCH 3/3] move to correct folder --- .../src/{utils => tests/unit}/computeResourceOrder.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename packages/sync-engine/src/{utils => tests/unit}/computeResourceOrder.test.ts (97%) diff --git a/packages/sync-engine/src/utils/computeResourceOrder.test.ts b/packages/sync-engine/src/tests/unit/computeResourceOrder.test.ts similarity index 97% rename from packages/sync-engine/src/utils/computeResourceOrder.test.ts rename to packages/sync-engine/src/tests/unit/computeResourceOrder.test.ts index 8beebae3..cf4a8249 100644 --- a/packages/sync-engine/src/utils/computeResourceOrder.test.ts +++ b/packages/sync-engine/src/tests/unit/computeResourceOrder.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { computeResourceOrder } from './computeResourceOrder' +import { computeResourceOrder } from '../../utils/computeResourceOrder' describe('computeResourceOrder', () => { it('returns empty map for empty input', () => {