@open-mercato/shared 0.8.1-develop.7296.1.2111d779db → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -0
- package/dist/lib/auth/jwt.js +4 -0
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/organizationScope.js +15 -1
- package/dist/lib/auth/organizationScope.js.map +2 -2
- package/dist/lib/availability/catalogOnlyProvider.js +77 -0
- package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
- package/dist/lib/availability/index.js +8 -0
- package/dist/lib/availability/index.js.map +7 -0
- package/dist/lib/availability/registry.js +47 -0
- package/dist/lib/availability/registry.js.map +7 -0
- package/dist/lib/availability/types.js +7 -0
- package/dist/lib/availability/types.js.map +7 -0
- package/dist/lib/catalog-visibility/index.js +11 -0
- package/dist/lib/catalog-visibility/index.js.map +7 -0
- package/dist/lib/catalog-visibility/intersectScopes.js +49 -0
- package/dist/lib/catalog-visibility/intersectScopes.js.map +7 -0
- package/dist/lib/catalog-visibility/matchesOne.js +27 -0
- package/dist/lib/catalog-visibility/matchesOne.js.map +7 -0
- package/dist/lib/catalog-visibility/matchesScope.js +9 -0
- package/dist/lib/catalog-visibility/matchesScope.js.map +7 -0
- package/dist/lib/catalog-visibility/types.js +1 -0
- package/dist/lib/catalog-visibility/types.js.map +7 -0
- package/dist/lib/catalog-visibility/unionScopes.js +9 -0
- package/dist/lib/catalog-visibility/unionScopes.js.map +7 -0
- package/dist/lib/commands/command-bus.js +383 -125
- package/dist/lib/commands/command-bus.js.map +3 -3
- package/dist/lib/commands/flush.js +16 -0
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/commands/index.js +8 -0
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/commands/redo.js +2 -7
- package/dist/lib/commands/redo.js.map +2 -2
- package/dist/lib/commands/transaction-lifetime.js +67 -0
- package/dist/lib/commands/transaction-lifetime.js.map +7 -0
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/commands/undo.js +54 -2
- package/dist/lib/commands/undo.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +64 -9
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +42 -18
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +12 -3
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/accentInsensitiveSearch.js +21 -0
- package/dist/lib/db/accentInsensitiveSearch.js.map +7 -0
- package/dist/lib/encryption/kms.js +1 -5
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/likeFilterWarning.js +3 -1
- package/dist/lib/encryption/likeFilterWarning.js.map +2 -2
- package/dist/lib/encryption/migration-backfill.js +69 -0
- package/dist/lib/encryption/migration-backfill.js.map +7 -0
- package/dist/lib/encryption/policyMemo.js +28 -0
- package/dist/lib/encryption/policyMemo.js.map +7 -0
- package/dist/lib/encryption/subscriber.js +30 -3
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +113 -200
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +3 -3
- package/dist/lib/modules/api-route-identity.js +102 -0
- package/dist/lib/modules/api-route-identity.js.map +7 -0
- package/dist/lib/telemetry/runtime.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +20 -0
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +3 -1
- package/dist/modules/registry.js.map +2 -2
- package/package.json +12 -5
- package/src/lib/auth/__tests__/jwt.test.ts +12 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +27 -0
- package/src/lib/auth/jwt.ts +9 -0
- package/src/lib/auth/organizationScope.ts +39 -0
- package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +166 -0
- package/src/lib/availability/__tests__/registry.test.ts +130 -0
- package/src/lib/availability/catalogOnlyProvider.ts +152 -0
- package/src/lib/availability/index.ts +18 -0
- package/src/lib/availability/registry.ts +131 -0
- package/src/lib/availability/types.ts +86 -0
- package/src/lib/catalog-visibility/__tests__/intersectScopes.test.ts +168 -0
- package/src/lib/catalog-visibility/__tests__/matchesOne.test.ts +116 -0
- package/src/lib/catalog-visibility/__tests__/matchesScope.test.ts +35 -0
- package/src/lib/catalog-visibility/__tests__/testFixtures.ts +66 -0
- package/src/lib/catalog-visibility/__tests__/unionScopes.test.ts +62 -0
- package/src/lib/catalog-visibility/index.ts +5 -0
- package/src/lib/catalog-visibility/intersectScopes.ts +93 -0
- package/src/lib/catalog-visibility/matchesOne.ts +28 -0
- package/src/lib/catalog-visibility/matchesScope.ts +8 -0
- package/src/lib/catalog-visibility/types.ts +31 -0
- package/src/lib/catalog-visibility/unionScopes.ts +16 -0
- package/src/lib/commands/__tests__/command-bus.atomic-replay.test.ts +650 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +175 -0
- package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +1 -1
- package/src/lib/commands/__tests__/redo.test.ts +4 -0
- package/src/lib/commands/__tests__/transaction-lifetime.test.ts +118 -0
- package/src/lib/commands/__tests__/undo.test.ts +90 -1
- package/src/lib/commands/command-bus.ts +463 -148
- package/src/lib/commands/flush.ts +16 -0
- package/src/lib/commands/index.ts +7 -0
- package/src/lib/commands/redo.ts +4 -8
- package/src/lib/commands/transaction-lifetime.ts +88 -0
- package/src/lib/commands/types.ts +91 -0
- package/src/lib/commands/undo.ts +79 -1
- package/src/lib/crud/__tests__/crud-factory.cache-tenant-level.test.ts +149 -0
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +48 -6
- package/src/lib/crud/__tests__/crud-factory.test.ts +224 -27
- package/src/lib/crud/__tests__/enricher-runner.test.ts +270 -5
- package/src/lib/crud/enricher-runner.ts +83 -9
- package/src/lib/crud/factory.ts +46 -19
- package/src/lib/data/__tests__/engine.pin-organization-id.test.ts +102 -0
- package/src/lib/data/engine.ts +12 -0
- package/src/lib/db/__tests__/accentInsensitiveSearch.test.ts +51 -0
- package/src/lib/db/accentInsensitiveSearch.ts +70 -0
- package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +11 -9
- package/src/lib/encryption/__tests__/fixtures/policy-process-worker.ts +96 -0
- package/src/lib/encryption/__tests__/kms.test.ts +45 -3
- package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +43 -0
- package/src/lib/encryption/__tests__/migration-backfill.test.ts +57 -0
- package/src/lib/encryption/__tests__/process-policy-coherence.test.ts +128 -0
- package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +66 -1
- package/src/lib/encryption/__tests__/subscriber.test.ts +3 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +412 -51
- package/src/lib/encryption/kms.ts +1 -6
- package/src/lib/encryption/likeFilterWarning.ts +10 -2
- package/src/lib/encryption/migration-backfill.ts +90 -0
- package/src/lib/encryption/policyMemo.ts +51 -0
- package/src/lib/encryption/subscriber.ts +41 -4
- package/src/lib/encryption/tenantDataEncryptionService.ts +153 -219
- package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +20 -10
- package/src/lib/modules/api-route-identity.ts +116 -0
- package/src/lib/telemetry/runtime.ts +8 -0
- package/src/modules/__tests__/registry.test.ts +92 -0
- package/src/modules/events/__tests__/broadcast-coalescing-declaration.test.ts +53 -0
- package/src/modules/events/factory.ts +31 -0
- package/src/modules/events/types.ts +13 -0
- package/src/modules/notifications/types.ts +1 -0
- package/src/modules/registry.ts +4 -1
package/src/lib/auth/jwt.ts
CHANGED
|
@@ -118,6 +118,15 @@ function inspectSecret(secret: string | undefined | null): JwtSecretViolation |
|
|
|
118
118
|
return null
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* True when `secret` would be refused by the production signing-secret policy: missing, a
|
|
123
|
+
* published placeholder, or shorter than the minimum length. Lets tooling that launches the app
|
|
124
|
+
* (e.g. the ephemeral integration runner) swap in a safe value instead of booting into a crash.
|
|
125
|
+
*/
|
|
126
|
+
export function isUnsafeJwtSecret(secret?: string | null): boolean {
|
|
127
|
+
return inspectSecret(secret) !== null
|
|
128
|
+
}
|
|
129
|
+
|
|
121
130
|
function describeViolation(name: string, violation: JwtSecretViolation): string {
|
|
122
131
|
switch (violation) {
|
|
123
132
|
case 'missing':
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { forbidden } from '../crud/errors'
|
|
2
|
+
|
|
1
3
|
type OrganizationScopedAuth = {
|
|
2
4
|
orgId?: string | null
|
|
3
5
|
actorOrgId?: unknown
|
|
@@ -5,6 +7,16 @@ type OrganizationScopedAuth = {
|
|
|
5
7
|
actorTenantId?: unknown
|
|
6
8
|
} | null | undefined
|
|
7
9
|
|
|
10
|
+
type FiniteOrganizationScope = {
|
|
11
|
+
selectedId?: string | null
|
|
12
|
+
filterIds?: readonly string[] | null
|
|
13
|
+
allowedIds?: readonly string[] | null
|
|
14
|
+
} | null | undefined
|
|
15
|
+
|
|
16
|
+
type OrganizationHomeAuth = {
|
|
17
|
+
orgId?: string | null
|
|
18
|
+
} | null | undefined
|
|
19
|
+
|
|
8
20
|
function normalizeId(value: unknown): string | null {
|
|
9
21
|
if (typeof value !== 'string') return null
|
|
10
22
|
const trimmed = value.trim()
|
|
@@ -47,6 +59,33 @@ export function resolveActiveOrganizationId(auth: OrganizationScopedAuth): strin
|
|
|
47
59
|
return actorOrgId
|
|
48
60
|
}
|
|
49
61
|
|
|
62
|
+
/**
|
|
63
|
+
* True when the caller's organization scope was resolved to an explicitly empty set
|
|
64
|
+
* (`filterIds: []` or `allowedIds: []`), i.e. the principal can see no organization at
|
|
65
|
+
* all. An empty set is deny-all and MUST NOT be widened back to the home organization.
|
|
66
|
+
*
|
|
67
|
+
* Routes that would otherwise reveal a record's existence use this to answer with their
|
|
68
|
+
* own not-found response instead of letting `resolveSingleOrganizationIdOrDeny` throw —
|
|
69
|
+
* a thrown deny leaves the route's documented status (and the existence-oracle
|
|
70
|
+
* collapse of issue #5504) up to whoever catches it.
|
|
71
|
+
*/
|
|
72
|
+
export function isExplicitlyEmptyOrganizationScope(scope: FiniteOrganizationScope): boolean {
|
|
73
|
+
if (!scope) return false
|
|
74
|
+
return (Array.isArray(scope.filterIds) && scope.filterIds.length === 0)
|
|
75
|
+
|| (Array.isArray(scope.allowedIds) && scope.allowedIds.length === 0)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function resolveSingleOrganizationIdOrDeny(
|
|
79
|
+
scope: FiniteOrganizationScope,
|
|
80
|
+
auth: OrganizationHomeAuth,
|
|
81
|
+
): string | null {
|
|
82
|
+
if (isExplicitlyEmptyOrganizationScope(scope)) {
|
|
83
|
+
throw forbidden()
|
|
84
|
+
}
|
|
85
|
+
if (scope?.selectedId) return scope.selectedId
|
|
86
|
+
return auth?.orgId ?? null
|
|
87
|
+
}
|
|
88
|
+
|
|
50
89
|
export const ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE = 'organization_scope_required'
|
|
51
90
|
|
|
52
91
|
/**
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import '../catalogOnlyProvider'
|
|
2
|
+
import { setCatalogOnlyPolicyLookup } from '../catalogOnlyProvider'
|
|
3
|
+
import { availabilityProviderRegistry, resolveAvailability, AVAILABILITY_CATALOG_ONLY_PROVIDER_ID } from '../registry'
|
|
4
|
+
import type { AvailabilityQuery } from '../types'
|
|
5
|
+
|
|
6
|
+
function makeQuery(overrides: Partial<AvailabilityQuery> = {}): AvailabilityQuery {
|
|
7
|
+
return {
|
|
8
|
+
tenantId: 'tenant-1',
|
|
9
|
+
organizationId: 'org-1',
|
|
10
|
+
items: [{ catalogProductId: 'product-1', catalogVariantId: 'variant-1', quantity: 1 }],
|
|
11
|
+
...overrides,
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('catalog-only fallback provider', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
setCatalogOnlyPolicyLookup(null)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterAll(() => {
|
|
21
|
+
setCatalogOnlyPolicyLookup(null)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('is always registered under the catalog-only id', () => {
|
|
25
|
+
expect(availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)).not.toBeNull()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('returns not_tracked with canFulfil true and isAuthoritative true for every item with no policy hook (R5)', async () => {
|
|
29
|
+
const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
|
|
30
|
+
const result = await provider.getAvailability(makeQuery())
|
|
31
|
+
const item = result.byItem['product-1:variant-1']
|
|
32
|
+
expect(item.state).toBe('not_tracked')
|
|
33
|
+
expect(item.canFulfil).toBe(true)
|
|
34
|
+
expect(item.isAuthoritative).toBe(true)
|
|
35
|
+
expect(item.availableQuantity).toBeNull()
|
|
36
|
+
// not_tracked must never collapse into in_stock for a naive consumer.
|
|
37
|
+
expect(item.state).not.toBe('in_stock')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('reflects a future preorderReleaseAt from the policy hook as preorder', async () => {
|
|
41
|
+
const future = new Date(Date.now() + 86_400_000).toISOString()
|
|
42
|
+
setCatalogOnlyPolicyLookup(async () => ({
|
|
43
|
+
'product-1:variant-1': { preorderReleaseAt: future, policySourceId: 'policy-1' },
|
|
44
|
+
}))
|
|
45
|
+
const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
|
|
46
|
+
const result = await provider.getAvailability(makeQuery())
|
|
47
|
+
const item = result.byItem['product-1:variant-1']
|
|
48
|
+
expect(item.state).toBe('preorder')
|
|
49
|
+
expect(item.releaseAt).toBe(future)
|
|
50
|
+
expect(item.policySourceId).toBe('policy-1')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('treats a past preorderReleaseAt as no longer preorder', async () => {
|
|
54
|
+
const past = new Date(Date.now() - 86_400_000).toISOString()
|
|
55
|
+
setCatalogOnlyPolicyLookup(async () => ({
|
|
56
|
+
'product-1:variant-1': { preorderReleaseAt: past, policySourceId: 'policy-1' },
|
|
57
|
+
}))
|
|
58
|
+
const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
|
|
59
|
+
const result = await provider.getAvailability(makeQuery())
|
|
60
|
+
expect(result.byItem['product-1:variant-1'].state).not.toBe('preorder')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('treats an inactive policy row as out_of_stock', async () => {
|
|
64
|
+
setCatalogOnlyPolicyLookup(async () => ({
|
|
65
|
+
'product-1:variant-1': { isActive: false, policySourceId: 'policy-2' },
|
|
66
|
+
}))
|
|
67
|
+
const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
|
|
68
|
+
const result = await provider.getAvailability(makeQuery())
|
|
69
|
+
const item = result.byItem['product-1:variant-1']
|
|
70
|
+
expect(item.state).toBe('out_of_stock')
|
|
71
|
+
expect(item.canFulfil).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('keeps an inactive policy row out_of_stock even with a future preorder date (#6806)', async () => {
|
|
75
|
+
const future = new Date(Date.now() + 7 * 86_400_000).toISOString()
|
|
76
|
+
setCatalogOnlyPolicyLookup(async () => ({
|
|
77
|
+
'product-1:variant-1': { isActive: false, preorderReleaseAt: future, policySourceId: 'policy-4' },
|
|
78
|
+
}))
|
|
79
|
+
const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
|
|
80
|
+
const result = await provider.getAvailability(makeQuery())
|
|
81
|
+
const item = result.byItem['product-1:variant-1']
|
|
82
|
+
expect(item.state).toBe('out_of_stock')
|
|
83
|
+
expect(item.canFulfil).toBe(false)
|
|
84
|
+
expect(item.releaseAt).toBeNull()
|
|
85
|
+
expect(item.policySourceId).toBe('policy-4')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('treats is_stock_managed true (no real data source) as out_of_stock rather than in_stock', async () => {
|
|
89
|
+
setCatalogOnlyPolicyLookup(async () => ({
|
|
90
|
+
'product-1:variant-1': { isStockManaged: true, policySourceId: 'policy-3' },
|
|
91
|
+
}))
|
|
92
|
+
const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
|
|
93
|
+
const result = await provider.getAvailability(makeQuery())
|
|
94
|
+
const item = result.byItem['product-1:variant-1']
|
|
95
|
+
expect(item.state).toBe('out_of_stock')
|
|
96
|
+
expect(item.canFulfil).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('blocks a requested quantity above maxOrderQuantity without changing the state', async () => {
|
|
100
|
+
setCatalogOnlyPolicyLookup(async () => ({
|
|
101
|
+
'product-1:variant-1': { maxOrderQuantity: 5, policySourceId: 'policy-4' },
|
|
102
|
+
}))
|
|
103
|
+
const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
|
|
104
|
+
const result = await provider.getAvailability(makeQuery({
|
|
105
|
+
items: [{ catalogProductId: 'product-1', catalogVariantId: 'variant-1', quantity: 6 }],
|
|
106
|
+
}))
|
|
107
|
+
const item = result.byItem['product-1:variant-1']
|
|
108
|
+
expect(item.state).toBe('not_tracked')
|
|
109
|
+
expect(item.canFulfil).toBe(false)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('blocks a requested quantity below minOrderQuantity or off the quantityIncrement', async () => {
|
|
113
|
+
setCatalogOnlyPolicyLookup(async () => ({
|
|
114
|
+
'product-1:variant-1': { minOrderQuantity: 4, quantityIncrement: 4 },
|
|
115
|
+
'product-2:': { quantityIncrement: 4 },
|
|
116
|
+
'product-3:': { minOrderQuantity: 4, maxOrderQuantity: 12, quantityIncrement: 4 },
|
|
117
|
+
}))
|
|
118
|
+
const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
|
|
119
|
+
const result = await provider.getAvailability(makeQuery({
|
|
120
|
+
items: [
|
|
121
|
+
{ catalogProductId: 'product-1', catalogVariantId: 'variant-1', quantity: 2 },
|
|
122
|
+
{ catalogProductId: 'product-2', catalogVariantId: null, quantity: 6 },
|
|
123
|
+
{ catalogProductId: 'product-3', catalogVariantId: null, quantity: 8 },
|
|
124
|
+
],
|
|
125
|
+
}))
|
|
126
|
+
expect(result.byItem['product-1:variant-1'].canFulfil).toBe(false)
|
|
127
|
+
expect(result.byItem['product-2:'].canFulfil).toBe(false)
|
|
128
|
+
expect(result.byItem['product-3:'].canFulfil).toBe(true)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('keeps a future preorder fulfillable only within the order-quantity rules', async () => {
|
|
132
|
+
const future = new Date(Date.now() + 86_400_000).toISOString()
|
|
133
|
+
setCatalogOnlyPolicyLookup(async () => ({
|
|
134
|
+
'product-1:variant-1': { preorderReleaseAt: future, maxOrderQuantity: 1 },
|
|
135
|
+
}))
|
|
136
|
+
const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
|
|
137
|
+
const result = await provider.getAvailability(makeQuery({
|
|
138
|
+
items: [{ catalogProductId: 'product-1', catalogVariantId: 'variant-1', quantity: 2 }],
|
|
139
|
+
}))
|
|
140
|
+
const item = result.byItem['product-1:variant-1']
|
|
141
|
+
expect(item.state).toBe('preorder')
|
|
142
|
+
expect(item.canFulfil).toBe(false)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('degrades to the pure fallback when the policy hook throws', async () => {
|
|
146
|
+
setCatalogOnlyPolicyLookup(async () => {
|
|
147
|
+
throw new Error('boom')
|
|
148
|
+
})
|
|
149
|
+
const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
|
|
150
|
+
const result = await provider.getAvailability(makeQuery())
|
|
151
|
+
expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('hands the per-call context to the policy hook', async () => {
|
|
155
|
+
const lookup = jest.fn(async () => ({}))
|
|
156
|
+
setCatalogOnlyPolicyLookup(lookup)
|
|
157
|
+
const container = { resolve: <T,>(): T => undefined as T }
|
|
158
|
+
await resolveAvailability(makeQuery(), { container })
|
|
159
|
+
expect(lookup).toHaveBeenCalledWith(makeQuery(), { container })
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('is reachable end-to-end via resolveAvailability', async () => {
|
|
163
|
+
const result = await resolveAvailability(makeQuery())
|
|
164
|
+
expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { availabilityProviderRegistry, resolveAvailability, AVAILABILITY_CATALOG_ONLY_PROVIDER_ID } from '../registry'
|
|
2
|
+
import type { AvailabilityModuleConfigReader } from '../registry'
|
|
3
|
+
import type { AvailabilityProvider, AvailabilityQuery, AvailabilityResult } from '../types'
|
|
4
|
+
|
|
5
|
+
function makeProvider(id: string, result: Partial<AvailabilityResult['byItem'][string]> = {}): AvailabilityProvider {
|
|
6
|
+
return {
|
|
7
|
+
id,
|
|
8
|
+
async getAvailability(query: AvailabilityQuery): Promise<AvailabilityResult> {
|
|
9
|
+
const byItem: AvailabilityResult['byItem'] = {}
|
|
10
|
+
for (const item of query.items) {
|
|
11
|
+
const key = `${item.catalogProductId}:${item.catalogVariantId ?? ''}`
|
|
12
|
+
byItem[key] = {
|
|
13
|
+
state: 'in_stock',
|
|
14
|
+
availableQuantity: 100,
|
|
15
|
+
canFulfil: true,
|
|
16
|
+
leadTimeDays: null,
|
|
17
|
+
releaseAt: null,
|
|
18
|
+
isAuthoritative: true,
|
|
19
|
+
policySourceId: null,
|
|
20
|
+
...result,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { byItem }
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeQuery(overrides: Partial<AvailabilityQuery> = {}): AvailabilityQuery {
|
|
29
|
+
return {
|
|
30
|
+
tenantId: 'tenant-1',
|
|
31
|
+
organizationId: 'org-1',
|
|
32
|
+
items: [{ catalogProductId: 'product-1', catalogVariantId: 'variant-1', quantity: 1 }],
|
|
33
|
+
...overrides,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('availabilityProviderRegistry', () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
availabilityProviderRegistry.reset()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('registers and retrieves providers by id', () => {
|
|
43
|
+
const provider = makeProvider('alpha')
|
|
44
|
+
availabilityProviderRegistry.register(provider)
|
|
45
|
+
expect(availabilityProviderRegistry.get('alpha')).toBe(provider)
|
|
46
|
+
expect(availabilityProviderRegistry.get('missing')).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('is idempotent — replaces by id rather than duplicating', () => {
|
|
50
|
+
const first = makeProvider('alpha')
|
|
51
|
+
const second = makeProvider('alpha')
|
|
52
|
+
availabilityProviderRegistry.register(first)
|
|
53
|
+
availabilityProviderRegistry.register(second)
|
|
54
|
+
expect(availabilityProviderRegistry.list()).toHaveLength(1)
|
|
55
|
+
expect(availabilityProviderRegistry.get('alpha')).toBe(second)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('lists providers in registration order', () => {
|
|
59
|
+
availabilityProviderRegistry.register(makeProvider('first'))
|
|
60
|
+
availabilityProviderRegistry.register(makeProvider('second'))
|
|
61
|
+
expect(availabilityProviderRegistry.list().map((p) => p.id)).toEqual(['first', 'second'])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('rejects a provider with an empty id', () => {
|
|
65
|
+
expect(() => availabilityProviderRegistry.register({ id: '', getAvailability: async () => ({ byItem: {} }) })).toThrow()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('resolveAvailability', () => {
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
availabilityProviderRegistry.reset()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('falls back to catalog-only when nothing else is registered and selection is auto', async () => {
|
|
75
|
+
availabilityProviderRegistry.register(makeProvider(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID, { state: 'not_tracked', availableQuantity: null }))
|
|
76
|
+
const result = await resolveAvailability(makeQuery())
|
|
77
|
+
expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("'auto' picks the highest-precedence non-catalog-only registrant", async () => {
|
|
81
|
+
availabilityProviderRegistry.register(makeProvider(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID, { state: 'not_tracked' }))
|
|
82
|
+
availabilityProviderRegistry.register(makeProvider('wms', { state: 'in_stock' }))
|
|
83
|
+
const result = await resolveAvailability(makeQuery())
|
|
84
|
+
expect(result.byItem['product-1:variant-1'].state).toBe('in_stock')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('honors an explicit selection via the injected module-config reader', async () => {
|
|
88
|
+
availabilityProviderRegistry.register(makeProvider(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID, { state: 'not_tracked' }))
|
|
89
|
+
availabilityProviderRegistry.register(makeProvider('wms', { state: 'in_stock' }))
|
|
90
|
+
const moduleConfig: AvailabilityModuleConfigReader = {
|
|
91
|
+
getValue: async <T,>() => 'catalog-only' as T,
|
|
92
|
+
}
|
|
93
|
+
const result = await resolveAvailability(makeQuery(), { moduleConfig })
|
|
94
|
+
expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('falls back to catalog-only when the selected id is not currently registered', async () => {
|
|
98
|
+
availabilityProviderRegistry.register(makeProvider(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID, { state: 'not_tracked' }))
|
|
99
|
+
const moduleConfig: AvailabilityModuleConfigReader = {
|
|
100
|
+
getValue: async <T,>() => 'some-unregistered-provider' as T,
|
|
101
|
+
}
|
|
102
|
+
const result = await resolveAvailability(makeQuery(), { moduleConfig })
|
|
103
|
+
expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('defaults to auto when the module-config reader is omitted', async () => {
|
|
107
|
+
availabilityProviderRegistry.register(makeProvider('wms', { state: 'in_stock' }))
|
|
108
|
+
const result = await resolveAvailability(makeQuery())
|
|
109
|
+
expect(result.byItem['product-1:variant-1'].state).toBe('in_stock')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('forwards the calling request container to the selected provider', async () => {
|
|
113
|
+
const getAvailability = jest.fn(async () => ({ byItem: {} }))
|
|
114
|
+
availabilityProviderRegistry.register({ id: 'wms', getAvailability })
|
|
115
|
+
const container = { resolve: <T,>(): T => undefined as T }
|
|
116
|
+
await resolveAvailability(makeQuery(), { container })
|
|
117
|
+
expect(getAvailability).toHaveBeenCalledWith(makeQuery(), { container })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('passes a null container when the caller supplies none', async () => {
|
|
121
|
+
const getAvailability = jest.fn(async () => ({ byItem: {} }))
|
|
122
|
+
availabilityProviderRegistry.register({ id: 'wms', getAvailability })
|
|
123
|
+
await resolveAvailability(makeQuery())
|
|
124
|
+
expect(getAvailability).toHaveBeenCalledWith(makeQuery(), { container: null })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('throws when no provider is registered at all', async () => {
|
|
128
|
+
await expect(resolveAvailability(makeQuery())).rejects.toThrow()
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in `catalog-only` fallback provider. Always registered so a
|
|
3
|
+
* `wms`-less *and* `availability`-less storefront stays fully functional.
|
|
4
|
+
*
|
|
5
|
+
* @see .ai/specs/2026-08-14-availability-contract.md §4.3
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { availabilityItemKey } from './types'
|
|
9
|
+
import type { AvailabilityItemResult, AvailabilityProviderContext, AvailabilityQuery, AvailabilityResult } from './types'
|
|
10
|
+
import { availabilityProviderRegistry, AVAILABILITY_CATALOG_ONLY_PROVIDER_ID } from './registry'
|
|
11
|
+
|
|
12
|
+
/** Per-item policy signal the optional lookup hook may report. */
|
|
13
|
+
export type CatalogOnlyPolicyOverride = {
|
|
14
|
+
/** `true` → the item is explicitly opted into stock tracking with no data source behind it (§4.3, out_of_stock). */
|
|
15
|
+
isStockManaged?: boolean
|
|
16
|
+
/** `false` → the policy row is inactive; treated as out_of_stock. */
|
|
17
|
+
isActive?: boolean
|
|
18
|
+
/** ISO-8601. In the future → `preorder`. */
|
|
19
|
+
preorderReleaseAt?: string | null
|
|
20
|
+
/** Requested quantities below this cannot be fulfilled. */
|
|
21
|
+
minOrderQuantity?: number | null
|
|
22
|
+
/** Requested quantities above this cannot be fulfilled — a cap independent of stock. */
|
|
23
|
+
maxOrderQuantity?: number | null
|
|
24
|
+
/** Pack size; requested quantities must be a multiple. */
|
|
25
|
+
quantityIncrement?: number | null
|
|
26
|
+
/** The `AvailabilityPolicy` row id that produced this override. */
|
|
27
|
+
policySourceId?: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Optional soft lookup into `AvailabilityPolicy` when the `availability`
|
|
32
|
+
* module is installed. Keyed by `availabilityItemKey()`. Returning `null` or
|
|
33
|
+
* omitting an item from the map means "no policy row — module default".
|
|
34
|
+
*/
|
|
35
|
+
export type CatalogOnlyPolicyLookup = (
|
|
36
|
+
query: AvailabilityQuery,
|
|
37
|
+
context?: AvailabilityProviderContext,
|
|
38
|
+
) => Promise<Record<string, CatalogOnlyPolicyOverride | null | undefined>>
|
|
39
|
+
|
|
40
|
+
let policyLookup: CatalogOnlyPolicyLookup | null = null
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Wired by the `availability` module's `di.ts` — never a static import from
|
|
44
|
+
* `packages/shared`. The lookup MUST resolve its dependencies from the
|
|
45
|
+
* per-call `context.container`, never from a container captured at
|
|
46
|
+
* registration time. Pass `null` to clear (test isolation).
|
|
47
|
+
*/
|
|
48
|
+
export function setCatalogOnlyPolicyLookup(lookup: CatalogOnlyPolicyLookup | null): void {
|
|
49
|
+
policyLookup = lookup
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function pureFallbackItem(): AvailabilityItemResult {
|
|
53
|
+
return {
|
|
54
|
+
state: 'not_tracked',
|
|
55
|
+
availableQuantity: null,
|
|
56
|
+
canFulfil: true,
|
|
57
|
+
leadTimeDays: null,
|
|
58
|
+
releaseAt: null,
|
|
59
|
+
isAuthoritative: true,
|
|
60
|
+
policySourceId: null,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isWithinOrderQuantityRules(requested: number, override: CatalogOnlyPolicyOverride): boolean {
|
|
65
|
+
const { minOrderQuantity, maxOrderQuantity, quantityIncrement } = override
|
|
66
|
+
if (minOrderQuantity != null && requested < minOrderQuantity) return false
|
|
67
|
+
if (maxOrderQuantity != null && requested > maxOrderQuantity) return false
|
|
68
|
+
if (quantityIncrement != null && quantityIncrement > 0 && requested % quantityIncrement !== 0) return false
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Order-quantity rules are a cap independent of stock: a violation blocks
|
|
74
|
+
* fulfilment without changing the state the policy matrix produced.
|
|
75
|
+
*/
|
|
76
|
+
function applyOverride(
|
|
77
|
+
override: CatalogOnlyPolicyOverride | null | undefined,
|
|
78
|
+
requestedQuantity: number,
|
|
79
|
+
): AvailabilityItemResult {
|
|
80
|
+
const result = applyPolicyMatrix(override)
|
|
81
|
+
if (!override || isWithinOrderQuantityRules(requestedQuantity, override)) return result
|
|
82
|
+
return { ...result, canFulfil: false }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Applies decision 7's matrix (see PLAN.md § Key design decisions) for a
|
|
87
|
+
* resolved policy override on top of the pure fallback.
|
|
88
|
+
*/
|
|
89
|
+
function applyPolicyMatrix(override: CatalogOnlyPolicyOverride | null | undefined): AvailabilityItemResult {
|
|
90
|
+
const base = pureFallbackItem()
|
|
91
|
+
if (!override) return base
|
|
92
|
+
|
|
93
|
+
const policySourceId = override.policySourceId ?? null
|
|
94
|
+
|
|
95
|
+
// An inactive row makes the item unpurchasable before any preorder date, exactly
|
|
96
|
+
// like the stock-tracked providers.
|
|
97
|
+
if (override.isActive === false) {
|
|
98
|
+
return { ...base, state: 'out_of_stock', canFulfil: false, policySourceId }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (override.preorderReleaseAt) {
|
|
102
|
+
const releaseAt = new Date(override.preorderReleaseAt)
|
|
103
|
+
if (!Number.isNaN(releaseAt.getTime()) && releaseAt.getTime() > Date.now()) {
|
|
104
|
+
return {
|
|
105
|
+
...base,
|
|
106
|
+
state: 'preorder',
|
|
107
|
+
canFulfil: true,
|
|
108
|
+
releaseAt: override.preorderReleaseAt,
|
|
109
|
+
policySourceId,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (override.isStockManaged === true) {
|
|
115
|
+
// Opted into stock tracking with no data source to verify against — see
|
|
116
|
+
// decision 7: this is the "policy explicitly marks the item unavailable"
|
|
117
|
+
// case rather than a silent "in stock" claim (R5).
|
|
118
|
+
return { ...base, state: 'out_of_stock', canFulfil: false, policySourceId }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { ...base, policySourceId }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function getAvailability(
|
|
125
|
+
query: AvailabilityQuery,
|
|
126
|
+
context?: AvailabilityProviderContext,
|
|
127
|
+
): Promise<AvailabilityResult> {
|
|
128
|
+
const byItem: AvailabilityResult['byItem'] = {}
|
|
129
|
+
|
|
130
|
+
let overrides: Record<string, CatalogOnlyPolicyOverride | null | undefined> = {}
|
|
131
|
+
if (policyLookup) {
|
|
132
|
+
try {
|
|
133
|
+
overrides = await policyLookup(query, context)
|
|
134
|
+
} catch {
|
|
135
|
+
// Degrade gracefully to the pure fallback — never let an optional
|
|
136
|
+
// policy lookup failure break the always-available fallback provider.
|
|
137
|
+
overrides = {}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const item of query.items) {
|
|
142
|
+
const key = availabilityItemKey(item)
|
|
143
|
+
byItem[key] = applyOverride(overrides[key], item.quantity)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { byItem }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
availabilityProviderRegistry.register({
|
|
150
|
+
id: AVAILABILITY_CATALOG_ONLY_PROVIDER_ID,
|
|
151
|
+
getAvailability,
|
|
152
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public entrypoint for the Availability Contract's shared, dependency-free
|
|
3
|
+
* base — types, the provider registry, `resolveAvailability()`, and the
|
|
4
|
+
* built-in `catalog-only` fallback.
|
|
5
|
+
*
|
|
6
|
+
* Importing from this barrel (rather than `./registry` directly) guarantees
|
|
7
|
+
* the `catalog-only` provider is registered — it self-registers as an import
|
|
8
|
+
* side effect in `./catalogOnlyProvider`, which this file always pulls in.
|
|
9
|
+
*
|
|
10
|
+
* @see .ai/specs/2026-08-14-availability-contract.md §4.1a
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export * from './types'
|
|
14
|
+
export * from './registry'
|
|
15
|
+
export { setCatalogOnlyPolicyLookup } from './catalogOnlyProvider'
|
|
16
|
+
export type { CatalogOnlyPolicyLookup, CatalogOnlyPolicyOverride } from './catalogOnlyProvider'
|
|
17
|
+
|
|
18
|
+
import './catalogOnlyProvider'
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Availability provider registry — module-level singleton that collects
|
|
3
|
+
* registered providers and dispatches `resolveAvailability()` to the
|
|
4
|
+
* per-tenant selected one, falling back safely to the built-in
|
|
5
|
+
* `catalog-only` provider.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors `packages/shared/src/lib/ai/llm-provider-registry.ts`.
|
|
8
|
+
*
|
|
9
|
+
* @see ./types
|
|
10
|
+
* @see .ai/specs/2026-08-14-availability-contract.md §4.1a
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
AvailabilityDependencyResolver,
|
|
15
|
+
AvailabilityProvider,
|
|
16
|
+
AvailabilityQuery,
|
|
17
|
+
AvailabilityResult,
|
|
18
|
+
} from './types'
|
|
19
|
+
|
|
20
|
+
export const AVAILABILITY_CATALOG_ONLY_PROVIDER_ID = 'catalog-only'
|
|
21
|
+
|
|
22
|
+
/** Public interface of the registry. Exposed as a singleton via {@link availabilityProviderRegistry}. */
|
|
23
|
+
export interface AvailabilityProviderRegistry {
|
|
24
|
+
/**
|
|
25
|
+
* Registers or replaces a provider. Registration is idempotent — calling
|
|
26
|
+
* with the same id replaces the existing entry. Never order-dependent.
|
|
27
|
+
*/
|
|
28
|
+
register(provider: AvailabilityProvider): void
|
|
29
|
+
|
|
30
|
+
/** Returns the provider with the given id, or null when not registered. */
|
|
31
|
+
get(id: string): AvailabilityProvider | null
|
|
32
|
+
|
|
33
|
+
/** Returns all registered providers in registration order. */
|
|
34
|
+
list(): readonly AvailabilityProvider[]
|
|
35
|
+
|
|
36
|
+
/** Removes all registered providers. Intended for test isolation. */
|
|
37
|
+
reset(): void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class AvailabilityProviderRegistryImpl implements AvailabilityProviderRegistry {
|
|
41
|
+
// Preserves registration order via Map iteration semantics.
|
|
42
|
+
private readonly providers = new Map<string, AvailabilityProvider>()
|
|
43
|
+
|
|
44
|
+
register(provider: AvailabilityProvider): void {
|
|
45
|
+
if (!provider || typeof provider.id !== 'string' || provider.id.length === 0) {
|
|
46
|
+
throw new Error('[internal] AvailabilityProviderRegistry: provider must have a non-empty id')
|
|
47
|
+
}
|
|
48
|
+
// Idempotent: replace existing by id.
|
|
49
|
+
this.providers.set(provider.id, provider)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get(id: string): AvailabilityProvider | null {
|
|
53
|
+
return this.providers.get(id) ?? null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
list(): readonly AvailabilityProvider[] {
|
|
57
|
+
return Array.from(this.providers.values())
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
reset(): void {
|
|
61
|
+
this.providers.clear()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Process-level singleton instance of the registry. */
|
|
66
|
+
export const availabilityProviderRegistry: AvailabilityProviderRegistry = new AvailabilityProviderRegistryImpl()
|
|
67
|
+
|
|
68
|
+
export type AvailabilityProviderSelection = 'auto' | 'catalog-only' | (string & {})
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Narrow port for per-tenant provider selection — declared locally so this
|
|
72
|
+
* package stays dependency-free. A caller with DI access (an `availability`
|
|
73
|
+
* module route, a future `ecommerce`/`cart`/`checkout` consumer) passes in
|
|
74
|
+
* its resolved `ModuleConfigService` instance; omitting it always resolves
|
|
75
|
+
* `'auto'`.
|
|
76
|
+
*/
|
|
77
|
+
export interface AvailabilityModuleConfigReader {
|
|
78
|
+
getValue<T = unknown>(
|
|
79
|
+
moduleId: string,
|
|
80
|
+
name: string,
|
|
81
|
+
options?: { defaultValue?: T | null; scope?: { tenantId?: string | null; organizationId?: string | null } },
|
|
82
|
+
): Promise<T | null>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ResolveAvailabilityOptions {
|
|
86
|
+
/** `ModuleConfigService('availability', 'selectedProvider')` reader. See {@link AvailabilityModuleConfigReader}. */
|
|
87
|
+
moduleConfig?: AvailabilityModuleConfigReader
|
|
88
|
+
/**
|
|
89
|
+
* The calling request's DI container, forwarded to the selected provider
|
|
90
|
+
* as {@link AvailabilityProviderContext.container}. Omitting it makes a
|
|
91
|
+
* DI-backed provider build its own request container for the call.
|
|
92
|
+
*/
|
|
93
|
+
container?: AvailabilityDependencyResolver | null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveAutoProvider(): AvailabilityProvider | null {
|
|
97
|
+
// 'auto' = "the highest-precedence registered provider" — the first
|
|
98
|
+
// non-catalog-only registrant, in registration order.
|
|
99
|
+
for (const provider of availabilityProviderRegistry.list()) {
|
|
100
|
+
if (provider.id !== AVAILABILITY_CATALOG_ONLY_PROVIDER_ID) return provider
|
|
101
|
+
}
|
|
102
|
+
return availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The entry point every read-side consumer calls. Advisory only — see §4.1:
|
|
107
|
+
* `resolveAvailability()` is never a stock guarantee, `reserveAvailability()`
|
|
108
|
+
* (the `availability` module, not shipped by this contract) is.
|
|
109
|
+
*/
|
|
110
|
+
export async function resolveAvailability(
|
|
111
|
+
query: AvailabilityQuery,
|
|
112
|
+
options?: ResolveAvailabilityOptions,
|
|
113
|
+
): Promise<AvailabilityResult> {
|
|
114
|
+
const selection = options?.moduleConfig
|
|
115
|
+
? await options.moduleConfig.getValue<AvailabilityProviderSelection>('availability', 'selectedProvider', {
|
|
116
|
+
defaultValue: 'auto',
|
|
117
|
+
scope: { tenantId: query.tenantId },
|
|
118
|
+
})
|
|
119
|
+
: 'auto'
|
|
120
|
+
|
|
121
|
+
const provider =
|
|
122
|
+
!selection || selection === 'auto'
|
|
123
|
+
? resolveAutoProvider()
|
|
124
|
+
: availabilityProviderRegistry.get(selection) ?? availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)
|
|
125
|
+
|
|
126
|
+
const resolved = provider ?? availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)
|
|
127
|
+
if (!resolved) {
|
|
128
|
+
throw new Error('[internal] No availability provider registered, including the built-in catalog-only fallback')
|
|
129
|
+
}
|
|
130
|
+
return resolved.getAvailability(query, { container: options?.container ?? null })
|
|
131
|
+
}
|