Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 5 additions & 1 deletion gen/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ export const CONFIG: ConfigType = {
RELATIONSHIP_FUNCTIONS: true,
TRIGGER_FUNCTIONS: true,
RESOURCES_FULL_BUNDLE: true,
RESOURCES_INSTANCE_STYLE: 'accessors_only'
// Bundle clients must be isolated per access token. `leazy_loading` emits
// private cache fields + lazy getters that bind each resource to the
// client's own adapter (via `.withAdapter(this.adapter)`), instead of
// `accessors_only` which returned the shared process-global singletons.
RESOURCES_INSTANCE_STYLE: 'leazy_loading'
}
CONFIG.RESOURCES_STANDARD_OBJECT = (CONFIG.RESOURCES_INSTANCE_STYLE === 'standard_object')
CONFIG.RESOURCES_LEAZY_LOADING = (CONFIG.RESOURCES_INSTANCE_STYLE === 'leazy_loading')
Expand Down
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,23 +57,23 @@
"node": ">=20"
},
"devDependencies": {
"@biomejs/biome": "^2.5.1",
"@biomejs/biome": "^2.5.4",
"@commercelayer/js-auth": "^7.4.2",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@types/debug": "^4.1.13",
"@types/node": "^25.9.4",
"@vitest/coverage-v8": "^4.1.9",
"@vitest/eslint-plugin": "^1.6.20",
"@types/node": "^25.9.5",
"@vitest/coverage-v8": "^4.1.10",
"@vitest/eslint-plugin": "^1.6.23",
"conventional-changelog-conventionalcommits": "^9.3.1",
"dotenv": "^17.4.2",
"json-typescript": "^1.1.2",
"jsonapi-typescript": "^0.1.3",
"semantic-release": "^25.0.5",
"semantic-release": "^25.0.7",
"tsup": "^8.5.1",
"tsx": "^4.22.4",
"tsx": "^4.23.1",
"typescript": "^5.9.3",
"vitest": "^4.1.9"
"vitest": "^4.1.10"
},
"repository": "github:commercelayer/commercelayer-sdk",
"bugs": "https://github.com/commercelayer/commercelayer-sdk/issues",
Expand Down
478 changes: 239 additions & 239 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

86 changes: 86 additions & 0 deletions specs/bundle-isolation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, test } from 'vitest'
import { CommerceLayer } from '../src/bundle'
import { handleError, interceptRequest } from '../test/common'

// Regression test for the singleton bug: two bundle clients created with
// different tokens must stay isolated. Previously every client funneled
// through the process-global static adapter (and shared module-singleton
// resource instances), so the last-created client's token/org won for all.

const config1 = { organization: 'org-one', accessToken: 'token-one' } as const
const config2 = { organization: 'org-two', accessToken: 'token-two' } as const

describe('Bundle client isolation', () => {
test('each client reports its own organization', () => {
const client1 = CommerceLayer(config1)
const client2 = CommerceLayer(config2)

// With the singleton bug both would report 'org-two' (last created wins).
expect(client1.currentOrganization).toBe('org-one')
expect(client2.currentOrganization).toBe('org-two')
})

test('each client reports its own access token', () => {
const client1 = CommerceLayer(config1)
const client2 = CommerceLayer(config2)

expect(client1.currentAccessToken).toBe('token-one')
expect(client2.currentAccessToken).toBe('token-two')
})

test('each client routes requests through its own token and org', async () => {
const client1 = CommerceLayer(config1)
const client2 = CommerceLayer(config2)

client1.addRequestInterceptor((request) => {
const headers = request.options.headers as Record<string, string>
expect(headers.Authorization).toBe('Bearer token-one')
expect(request.url.hostname).toContain('org-one')
return interceptRequest()
})
client2.addRequestInterceptor((request) => {
const headers = request.options.headers as Record<string, string>
expect(headers.Authorization).toBe('Bearer token-two')
expect(request.url.hostname).toContain('org-two')
return interceptRequest()
})

await client1.organization
.retrieve()
.catch(handleError)
.finally(() => client1.removeInterceptor('request'))
await client2.organization
.retrieve()
.catch(handleError)
.finally(() => client2.removeInterceptor('request'))
})

test('interceptor on one client does not leak into the other', async () => {
const client1 = CommerceLayer(config1)
const client2 = CommerceLayer(config2)

let client1InterceptorCalls = 0
let client2InterceptorCalls = 0
client1.addRequestInterceptor((_request) => {
client1InterceptorCalls += 1
return interceptRequest()
})
// client2 cancels via its own interceptor (avoids a real network call).
client2.addRequestInterceptor((_request) => {
client2InterceptorCalls += 1
return interceptRequest()
})

// A request from client2 must only trigger client2's interceptor.
await client2.organization
.retrieve()
.catch(handleError)
.finally(() => {
client1.removeInterceptor('request')
client2.removeInterceptor('request')
})

expect(client1InterceptorCalls).toBe(0)
expect(client2InterceptorCalls).toBe(1)
})
})
Loading