Skip to content

Commit d86a666

Browse files
kdhillon-stripeclaudeYostra
authored
feat(source-stripe): expose supports_realtime_sync in catalog metadata (#345)
* feat(source-stripe): expose supports_realtime_sync in catalog metadata Add a `supports_realtime_sync` boolean to each stream's metadata in the discover catalog. This lets destinations know which tables receive real-time updates via webhooks vs. tables that are backfill-only. A new `REALTIME_SYNC_TABLES` set in types.ts is derived from the existing SUPPORTED_WEBHOOK_EVENTS list. Two new tests verify the flag is true for webhook-backed tables and false for list-only tables. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> Committed-By-Agent: claude * step one, remove the fan out of discover list... * unify discover pipeline, fuse schema into registry, single-arg catalogFromOpenApi * use bundled version * skip version with no webhook info * use enabled events to discover webhook supported objects * v2 webhooks * catalog snap --------- Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Yostra <straya.mark@gmail.com>
1 parent 9476b73 commit d86a666

16 files changed

Lines changed: 520 additions & 428 deletions

e2e/test-server-all-api.test.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
resolveOpenApiSpec,
1818
findSchemaNameByResourceId,
1919
generateObjectsFromSchema,
20+
SpecParser,
2021
} from '@stripe/sync-openapi'
2122
import destinationPostgres from '@stripe/sync-destination-postgres'
2223
import sourceStripe, { type StreamState, EXCLUDED_TABLES } from '@stripe/sync-source-stripe'
@@ -140,7 +141,10 @@ async function resolveSpecPath(apiVersion: string): Promise<string> {
140141
return resolved.cachePath
141142
}
142143

