@open-mercato/shared 0.8.1-develop.7228.1.b7ea09d2d3 → 0.8.1-develop.7237.1.635faf2c7a
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/dist/lib/availability/catalogOnlyProvider.js +65 -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/crud/factory.js +21 -7
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/db/pg-errors.js +9 -1
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/encryption/aes.js +14 -0
- package/dist/lib/encryption/aes.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +17 -1
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +6 -2
- package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +98 -0
- package/src/lib/availability/__tests__/registry.test.ts +115 -0
- package/src/lib/availability/catalogOnlyProvider.ts +119 -0
- package/src/lib/availability/index.ts +18 -0
- package/src/lib/availability/registry.ts +120 -0
- package/src/lib/availability/types.ts +71 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +91 -4
- package/src/lib/crud/factory.ts +37 -6
- package/src/lib/db/__tests__/pg-errors.test.ts +31 -1
- package/src/lib/db/pg-errors.ts +23 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +97 -9
- package/src/lib/encryption/aes.ts +43 -0
- package/src/lib/encryption/tenantDataEncryptionService.ts +44 -1
|
@@ -0,0 +1,115 @@
|
|
|
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 () => 'catalog-only',
|
|
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 () => 'some-unregistered-provider',
|
|
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('throws when no provider is registered at all', async () => {
|
|
113
|
+
await expect(resolveAvailability(makeQuery())).rejects.toThrow()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
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, 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
|
+
/** The `AvailabilityPolicy` row id that produced this override. */
|
|
21
|
+
policySourceId?: string | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Optional soft lookup into `AvailabilityPolicy` when the `availability`
|
|
26
|
+
* module is installed. Keyed by `availabilityItemKey()`. Returning `null` or
|
|
27
|
+
* omitting an item from the map means "no policy row — module default".
|
|
28
|
+
*/
|
|
29
|
+
export type CatalogOnlyPolicyLookup = (
|
|
30
|
+
query: AvailabilityQuery,
|
|
31
|
+
) => Promise<Record<string, CatalogOnlyPolicyOverride | null | undefined>>
|
|
32
|
+
|
|
33
|
+
let policyLookup: CatalogOnlyPolicyLookup | null = null
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Wired by the `availability` module's `di.ts` at container-build time
|
|
37
|
+
* (closure captures the container, mirroring `wms/di.ts`'s own provider
|
|
38
|
+
* registration) — never a static import from `packages/shared`. Pass `null`
|
|
39
|
+
* to clear (test isolation).
|
|
40
|
+
*/
|
|
41
|
+
export function setCatalogOnlyPolicyLookup(lookup: CatalogOnlyPolicyLookup | null): void {
|
|
42
|
+
policyLookup = lookup
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function pureFallbackItem(): AvailabilityItemResult {
|
|
46
|
+
return {
|
|
47
|
+
state: 'not_tracked',
|
|
48
|
+
availableQuantity: null,
|
|
49
|
+
canFulfil: true,
|
|
50
|
+
leadTimeDays: null,
|
|
51
|
+
releaseAt: null,
|
|
52
|
+
isAuthoritative: true,
|
|
53
|
+
policySourceId: null,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Applies decision 7's matrix (see PLAN.md § Key design decisions) for a
|
|
59
|
+
* resolved policy override on top of the pure fallback.
|
|
60
|
+
*/
|
|
61
|
+
function applyOverride(override: CatalogOnlyPolicyOverride | null | undefined): AvailabilityItemResult {
|
|
62
|
+
const base = pureFallbackItem()
|
|
63
|
+
if (!override) return base
|
|
64
|
+
|
|
65
|
+
const policySourceId = override.policySourceId ?? null
|
|
66
|
+
|
|
67
|
+
if (override.preorderReleaseAt) {
|
|
68
|
+
const releaseAt = new Date(override.preorderReleaseAt)
|
|
69
|
+
if (!Number.isNaN(releaseAt.getTime()) && releaseAt.getTime() > Date.now()) {
|
|
70
|
+
return {
|
|
71
|
+
...base,
|
|
72
|
+
state: 'preorder',
|
|
73
|
+
canFulfil: true,
|
|
74
|
+
releaseAt: override.preorderReleaseAt,
|
|
75
|
+
policySourceId,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (override.isActive === false) {
|
|
81
|
+
return { ...base, state: 'out_of_stock', canFulfil: false, policySourceId }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (override.isStockManaged === true) {
|
|
85
|
+
// Opted into stock tracking with no data source to verify against — see
|
|
86
|
+
// decision 7: this is the "policy explicitly marks the item unavailable"
|
|
87
|
+
// case rather than a silent "in stock" claim (R5).
|
|
88
|
+
return { ...base, state: 'out_of_stock', canFulfil: false, policySourceId }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { ...base, policySourceId }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function getAvailability(query: AvailabilityQuery): Promise<AvailabilityResult> {
|
|
95
|
+
const byItem: AvailabilityResult['byItem'] = {}
|
|
96
|
+
|
|
97
|
+
let overrides: Record<string, CatalogOnlyPolicyOverride | null | undefined> = {}
|
|
98
|
+
if (policyLookup) {
|
|
99
|
+
try {
|
|
100
|
+
overrides = await policyLookup(query)
|
|
101
|
+
} catch {
|
|
102
|
+
// Degrade gracefully to the pure fallback — never let an optional
|
|
103
|
+
// policy lookup failure break the always-available fallback provider.
|
|
104
|
+
overrides = {}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const item of query.items) {
|
|
109
|
+
const key = availabilityItemKey(item)
|
|
110
|
+
byItem[key] = applyOverride(overrides[key])
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { byItem }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
availabilityProviderRegistry.register({
|
|
117
|
+
id: AVAILABILITY_CATALOG_ONLY_PROVIDER_ID,
|
|
118
|
+
getAvailability,
|
|
119
|
+
})
|
|
@@ -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,120 @@
|
|
|
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 { AvailabilityProvider, AvailabilityQuery, AvailabilityResult } from './types'
|
|
14
|
+
|
|
15
|
+
export const AVAILABILITY_CATALOG_ONLY_PROVIDER_ID = 'catalog-only'
|
|
16
|
+
|
|
17
|
+
/** Public interface of the registry. Exposed as a singleton via {@link availabilityProviderRegistry}. */
|
|
18
|
+
export interface AvailabilityProviderRegistry {
|
|
19
|
+
/**
|
|
20
|
+
* Registers or replaces a provider. Registration is idempotent — calling
|
|
21
|
+
* with the same id replaces the existing entry. Never order-dependent.
|
|
22
|
+
*/
|
|
23
|
+
register(provider: AvailabilityProvider): void
|
|
24
|
+
|
|
25
|
+
/** Returns the provider with the given id, or null when not registered. */
|
|
26
|
+
get(id: string): AvailabilityProvider | null
|
|
27
|
+
|
|
28
|
+
/** Returns all registered providers in registration order. */
|
|
29
|
+
list(): readonly AvailabilityProvider[]
|
|
30
|
+
|
|
31
|
+
/** Removes all registered providers. Intended for test isolation. */
|
|
32
|
+
reset(): void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class AvailabilityProviderRegistryImpl implements AvailabilityProviderRegistry {
|
|
36
|
+
// Preserves registration order via Map iteration semantics.
|
|
37
|
+
private readonly providers = new Map<string, AvailabilityProvider>()
|
|
38
|
+
|
|
39
|
+
register(provider: AvailabilityProvider): void {
|
|
40
|
+
if (!provider || typeof provider.id !== 'string' || provider.id.length === 0) {
|
|
41
|
+
throw new Error('[internal] AvailabilityProviderRegistry: provider must have a non-empty id')
|
|
42
|
+
}
|
|
43
|
+
// Idempotent: replace existing by id.
|
|
44
|
+
this.providers.set(provider.id, provider)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get(id: string): AvailabilityProvider | null {
|
|
48
|
+
return this.providers.get(id) ?? null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
list(): readonly AvailabilityProvider[] {
|
|
52
|
+
return Array.from(this.providers.values())
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
reset(): void {
|
|
56
|
+
this.providers.clear()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Process-level singleton instance of the registry. */
|
|
61
|
+
export const availabilityProviderRegistry: AvailabilityProviderRegistry = new AvailabilityProviderRegistryImpl()
|
|
62
|
+
|
|
63
|
+
export type AvailabilityProviderSelection = 'auto' | 'catalog-only' | (string & {})
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Narrow port for per-tenant provider selection — declared locally so this
|
|
67
|
+
* package stays dependency-free. A caller with DI access (an `availability`
|
|
68
|
+
* module route, a future `ecommerce`/`cart`/`checkout` consumer) passes in
|
|
69
|
+
* its resolved `ModuleConfigService` instance; omitting it always resolves
|
|
70
|
+
* `'auto'`.
|
|
71
|
+
*/
|
|
72
|
+
export interface AvailabilityModuleConfigReader {
|
|
73
|
+
getValue<T = unknown>(
|
|
74
|
+
moduleId: string,
|
|
75
|
+
name: string,
|
|
76
|
+
options?: { defaultValue?: T | null; scope?: { tenantId?: string | null; organizationId?: string | null } },
|
|
77
|
+
): Promise<T | null>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ResolveAvailabilityOptions {
|
|
81
|
+
/** `ModuleConfigService('availability', 'selectedProvider')` reader. See {@link AvailabilityModuleConfigReader}. */
|
|
82
|
+
moduleConfig?: AvailabilityModuleConfigReader
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveAutoProvider(): AvailabilityProvider | null {
|
|
86
|
+
// 'auto' = "the highest-precedence registered provider" — the first
|
|
87
|
+
// non-catalog-only registrant, in registration order.
|
|
88
|
+
for (const provider of availabilityProviderRegistry.list()) {
|
|
89
|
+
if (provider.id !== AVAILABILITY_CATALOG_ONLY_PROVIDER_ID) return provider
|
|
90
|
+
}
|
|
91
|
+
return availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The entry point every read-side consumer calls. Advisory only — see §4.1:
|
|
96
|
+
* `resolveAvailability()` is never a stock guarantee, `reserveAvailability()`
|
|
97
|
+
* (the `availability` module, not shipped by this contract) is.
|
|
98
|
+
*/
|
|
99
|
+
export async function resolveAvailability(
|
|
100
|
+
query: AvailabilityQuery,
|
|
101
|
+
options?: ResolveAvailabilityOptions,
|
|
102
|
+
): Promise<AvailabilityResult> {
|
|
103
|
+
const selection = options?.moduleConfig
|
|
104
|
+
? await options.moduleConfig.getValue<AvailabilityProviderSelection>('availability', 'selectedProvider', {
|
|
105
|
+
defaultValue: 'auto',
|
|
106
|
+
scope: { tenantId: query.tenantId },
|
|
107
|
+
})
|
|
108
|
+
: 'auto'
|
|
109
|
+
|
|
110
|
+
const provider =
|
|
111
|
+
!selection || selection === 'auto'
|
|
112
|
+
? resolveAutoProvider()
|
|
113
|
+
: availabilityProviderRegistry.get(selection) ?? availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)
|
|
114
|
+
|
|
115
|
+
const resolved = provider ?? availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)
|
|
116
|
+
if (!resolved) {
|
|
117
|
+
throw new Error('[internal] No availability provider registered, including the built-in catalog-only fallback')
|
|
118
|
+
}
|
|
119
|
+
return resolved.getAvailability(query)
|
|
120
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Availability Contract — base types.
|
|
3
|
+
*
|
|
4
|
+
* Zero module dependencies, so `ecommerce`, `cart`, `checkout` and `catalog`
|
|
5
|
+
* can consume this without a `requires` edge on either `availability` or `wms`.
|
|
6
|
+
*
|
|
7
|
+
* @see .ai/specs/2026-08-14-availability-contract.md §4.1a
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type AvailabilityState =
|
|
11
|
+
| 'in_stock'
|
|
12
|
+
| 'low_stock'
|
|
13
|
+
| 'out_of_stock'
|
|
14
|
+
| 'backorder'
|
|
15
|
+
| 'preorder'
|
|
16
|
+
| 'not_tracked'
|
|
17
|
+
|
|
18
|
+
export type AvailabilityItemQuery = {
|
|
19
|
+
catalogProductId: string
|
|
20
|
+
/** `null` or omitted → product-level rollup over active variants. */
|
|
21
|
+
catalogVariantId?: string | null
|
|
22
|
+
/** The quantity being asked about; state is relative to it. */
|
|
23
|
+
quantity: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type AvailabilityQuery = {
|
|
27
|
+
tenantId: string
|
|
28
|
+
organizationId: string
|
|
29
|
+
items: AvailabilityItemQuery[]
|
|
30
|
+
/** Selects the policy chain. */
|
|
31
|
+
storeId?: string | null
|
|
32
|
+
channelId?: string | null
|
|
33
|
+
/** `null`/omitted → every in-scope location. */
|
|
34
|
+
locationIds?: string[] | null
|
|
35
|
+
/**
|
|
36
|
+
* Additive, optional. When true, a provider MUST skip any internal read
|
|
37
|
+
* cache and compute a live result — the §6 "cart re-validation" row.
|
|
38
|
+
*/
|
|
39
|
+
bypassCache?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type AvailabilityItemResult = {
|
|
43
|
+
state: AvailabilityState
|
|
44
|
+
/** Sellable quantity; `null` when not tracked. */
|
|
45
|
+
availableQuantity: number | null
|
|
46
|
+
/** Whether the requested quantity can be met, including a backorder/preorder path. */
|
|
47
|
+
canFulfil: boolean
|
|
48
|
+
/** Set for `'backorder'`. */
|
|
49
|
+
leadTimeDays: number | null
|
|
50
|
+
/** ISO-8601; set for `'preorder'`. */
|
|
51
|
+
releaseAt: string | null
|
|
52
|
+
/** `false` for a cached browse-time read — never a guarantee. */
|
|
53
|
+
isAuthoritative: boolean
|
|
54
|
+
/** The `AvailabilityPolicy` row that decided, or `null` for a module default. */
|
|
55
|
+
policySourceId: string | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type AvailabilityResult = {
|
|
59
|
+
/** Key: `${catalogProductId}:${catalogVariantId ?? ''}` — stable and caller-derivable. */
|
|
60
|
+
byItem: Record<string, AvailabilityItemResult>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface AvailabilityProvider {
|
|
64
|
+
id: string
|
|
65
|
+
getAvailability(query: AvailabilityQuery): Promise<AvailabilityResult>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Builds the stable `AvailabilityResult.byItem` key for an item. */
|
|
69
|
+
export function availabilityItemKey(item: { catalogProductId: string; catalogVariantId?: string | null }): string {
|
|
70
|
+
return `${item.catalogProductId}:${item.catalogVariantId ?? ''}`
|
|
71
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type TelemetryRuntime,
|
|
24
24
|
} from '@open-mercato/shared/lib/telemetry/runtime'
|
|
25
25
|
import { z } from 'zod'
|
|
26
|
+
import { NotFoundError as MikroOrmNotFoundError, ValidationError as MikroOrmValidationError } from '@mikro-orm/core'
|
|
26
27
|
|
|
27
28
|
// Keep the real custom-field helpers but spy on the definition loader so we can
|
|
28
29
|
// assert the factory skips the second DB round-trip when the query engine has
|
|
@@ -917,11 +918,90 @@ describe('CRUD Factory', () => {
|
|
|
917
918
|
const res = await route.POST(new Request('http://x/api/example/todos', { method: 'POST', body: JSON.stringify({ title: 'Exhausted', is_done: true, cf_priority: 3 }), headers: { 'content-type': 'application/json' } }))
|
|
918
919
|
expect(res.status).toBe(503)
|
|
919
920
|
expect(res.headers.get('Retry-After')).toBe('2')
|
|
921
|
+
const body = await res.json()
|
|
922
|
+
expect(body.code).toBe('DATABASE_UNAVAILABLE')
|
|
923
|
+
expect(typeof body.requestId).toBe('string')
|
|
924
|
+
expect(res.headers.get('x-request-id')).toBe(body.requestId)
|
|
920
925
|
// The failed write is still rolled back — no created event/index leaks out.
|
|
921
926
|
expect(Object.values(db)).toHaveLength(0)
|
|
922
927
|
expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
|
|
923
928
|
})
|
|
924
929
|
|
|
930
|
+
it('echoes an inbound x-request-id on the 503 transient-DB response', async () => {
|
|
931
|
+
setRecordCustomFields.mockImplementationOnce(async () => {
|
|
932
|
+
throw Object.assign(new Error('sorry, too many clients already'), { code: '53300' })
|
|
933
|
+
})
|
|
934
|
+
const res = await route.POST(new Request('http://x/api/example/todos', {
|
|
935
|
+
method: 'POST',
|
|
936
|
+
body: JSON.stringify({ title: 'Exhausted', is_done: true, cf_priority: 3 }),
|
|
937
|
+
headers: { 'content-type': 'application/json', 'x-request-id': 'req-fixed-503' },
|
|
938
|
+
}))
|
|
939
|
+
expect(res.status).toBe(503)
|
|
940
|
+
const body = await res.json()
|
|
941
|
+
expect(body.requestId).toBe('req-fixed-503')
|
|
942
|
+
expect(res.headers.get('x-request-id')).toBe('req-fixed-503')
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
it('returns DATABASE_ERROR for an unmapped Postgres SQLSTATE without leaking driver detail', async () => {
|
|
946
|
+
setRecordCustomFields.mockImplementationOnce(async () => {
|
|
947
|
+
throw Object.assign(new Error('relation "missing_table" does not exist'), { code: '42P01' })
|
|
948
|
+
})
|
|
949
|
+
const res = await route.POST(new Request('http://x/api/example/todos', {
|
|
950
|
+
method: 'POST',
|
|
951
|
+
body: JSON.stringify({ title: 'Undefined table', is_done: true, cf_priority: 3 }),
|
|
952
|
+
headers: { 'content-type': 'application/json' },
|
|
953
|
+
}))
|
|
954
|
+
expect(res.status).toBe(500)
|
|
955
|
+
const body = await res.json()
|
|
956
|
+
expect(body.code).toBe('DATABASE_ERROR')
|
|
957
|
+
expect(typeof body.requestId).toBe('string')
|
|
958
|
+
expect(JSON.stringify(body)).not.toContain('42P01')
|
|
959
|
+
expect(JSON.stringify(body)).not.toContain('missing_table')
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
it('returns PERSISTENCE_ERROR for a MikroORM ValidationError with no Postgres SQLSTATE', async () => {
|
|
963
|
+
setRecordCustomFields.mockImplementationOnce(async () => {
|
|
964
|
+
throw new MikroOrmValidationError('entity failed validation')
|
|
965
|
+
})
|
|
966
|
+
const res = await route.POST(new Request('http://x/api/example/todos', {
|
|
967
|
+
method: 'POST',
|
|
968
|
+
body: JSON.stringify({ title: 'Invalid entity', is_done: true, cf_priority: 3 }),
|
|
969
|
+
headers: { 'content-type': 'application/json' },
|
|
970
|
+
}))
|
|
971
|
+
expect(res.status).toBe(500)
|
|
972
|
+
const body = await res.json()
|
|
973
|
+
expect(body.code).toBe('PERSISTENCE_ERROR')
|
|
974
|
+
expect(typeof body.requestId).toBe('string')
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
it('returns PERSISTENCE_ERROR for a MikroORM NotFoundError with no Postgres SQLSTATE', async () => {
|
|
978
|
+
setRecordCustomFields.mockImplementationOnce(async () => {
|
|
979
|
+
throw new MikroOrmNotFoundError('entity not found')
|
|
980
|
+
})
|
|
981
|
+
const res = await route.POST(new Request('http://x/api/example/todos', {
|
|
982
|
+
method: 'POST',
|
|
983
|
+
body: JSON.stringify({ title: 'Missing entity', is_done: true, cf_priority: 3 }),
|
|
984
|
+
headers: { 'content-type': 'application/json' },
|
|
985
|
+
}))
|
|
986
|
+
expect(res.status).toBe(500)
|
|
987
|
+
const body = await res.json()
|
|
988
|
+
expect(body.code).toBe('PERSISTENCE_ERROR')
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
it('does not misclassify a Node system error code as a database error', async () => {
|
|
992
|
+
setRecordCustomFields.mockImplementationOnce(async () => {
|
|
993
|
+
throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:80'), { code: 'ECONNREFUSED' })
|
|
994
|
+
})
|
|
995
|
+
const res = await route.POST(new Request('http://x/api/example/todos', {
|
|
996
|
+
method: 'POST',
|
|
997
|
+
body: JSON.stringify({ title: 'Unrelated socket failure', is_done: true, cf_priority: 3 }),
|
|
998
|
+
headers: { 'content-type': 'application/json' },
|
|
999
|
+
}))
|
|
1000
|
+
expect(res.status).toBe(500)
|
|
1001
|
+
const body = await res.json()
|
|
1002
|
+
expect(body.code).toBe('INTERNAL_ERROR')
|
|
1003
|
+
})
|
|
1004
|
+
|
|
925
1005
|
it('returns a correlated 409 without leaking the constraint name when a handler hits a foreign key violation', async () => {
|
|
926
1006
|
setRecordCustomFields.mockImplementationOnce(async () => {
|
|
927
1007
|
// Mirror MikroORM's wrapping: the pg error sits behind `previous`, and the
|
|
@@ -1353,6 +1433,7 @@ describe('CRUD Factory', () => {
|
|
|
1353
1433
|
error: 'Internal server error',
|
|
1354
1434
|
message: 'Something went wrong. Please try again later.',
|
|
1355
1435
|
requestId: expect.any(String),
|
|
1436
|
+
code: 'INTERNAL_ERROR',
|
|
1356
1437
|
})
|
|
1357
1438
|
})
|
|
1358
1439
|
|
|
@@ -1392,6 +1473,7 @@ describe('CRUD Factory', () => {
|
|
|
1392
1473
|
error: 'Internal server error',
|
|
1393
1474
|
message: 'Something went wrong. Please try again later.',
|
|
1394
1475
|
requestId: expect.any(String),
|
|
1476
|
+
code: 'INTERNAL_ERROR',
|
|
1395
1477
|
})
|
|
1396
1478
|
})
|
|
1397
1479
|
|
|
@@ -1497,7 +1579,7 @@ describe('CRUD Factory', () => {
|
|
|
1497
1579
|
expect(body.requestId).toMatch(/^[A-Za-z0-9-]{36}$/)
|
|
1498
1580
|
})
|
|
1499
1581
|
|
|
1500
|
-
it('reports the error to telemetry with the same requestId', async () => {
|
|
1582
|
+
it('reports the error to telemetry with the same requestId and a groupable code', async () => {
|
|
1501
1583
|
commandBus.execute.mockRejectedValue(new Error('boom'))
|
|
1502
1584
|
|
|
1503
1585
|
const res = await postWithRequestId('req-fixed-123')
|
|
@@ -1507,12 +1589,17 @@ describe('CRUD Factory', () => {
|
|
|
1507
1589
|
expect(reportError).toHaveBeenCalledTimes(1)
|
|
1508
1590
|
expect(reportError).toHaveBeenCalledWith(
|
|
1509
1591
|
expect.any(Error),
|
|
1510
|
-
{
|
|
1592
|
+
{
|
|
1593
|
+
module: 'crud',
|
|
1594
|
+
code: 'crud.internal_error',
|
|
1595
|
+
attributes: { requestId: 'req-fixed-123', errorName: 'Error', code: 'INTERNAL_ERROR', pgCode: undefined },
|
|
1596
|
+
},
|
|
1511
1597
|
)
|
|
1512
1598
|
})
|
|
1513
1599
|
|
|
1514
|
-
// The
|
|
1515
|
-
// in so a later refactor cannot quietly widen the correlation id
|
|
1600
|
+
// The 422 interceptor-rejection branch deliberately stays outside this change (issue
|
|
1601
|
+
// #5608) — lock that in so a later refactor cannot quietly widen the correlation id
|
|
1602
|
+
// across every branch. The 503 transient-DB branch gained requestId/code separately.
|
|
1516
1603
|
it('leaves the interceptor-rejection branch without a requestId', async () => {
|
|
1517
1604
|
commandBus.execute.mockRejectedValue(
|
|
1518
1605
|
new CommandInterceptorError('Missing required fields: VAT id', { status: 422 }),
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -75,9 +75,10 @@ import { parseExtensionHeaders } from '../umes/extension-headers'
|
|
|
75
75
|
import { createGenericOptimisticLockReader } from './optimistic-lock'
|
|
76
76
|
import { registerOptimisticLockReaderIfAbsent } from './optimistic-lock-store'
|
|
77
77
|
import { createLogger } from '../logger'
|
|
78
|
-
import { getForeignKeyViolationConstraint, isForeignKeyViolation, isTransientDbError } from '../db/pg-errors'
|
|
78
|
+
import { getForeignKeyViolationConstraint, isForeignKeyViolation, isTransientDbError, readPgSqlState } from '../db/pg-errors'
|
|
79
79
|
import { getTelemetryRuntime } from '../telemetry/runtime'
|
|
80
80
|
import { randomUUID } from 'node:crypto'
|
|
81
|
+
import { NotFoundError as MikroOrmNotFoundError, ValidationError as MikroOrmValidationError } from '@mikro-orm/core'
|
|
81
82
|
|
|
82
83
|
type RbacServiceLike = {
|
|
83
84
|
getGrantedFeatures: (userId: string, opts: { tenantId: string | null; organizationId: string | null }) => Promise<string[]>
|
|
@@ -609,6 +610,30 @@ function resolveRequestId(request?: Request): string {
|
|
|
609
610
|
return randomUUID()
|
|
610
611
|
}
|
|
611
612
|
|
|
613
|
+
/**
|
|
614
|
+
* Stable, UPPER_SNAKE classification codes for the generic 500/503 fallback
|
|
615
|
+
* bodies below. See `apps/docs/docs/framework/runtime/request-lifecycle.mdx`
|
|
616
|
+
* for the full contract.
|
|
617
|
+
*
|
|
618
|
+
* - `DATABASE_UNAVAILABLE` — `isTransientDbError` matched (503 branch).
|
|
619
|
+
* - `DATABASE_ERROR` — a Postgres SQLSTATE is present (via `readPgSqlState`)
|
|
620
|
+
* but was not already classified as transient (503) or a foreign-key
|
|
621
|
+
* violation (409).
|
|
622
|
+
* - `PERSISTENCE_ERROR` — a MikroORM `ValidationError`/`NotFoundError` that
|
|
623
|
+
* was not already handled by a more specific branch.
|
|
624
|
+
* - `INTERNAL_ERROR` — default fallback for anything else.
|
|
625
|
+
*/
|
|
626
|
+
type CrudErrorCode = 'INTERNAL_ERROR' | 'DATABASE_ERROR' | 'PERSISTENCE_ERROR' | 'DATABASE_UNAVAILABLE'
|
|
627
|
+
|
|
628
|
+
function classifyCrudError(err: unknown): { code: CrudErrorCode; pgSqlState: string | null } {
|
|
629
|
+
const pgSqlState = readPgSqlState(err)
|
|
630
|
+
if (pgSqlState) return { code: 'DATABASE_ERROR', pgSqlState }
|
|
631
|
+
if (err instanceof MikroOrmValidationError || err instanceof MikroOrmNotFoundError) {
|
|
632
|
+
return { code: 'PERSISTENCE_ERROR', pgSqlState: null }
|
|
633
|
+
}
|
|
634
|
+
return { code: 'INTERNAL_ERROR', pgSqlState: null }
|
|
635
|
+
}
|
|
636
|
+
|
|
612
637
|
function handleError(err: unknown, request?: Request): Response {
|
|
613
638
|
if (err instanceof Response) return err
|
|
614
639
|
if (isCrudHttpError(err)) return json(err.body, { status: err.status })
|
|
@@ -623,13 +648,16 @@ function handleError(err: unknown, request?: Request): Response {
|
|
|
623
648
|
if (isTransientDbError(err)) {
|
|
624
649
|
// Transient DB unavailability (pool exhausted, `max_connections` reached, DB
|
|
625
650
|
// restarting) is retryable — surface a 503 with a Retry-After hint instead of
|
|
626
|
-
// a generic 500 so clients back off and retry once the DB recovers.
|
|
651
|
+
// a generic 500 so clients back off and retry once the DB recovers. Carries
|
|
652
|
+
// the same requestId correlation contract as the other branches below.
|
|
653
|
+
const requestId = resolveRequestId(request)
|
|
627
654
|
logger.warn('Transient DB failure during CRUD handler', {
|
|
628
655
|
message: err instanceof Error ? err.message : undefined,
|
|
656
|
+
requestId,
|
|
629
657
|
})
|
|
630
658
|
return json(
|
|
631
|
-
{ error: 'Service temporarily unavailable' },
|
|
632
|
-
{ status: 503, headers: { 'Retry-After': '2' } },
|
|
659
|
+
{ error: 'Service temporarily unavailable', code: 'DATABASE_UNAVAILABLE', requestId },
|
|
660
|
+
{ status: 503, headers: { 'Retry-After': '2', 'x-request-id': requestId } },
|
|
633
661
|
)
|
|
634
662
|
}
|
|
635
663
|
|
|
@@ -670,15 +698,18 @@ function handleError(err: unknown, request?: Request): Response {
|
|
|
670
698
|
const stack = err instanceof Error ? err.stack : undefined
|
|
671
699
|
const errorName = err instanceof Error ? err.name : undefined
|
|
672
700
|
const requestId = resolveRequestId(request)
|
|
673
|
-
|
|
701
|
+
const { code, pgSqlState } = classifyCrudError(err)
|
|
702
|
+
logger.error('Unexpected CRUD error', { message, stack, err, requestId, code })
|
|
674
703
|
getTelemetryRuntime()?.reportError(err, {
|
|
675
704
|
module: 'crud',
|
|
676
|
-
|
|
705
|
+
code: `crud.${code.toLowerCase()}`,
|
|
706
|
+
attributes: { requestId, errorName, code, pgCode: pgSqlState ?? undefined },
|
|
677
707
|
})
|
|
678
708
|
const body: Record<string, unknown> = {
|
|
679
709
|
error: 'Internal server error',
|
|
680
710
|
message: 'Something went wrong. Please try again later.',
|
|
681
711
|
requestId,
|
|
712
|
+
code,
|
|
682
713
|
}
|
|
683
714
|
return json(body, { status: 500, headers: { 'x-request-id': requestId } })
|
|
684
715
|
}
|