143-
async function syncAllEndpointsForVersion(apiVersion: string): Promise<void> {
144+
async function syncAllEndpointsForVersion(
145+
apiVersion: string,
146+
skip: (reason: string) => void
147+
): Promise<void> {
144148
const createdRange = { startUnix: RANGE_START, endUnix: RANGE_END }
145149
const openApiSpecPath = await resolveSpecPath(apiVersion)
146150
const endpointSet = await resolveEndpointSet({
@@ -166,21 +170,32 @@ async function syncAllEndpointsForVersion(apiVersion: string): Promise<void> {
166170
fetchImpl: specFetch,
167171
})
168172

169-
expect(sortedEndpoints.length, `${apiVersion} should expose at least one stream`).toBeGreaterThan(
170-
0
171-
)
173+
if (sortedEndpoints.length === 0) {
174+
await versionTestServer.close().catch(() => {})
175+
skip(`${apiVersion}: spec exposes no listable endpoints`)
176+
return
177+
}
172178

173179
try {
174180
// v2_core_events uses ISO timestamps for created filter and opaque page tokens;
175181
// the test-server's V2 pagination + subdivision interaction is not yet verified.
176182
const TEST_EXCLUDED = new Set([...EXCLUDED_TABLES, 'v2_core_events'])
183+
const syncableTables = new SpecParser().discoverSyncableTables(endpointSet.spec, {
184+
excluded: EXCLUDED_TABLES,
185+
})
177186
const seedable = sortedEndpoints.filter(
178187
(ep) =>
179188
findSchemaNameByResourceId(endpointSet.spec, ep.resourceId) != null &&
180189
!TEST_EXCLUDED.has(ep.tableName) &&
190+
syncableTables.has(ep.tableName) &&
181191
(!STREAM_FILTER || STREAM_FILTER.has(ep.tableName))
182192
)
183193

194+
if (seedable.length === 0) {
195+
skip(`${apiVersion}: no syncable streams after filtering`)
196+
return
197+
}
198+
184199
for (let i = 0; i < seedable.length; i += SEED_CONCURRENCY) {
185200
const batch = seedable.slice(i, i + SEED_CONCURRENCY)
186201
await Promise.all(
@@ -404,12 +419,13 @@ describe('test-server API', () => {
404419
for (const supportedApiVersion of SUPPORTED_API_VERSIONS) {
405420
it(
406421
`syncs all supported streams for Stripe API ${supportedApiVersion}`,
407-
async () => {
422+
async (ctx) => {
408423
const year = parseInt(supportedApiVersion.slice(0, 4), 10)
409424
if (year < 2020) {
425+
ctx.skip(`${supportedApiVersion}: pre-2020 versions are not exercised`)
410426
return
411427
}
412-
await syncAllEndpointsForVersion(supportedApiVersion)
428+
await syncAllEndpointsForVersion(supportedApiVersion, (reason) => ctx.skip(reason))
413429
},
414430
3 * 60_000
415431
)

e2e/test-server-sync.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ describe('test-server sync via Docker engine', () => {
143143
type: 'stripe',
144144
stripe: {
145145
api_key: 'sk_test_fake',
146-
api_version: '2025-04-30.basil',
146+
api_version: BUNDLED_API_VERSION,
147147
base_url: harness.testServerContainerUrl(),
148148
...opts.sourceOverrides,
149149
},

packages/openapi/__tests__/listFnResolver.test.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { describe, expect, it, vi } from 'vitest'
2-
import { buildListFn, buildRetrieveFn, discoverListEndpoints } from '../listFnResolver'
2+
import { buildListFn, buildRetrieveFn } from '../listFnResolver'
3+
import { SpecParser } from '../specParser'
34
import { isDeprecatedOperation } from '../specCleaning'
45
import { minimalStripeOpenApiSpec } from './fixtures/minimalSpec'
56

6-
describe('discoverListEndpoints', () => {
7+
describe('SpecParser.discoverListEndpoints', () => {
8+
const parser = new SpecParser()
9+
710
it('maps table names to their API paths', () => {
8-
const endpoints = discoverListEndpoints(minimalStripeOpenApiSpec)
11+
const endpoints = parser.discoverListEndpoints(minimalStripeOpenApiSpec)
912

1013
expect(endpoints.get('customers')).toEqual({
1114
tableName: 'customers',
@@ -37,7 +40,7 @@ describe('discoverListEndpoints', () => {
3740
})
3841

3942
it('discovers v2 list endpoints using next_page_url format', () => {
40-
const endpoints = discoverListEndpoints(minimalStripeOpenApiSpec)
43+
const endpoints = parser.discoverListEndpoints(minimalStripeOpenApiSpec)
4144

4245
expect(endpoints.get('v2_core_accounts')).toEqual({
4346
tableName: 'v2_core_accounts',
@@ -89,35 +92,37 @@ describe('discoverListEndpoints', () => {
8992
},
9093
},
9194
}
92-
const endpoints = discoverListEndpoints(spec)
95+
const endpoints = parser.discoverListEndpoints(spec)
9396
const paths = Array.from(endpoints.values()).map((e) => e.apiPath)
9497
expect(paths).not.toContain('/v1/customers/{customer}/sources')
9598
})
9699

97100
it('skips endpoints with deprecated: true on the operation', () => {
98-
const endpoints = discoverListEndpoints(minimalStripeOpenApiSpec)
101+
const endpoints = parser.discoverListEndpoints(minimalStripeOpenApiSpec)
99102
const tables = Array.from(endpoints.keys())
100103
expect(tables).not.toContain('deprecated_widgets')
101104
})
102105

103106
it('skips endpoints with [Deprecated] in the description', () => {
104-
const endpoints = discoverListEndpoints(minimalStripeOpenApiSpec)
107+
const endpoints = parser.discoverListEndpoints(minimalStripeOpenApiSpec)
105108
const tables = Array.from(endpoints.keys())
106109
expect(tables).not.toContain('exchange_rates')
107110
})
108111

109112
it('skips endpoints that appear in the generated global deprecated paths set', () => {
110-
const endpoints = discoverListEndpoints(minimalStripeOpenApiSpec)
113+
const endpoints = parser.discoverListEndpoints(minimalStripeOpenApiSpec)
111114
const tables = Array.from(endpoints.keys())
112115
expect(tables).not.toContain('recipients')
113116
expect(tables).toContain('customers')
114117
})
115118

116119
it('returns empty map when spec has no paths', () => {
117-
const endpoints = discoverListEndpoints({ openapi: '3.0.0' })
120+
const endpoints = parser.discoverListEndpoints({ openapi: '3.0.0' })
118121
expect(endpoints.size).toBe(0)
119122
})
123+
})
120124

125+
describe('buildListFn / buildRetrieveFn', () => {
121126
it('uses the injected fetch for list and retrieve calls', async () => {
122127
const fetchMock = vi.fn(
123128
async () => new Response(JSON.stringify({ data: [], has_more: false }), { status: 200 })

packages/openapi/__tests__/specParser.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,3 +470,56 @@ describe('SpecParser', () => {
470470
})
471471
})
472472
})
473+
474+
describe('SpecParser.discoverSyncableTables', () => {
475+
const parser = new SpecParser()
476+
477+
it('returns the intersection of listable and webhook-updatable resources, resolved to table names', () => {
478+
const tables = parser.discoverSyncableTables(minimalStripeOpenApiSpec)
479+
480+
expect(tables).toContain('customers')
481+
expect(tables).toContain('products')
482+
expect(tables).toContain('plans')
483+
expect(tables).toContain('checkout_sessions')
484+
expect(tables).toContain('early_fraud_warnings')
485+
})
486+
487+
it('excludes resources that are listable but have no webhook events', () => {
488+
const tables = parser.discoverSyncableTables(minimalStripeOpenApiSpec)
489+
490+
expect(tables).not.toContain('exchange_rates')
491+
expect(tables).not.toContain('recipients')
492+
})
493+
494+
it('honors the excluded option', () => {
495+
const baseline = parser.discoverSyncableTables(minimalStripeOpenApiSpec)
496+
expect(baseline).toContain('customers')
497+
498+
const filtered = parser.discoverSyncableTables(minimalStripeOpenApiSpec, {
499+
excluded: new Set(['customers']),
500+
})
501+
expect(filtered).not.toContain('customers')
502+
expect(filtered).toContain('products')
503+
})
504+
505+
it('honors caller-provided aliases over the defaults', () => {
506+
const tables = parser.discoverSyncableTables(minimalStripeOpenApiSpec, {
507+
aliases: { customer: 'patrons' },
508+
})
509+
expect(tables).toContain('patrons')
510+
expect(tables).not.toContain('customers')
511+
})
512+
513+
it('returns the same set that SpecParser.parse uses internally', () => {
514+
const parsed = parser.parse(minimalStripeOpenApiSpec)
515+
const parsedTables = new Set(parsed.tables.map((t) => t.tableName))
516+
const syncable = parser.discoverSyncableTables(minimalStripeOpenApiSpec)
517+
518+
expect(syncable).toEqual(parsedTables)
519+
})
520+
521+
it('returns empty set when spec has no paths', () => {
522+
const spec: OpenApiSpec = { ...minimalStripeOpenApiSpec, paths: {} }
523+
expect(parser.discoverSyncableTables(spec)).toEqual(new Set())
524+
})
525+
})

packages/openapi/browser.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Browser-safe entry. Excludes specFetchHelper (which imports node:fs / node:path)
22
// so consumers in webpack/Next.js client bundles can import SpecParser without errors.
33

4-
export { SpecParser, OPENAPI_RESOURCE_TABLE_ALIASES } from './specParser.js'
4+
export { SpecParser, OPENAPI_RESOURCE_TABLE_ALIASES, resolveTableName } from './specParser.js'
5+
export type { ListEndpoint, NestedEndpoint } from './specParser.js'
56
export { OPENAPI_COMPATIBILITY_COLUMNS } from './runtimeMappings.js'
67
export { parsedTableToJsonSchema } from './jsonSchemaConverter.js'
78
export type {

packages/openapi/index.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export type * from './types.js'
2-
export { SpecParser, OPENAPI_RESOURCE_TABLE_ALIASES } from './specParser.js'
2+
export { SpecParser, OPENAPI_RESOURCE_TABLE_ALIASES, resolveTableName } from './specParser.js'
3+
export type { ListEndpoint, NestedEndpoint } from './specParser.js'
34
export { OPENAPI_COMPATIBILITY_COLUMNS } from './runtimeMappings.js'
45

56
export {
@@ -8,23 +9,13 @@ export {
89
SUPPORTED_API_VERSIONS,
910
} from './specFetchHelper.js'
1011
export {
11-
discoverListEndpoints,
12-
discoverNestedEndpoints,
1312
isV2Path,
1413
buildListFn,
1514
buildRetrieveFn,
16-
resolveTableName,
1715
StripeApiRequestError,
1816
pickDebugHeaders,
1917
} from './listFnResolver.js'
20-
export type {
21-
ListEndpoint,
22-
NestedEndpoint,
23-
ListFn,
24-
ListResult,
25-
RetrieveFn,
26-
ListParams,
27-
} from './listFnResolver.js'
18+
export type { ListFn, ListResult, RetrieveFn, ListParams } from './listFnResolver.js'
2819
export { parsedTableToJsonSchema } from './jsonSchemaConverter.js'
2920
export { generateObjectsFromSchema, findSchemaNameByResourceId } from './objectGenerator.js'
3021
export type { GenerateObjectsOptions } from './objectGenerator.js'

0 commit comments

Comments
 (0)