@open-mercato/shared 0.8.1-develop.7263.1.bbc6db3437 → 0.8.1-develop.7267.1.2e95af80a6
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/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -6
- package/dist/lib/availability/catalogOnlyProvider.js +0 -65
- package/dist/lib/availability/catalogOnlyProvider.js.map +0 -7
- package/dist/lib/availability/index.js +0 -8
- package/dist/lib/availability/index.js.map +0 -7
- package/dist/lib/availability/registry.js +0 -47
- package/dist/lib/availability/registry.js.map +0 -7
- package/dist/lib/availability/types.js +0 -7
- package/dist/lib/availability/types.js.map +0 -7
- package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +0 -98
- package/src/lib/availability/__tests__/registry.test.ts +0 -115
- package/src/lib/availability/catalogOnlyProvider.ts +0 -119
- package/src/lib/availability/index.ts +0 -18
- package/src/lib/availability/registry.ts +0 -120
- package/src/lib/availability/types.ts +0 -71
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:shared] found
|
|
1
|
+
[build:shared] found 293 entry points
|
|
2
2
|
[build:shared] built successfully
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.8.1-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.8.1-develop.7267.1.2e95af80a6';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.8.1-develop.
|
|
3
|
+
"version": "0.8.1-develop.7267.1.2e95af80a6",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -12,10 +12,6 @@
|
|
|
12
12
|
},
|
|
13
13
|
"exports": {
|
|
14
14
|
".": "./dist/index.js",
|
|
15
|
-
"./lib/availability": {
|
|
16
|
-
"types": "./src/lib/availability/index.ts",
|
|
17
|
-
"default": "./dist/lib/availability/index.js"
|
|
18
|
-
},
|
|
19
15
|
"./lib/openapi": {
|
|
20
16
|
"types": "./src/lib/openapi/index.ts",
|
|
21
17
|
"default": "./dist/lib/openapi/index.js"
|
|
@@ -117,7 +113,7 @@
|
|
|
117
113
|
"@mikro-orm/core": "^7.1.14",
|
|
118
114
|
"@mikro-orm/decorators": "^7.1.14",
|
|
119
115
|
"@mikro-orm/postgresql": "^7.1.14",
|
|
120
|
-
"@open-mercato/cache": "0.8.1-develop.
|
|
116
|
+
"@open-mercato/cache": "0.8.1-develop.7267.1.2e95af80a6",
|
|
121
117
|
"@types/html-to-text": "^9.0.4",
|
|
122
118
|
"@types/sanitize-html": "^2.16.1",
|
|
123
119
|
"dotenv": "^17.4.2",
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { availabilityItemKey } from "./types.js";
|
|
2
|
-
import { availabilityProviderRegistry, AVAILABILITY_CATALOG_ONLY_PROVIDER_ID } from "./registry.js";
|
|
3
|
-
let policyLookup = null;
|
|
4
|
-
function setCatalogOnlyPolicyLookup(lookup) {
|
|
5
|
-
policyLookup = lookup;
|
|
6
|
-
}
|
|
7
|
-
function pureFallbackItem() {
|
|
8
|
-
return {
|
|
9
|
-
state: "not_tracked",
|
|
10
|
-
availableQuantity: null,
|
|
11
|
-
canFulfil: true,
|
|
12
|
-
leadTimeDays: null,
|
|
13
|
-
releaseAt: null,
|
|
14
|
-
isAuthoritative: true,
|
|
15
|
-
policySourceId: null
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
function applyOverride(override) {
|
|
19
|
-
const base = pureFallbackItem();
|
|
20
|
-
if (!override) return base;
|
|
21
|
-
const policySourceId = override.policySourceId ?? null;
|
|
22
|
-
if (override.preorderReleaseAt) {
|
|
23
|
-
const releaseAt = new Date(override.preorderReleaseAt);
|
|
24
|
-
if (!Number.isNaN(releaseAt.getTime()) && releaseAt.getTime() > Date.now()) {
|
|
25
|
-
return {
|
|
26
|
-
...base,
|
|
27
|
-
state: "preorder",
|
|
28
|
-
canFulfil: true,
|
|
29
|
-
releaseAt: override.preorderReleaseAt,
|
|
30
|
-
policySourceId
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
if (override.isActive === false) {
|
|
35
|
-
return { ...base, state: "out_of_stock", canFulfil: false, policySourceId };
|
|
36
|
-
}
|
|
37
|
-
if (override.isStockManaged === true) {
|
|
38
|
-
return { ...base, state: "out_of_stock", canFulfil: false, policySourceId };
|
|
39
|
-
}
|
|
40
|
-
return { ...base, policySourceId };
|
|
41
|
-
}
|
|
42
|
-
async function getAvailability(query) {
|
|
43
|
-
const byItem = {};
|
|
44
|
-
let overrides = {};
|
|
45
|
-
if (policyLookup) {
|
|
46
|
-
try {
|
|
47
|
-
overrides = await policyLookup(query);
|
|
48
|
-
} catch {
|
|
49
|
-
overrides = {};
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
for (const item of query.items) {
|
|
53
|
-
const key = availabilityItemKey(item);
|
|
54
|
-
byItem[key] = applyOverride(overrides[key]);
|
|
55
|
-
}
|
|
56
|
-
return { byItem };
|
|
57
|
-
}
|
|
58
|
-
availabilityProviderRegistry.register({
|
|
59
|
-
id: AVAILABILITY_CATALOG_ONLY_PROVIDER_ID,
|
|
60
|
-
getAvailability
|
|
61
|
-
});
|
|
62
|
-
export {
|
|
63
|
-
setCatalogOnlyPolicyLookup
|
|
64
|
-
};
|
|
65
|
-
//# sourceMappingURL=catalogOnlyProvider.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/lib/availability/catalogOnlyProvider.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * The built-in `catalog-only` fallback provider. Always registered so a\n * `wms`-less *and* `availability`-less storefront stays fully functional.\n *\n * @see .ai/specs/2026-08-14-availability-contract.md \u00A74.3\n */\n\nimport { availabilityItemKey } from './types'\nimport type { AvailabilityItemResult, AvailabilityQuery, AvailabilityResult } from './types'\nimport { availabilityProviderRegistry, AVAILABILITY_CATALOG_ONLY_PROVIDER_ID } from './registry'\n\n/** Per-item policy signal the optional lookup hook may report. */\nexport type CatalogOnlyPolicyOverride = {\n /** `true` \u2192 the item is explicitly opted into stock tracking with no data source behind it (\u00A74.3, out_of_stock). */\n isStockManaged?: boolean\n /** `false` \u2192 the policy row is inactive; treated as out_of_stock. */\n isActive?: boolean\n /** ISO-8601. In the future \u2192 `preorder`. */\n preorderReleaseAt?: string | null\n /** The `AvailabilityPolicy` row id that produced this override. */\n policySourceId?: string | null\n}\n\n/**\n * Optional soft lookup into `AvailabilityPolicy` when the `availability`\n * module is installed. Keyed by `availabilityItemKey()`. Returning `null` or\n * omitting an item from the map means \"no policy row \u2014 module default\".\n */\nexport type CatalogOnlyPolicyLookup = (\n query: AvailabilityQuery,\n) => Promise<Record<string, CatalogOnlyPolicyOverride | null | undefined>>\n\nlet policyLookup: CatalogOnlyPolicyLookup | null = null\n\n/**\n * Wired by the `availability` module's `di.ts` at container-build time\n * (closure captures the container, mirroring `wms/di.ts`'s own provider\n * registration) \u2014 never a static import from `packages/shared`. Pass `null`\n * to clear (test isolation).\n */\nexport function setCatalogOnlyPolicyLookup(lookup: CatalogOnlyPolicyLookup | null): void {\n policyLookup = lookup\n}\n\nfunction pureFallbackItem(): AvailabilityItemResult {\n return {\n state: 'not_tracked',\n availableQuantity: null,\n canFulfil: true,\n leadTimeDays: null,\n releaseAt: null,\n isAuthoritative: true,\n policySourceId: null,\n }\n}\n\n/**\n * Applies decision 7's matrix (see PLAN.md \u00A7 Key design decisions) for a\n * resolved policy override on top of the pure fallback.\n */\nfunction applyOverride(override: CatalogOnlyPolicyOverride | null | undefined): AvailabilityItemResult {\n const base = pureFallbackItem()\n if (!override) return base\n\n const policySourceId = override.policySourceId ?? null\n\n if (override.preorderReleaseAt) {\n const releaseAt = new Date(override.preorderReleaseAt)\n if (!Number.isNaN(releaseAt.getTime()) && releaseAt.getTime() > Date.now()) {\n return {\n ...base,\n state: 'preorder',\n canFulfil: true,\n releaseAt: override.preorderReleaseAt,\n policySourceId,\n }\n }\n }\n\n if (override.isActive === false) {\n return { ...base, state: 'out_of_stock', canFulfil: false, policySourceId }\n }\n\n if (override.isStockManaged === true) {\n // Opted into stock tracking with no data source to verify against \u2014 see\n // decision 7: this is the \"policy explicitly marks the item unavailable\"\n // case rather than a silent \"in stock\" claim (R5).\n return { ...base, state: 'out_of_stock', canFulfil: false, policySourceId }\n }\n\n return { ...base, policySourceId }\n}\n\nasync function getAvailability(query: AvailabilityQuery): Promise<AvailabilityResult> {\n const byItem: AvailabilityResult['byItem'] = {}\n\n let overrides: Record<string, CatalogOnlyPolicyOverride | null | undefined> = {}\n if (policyLookup) {\n try {\n overrides = await policyLookup(query)\n } catch {\n // Degrade gracefully to the pure fallback \u2014 never let an optional\n // policy lookup failure break the always-available fallback provider.\n overrides = {}\n }\n }\n\n for (const item of query.items) {\n const key = availabilityItemKey(item)\n byItem[key] = applyOverride(overrides[key])\n }\n\n return { byItem }\n}\n\navailabilityProviderRegistry.register({\n id: AVAILABILITY_CATALOG_ONLY_PROVIDER_ID,\n getAvailability,\n})\n"],
|
|
5
|
-
"mappings": "AAOA,SAAS,2BAA2B;AAEpC,SAAS,8BAA8B,6CAA6C;AAuBpF,IAAI,eAA+C;AAQ5C,SAAS,2BAA2B,QAA8C;AACvF,iBAAe;AACjB;AAEA,SAAS,mBAA2C;AAClD,SAAO;AAAA,IACL,OAAO;AAAA,IACP,mBAAmB;AAAA,IACnB,WAAW;AAAA,IACX,cAAc;AAAA,IACd,WAAW;AAAA,IACX,iBAAiB;AAAA,IACjB,gBAAgB;AAAA,EAClB;AACF;AAMA,SAAS,cAAc,UAAgF;AACrG,QAAM,OAAO,iBAAiB;AAC9B,MAAI,CAAC,SAAU,QAAO;AAEtB,QAAM,iBAAiB,SAAS,kBAAkB;AAElD,MAAI,SAAS,mBAAmB;AAC9B,UAAM,YAAY,IAAI,KAAK,SAAS,iBAAiB;AACrD,QAAI,CAAC,OAAO,MAAM,UAAU,QAAQ,CAAC,KAAK,UAAU,QAAQ,IAAI,KAAK,IAAI,GAAG;AAC1E,aAAO;AAAA,QACL,GAAG;AAAA,QACH,OAAO;AAAA,QACP,WAAW;AAAA,QACX,WAAW,SAAS;AAAA,QACpB;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,MAAI,SAAS,aAAa,OAAO;AAC/B,WAAO,EAAE,GAAG,MAAM,OAAO,gBAAgB,WAAW,OAAO,eAAe;AAAA,EAC5E;AAEA,MAAI,SAAS,mBAAmB,MAAM;AAIpC,WAAO,EAAE,GAAG,MAAM,OAAO,gBAAgB,WAAW,OAAO,eAAe;AAAA,EAC5E;AAEA,SAAO,EAAE,GAAG,MAAM,eAAe;AACnC;AAEA,eAAe,gBAAgB,OAAuD;AACpF,QAAM,SAAuC,CAAC;AAE9C,MAAI,YAA0E,CAAC;AAC/E,MAAI,cAAc;AAChB,QAAI;AACF,kBAAY,MAAM,aAAa,KAAK;AAAA,IACtC,QAAQ;AAGN,kBAAY,CAAC;AAAA,IACf;AAAA,EACF;AAEA,aAAW,QAAQ,MAAM,OAAO;AAC9B,UAAM,MAAM,oBAAoB,IAAI;AACpC,WAAO,GAAG,IAAI,cAAc,UAAU,GAAG,CAAC;AAAA,EAC5C;AAEA,SAAO,EAAE,OAAO;AAClB;AAEA,6BAA6B,SAAS;AAAA,EACpC,IAAI;AAAA,EACJ;AACF,CAAC;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/lib/availability/index.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Public entrypoint for the Availability Contract's shared, dependency-free\n * base \u2014 types, the provider registry, `resolveAvailability()`, and the\n * built-in `catalog-only` fallback.\n *\n * Importing from this barrel (rather than `./registry` directly) guarantees\n * the `catalog-only` provider is registered \u2014 it self-registers as an import\n * side effect in `./catalogOnlyProvider`, which this file always pulls in.\n *\n * @see .ai/specs/2026-08-14-availability-contract.md \u00A74.1a\n */\n\nexport * from './types'\nexport * from './registry'\nexport { setCatalogOnlyPolicyLookup } from './catalogOnlyProvider'\nexport type { CatalogOnlyPolicyLookup, CatalogOnlyPolicyOverride } from './catalogOnlyProvider'\n\nimport './catalogOnlyProvider'\n"],
|
|
5
|
-
"mappings": "AAYA,cAAc;AACd,cAAc;AACd,SAAS,kCAAkC;AAG3C,OAAO;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
const AVAILABILITY_CATALOG_ONLY_PROVIDER_ID = "catalog-only";
|
|
2
|
-
class AvailabilityProviderRegistryImpl {
|
|
3
|
-
constructor() {
|
|
4
|
-
// Preserves registration order via Map iteration semantics.
|
|
5
|
-
this.providers = /* @__PURE__ */ new Map();
|
|
6
|
-
}
|
|
7
|
-
register(provider) {
|
|
8
|
-
if (!provider || typeof provider.id !== "string" || provider.id.length === 0) {
|
|
9
|
-
throw new Error("[internal] AvailabilityProviderRegistry: provider must have a non-empty id");
|
|
10
|
-
}
|
|
11
|
-
this.providers.set(provider.id, provider);
|
|
12
|
-
}
|
|
13
|
-
get(id) {
|
|
14
|
-
return this.providers.get(id) ?? null;
|
|
15
|
-
}
|
|
16
|
-
list() {
|
|
17
|
-
return Array.from(this.providers.values());
|
|
18
|
-
}
|
|
19
|
-
reset() {
|
|
20
|
-
this.providers.clear();
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
const availabilityProviderRegistry = new AvailabilityProviderRegistryImpl();
|
|
24
|
-
function resolveAutoProvider() {
|
|
25
|
-
for (const provider of availabilityProviderRegistry.list()) {
|
|
26
|
-
if (provider.id !== AVAILABILITY_CATALOG_ONLY_PROVIDER_ID) return provider;
|
|
27
|
-
}
|
|
28
|
-
return availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID);
|
|
29
|
-
}
|
|
30
|
-
async function resolveAvailability(query, options) {
|
|
31
|
-
const selection = options?.moduleConfig ? await options.moduleConfig.getValue("availability", "selectedProvider", {
|
|
32
|
-
defaultValue: "auto",
|
|
33
|
-
scope: { tenantId: query.tenantId }
|
|
34
|
-
}) : "auto";
|
|
35
|
-
const provider = !selection || selection === "auto" ? resolveAutoProvider() : availabilityProviderRegistry.get(selection) ?? availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID);
|
|
36
|
-
const resolved = provider ?? availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID);
|
|
37
|
-
if (!resolved) {
|
|
38
|
-
throw new Error("[internal] No availability provider registered, including the built-in catalog-only fallback");
|
|
39
|
-
}
|
|
40
|
-
return resolved.getAvailability(query);
|
|
41
|
-
}
|
|
42
|
-
export {
|
|
43
|
-
AVAILABILITY_CATALOG_ONLY_PROVIDER_ID,
|
|
44
|
-
availabilityProviderRegistry,
|
|
45
|
-
resolveAvailability
|
|
46
|
-
};
|
|
47
|
-
//# sourceMappingURL=registry.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/lib/availability/registry.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Availability provider registry \u2014 module-level singleton that collects\n * registered providers and dispatches `resolveAvailability()` to the\n * per-tenant selected one, falling back safely to the built-in\n * `catalog-only` provider.\n *\n * Mirrors `packages/shared/src/lib/ai/llm-provider-registry.ts`.\n *\n * @see ./types\n * @see .ai/specs/2026-08-14-availability-contract.md \u00A74.1a\n */\n\nimport type { AvailabilityProvider, AvailabilityQuery, AvailabilityResult } from './types'\n\nexport const AVAILABILITY_CATALOG_ONLY_PROVIDER_ID = 'catalog-only'\n\n/** Public interface of the registry. Exposed as a singleton via {@link availabilityProviderRegistry}. */\nexport interface AvailabilityProviderRegistry {\n /**\n * Registers or replaces a provider. Registration is idempotent \u2014 calling\n * with the same id replaces the existing entry. Never order-dependent.\n */\n register(provider: AvailabilityProvider): void\n\n /** Returns the provider with the given id, or null when not registered. */\n get(id: string): AvailabilityProvider | null\n\n /** Returns all registered providers in registration order. */\n list(): readonly AvailabilityProvider[]\n\n /** Removes all registered providers. Intended for test isolation. */\n reset(): void\n}\n\nclass AvailabilityProviderRegistryImpl implements AvailabilityProviderRegistry {\n // Preserves registration order via Map iteration semantics.\n private readonly providers = new Map<string, AvailabilityProvider>()\n\n register(provider: AvailabilityProvider): void {\n if (!provider || typeof provider.id !== 'string' || provider.id.length === 0) {\n throw new Error('[internal] AvailabilityProviderRegistry: provider must have a non-empty id')\n }\n // Idempotent: replace existing by id.\n this.providers.set(provider.id, provider)\n }\n\n get(id: string): AvailabilityProvider | null {\n return this.providers.get(id) ?? null\n }\n\n list(): readonly AvailabilityProvider[] {\n return Array.from(this.providers.values())\n }\n\n reset(): void {\n this.providers.clear()\n }\n}\n\n/** Process-level singleton instance of the registry. */\nexport const availabilityProviderRegistry: AvailabilityProviderRegistry = new AvailabilityProviderRegistryImpl()\n\nexport type AvailabilityProviderSelection = 'auto' | 'catalog-only' | (string & {})\n\n/**\n * Narrow port for per-tenant provider selection \u2014 declared locally so this\n * package stays dependency-free. A caller with DI access (an `availability`\n * module route, a future `ecommerce`/`cart`/`checkout` consumer) passes in\n * its resolved `ModuleConfigService` instance; omitting it always resolves\n * `'auto'`.\n */\nexport interface AvailabilityModuleConfigReader {\n getValue<T = unknown>(\n moduleId: string,\n name: string,\n options?: { defaultValue?: T | null; scope?: { tenantId?: string | null; organizationId?: string | null } },\n ): Promise<T | null>\n}\n\nexport interface ResolveAvailabilityOptions {\n /** `ModuleConfigService('availability', 'selectedProvider')` reader. See {@link AvailabilityModuleConfigReader}. */\n moduleConfig?: AvailabilityModuleConfigReader\n}\n\nfunction resolveAutoProvider(): AvailabilityProvider | null {\n // 'auto' = \"the highest-precedence registered provider\" \u2014 the first\n // non-catalog-only registrant, in registration order.\n for (const provider of availabilityProviderRegistry.list()) {\n if (provider.id !== AVAILABILITY_CATALOG_ONLY_PROVIDER_ID) return provider\n }\n return availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)\n}\n\n/**\n * The entry point every read-side consumer calls. Advisory only \u2014 see \u00A74.1:\n * `resolveAvailability()` is never a stock guarantee, `reserveAvailability()`\n * (the `availability` module, not shipped by this contract) is.\n */\nexport async function resolveAvailability(\n query: AvailabilityQuery,\n options?: ResolveAvailabilityOptions,\n): Promise<AvailabilityResult> {\n const selection = options?.moduleConfig\n ? await options.moduleConfig.getValue<AvailabilityProviderSelection>('availability', 'selectedProvider', {\n defaultValue: 'auto',\n scope: { tenantId: query.tenantId },\n })\n : 'auto'\n\n const provider =\n !selection || selection === 'auto'\n ? resolveAutoProvider()\n : availabilityProviderRegistry.get(selection) ?? availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)\n\n const resolved = provider ?? availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)\n if (!resolved) {\n throw new Error('[internal] No availability provider registered, including the built-in catalog-only fallback')\n }\n return resolved.getAvailability(query)\n}\n"],
|
|
5
|
-
"mappings": "AAcO,MAAM,wCAAwC;AAoBrD,MAAM,iCAAyE;AAAA,EAA/E;AAEE;AAAA,SAAiB,YAAY,oBAAI,IAAkC;AAAA;AAAA,EAEnE,SAAS,UAAsC;AAC7C,QAAI,CAAC,YAAY,OAAO,SAAS,OAAO,YAAY,SAAS,GAAG,WAAW,GAAG;AAC5E,YAAM,IAAI,MAAM,4EAA4E;AAAA,IAC9F;AAEA,SAAK,UAAU,IAAI,SAAS,IAAI,QAAQ;AAAA,EAC1C;AAAA,EAEA,IAAI,IAAyC;AAC3C,WAAO,KAAK,UAAU,IAAI,EAAE,KAAK;AAAA,EACnC;AAAA,EAEA,OAAwC;AACtC,WAAO,MAAM,KAAK,KAAK,UAAU,OAAO,CAAC;AAAA,EAC3C;AAAA,EAEA,QAAc;AACZ,SAAK,UAAU,MAAM;AAAA,EACvB;AACF;AAGO,MAAM,+BAA6D,IAAI,iCAAiC;AAwB/G,SAAS,sBAAmD;AAG1D,aAAW,YAAY,6BAA6B,KAAK,GAAG;AAC1D,QAAI,SAAS,OAAO,sCAAuC,QAAO;AAAA,EACpE;AACA,SAAO,6BAA6B,IAAI,qCAAqC;AAC/E;AAOA,eAAsB,oBACpB,OACA,SAC6B;AAC7B,QAAM,YAAY,SAAS,eACvB,MAAM,QAAQ,aAAa,SAAwC,gBAAgB,oBAAoB;AAAA,IACrG,cAAc;AAAA,IACd,OAAO,EAAE,UAAU,MAAM,SAAS;AAAA,EACpC,CAAC,IACD;AAEJ,QAAM,WACJ,CAAC,aAAa,cAAc,SACxB,oBAAoB,IACpB,6BAA6B,IAAI,SAAS,KAAK,6BAA6B,IAAI,qCAAqC;AAE3H,QAAM,WAAW,YAAY,6BAA6B,IAAI,qCAAqC;AACnG,MAAI,CAAC,UAAU;AACb,UAAM,IAAI,MAAM,8FAA8F;AAAA,EAChH;AACA,SAAO,SAAS,gBAAgB,KAAK;AACvC;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/lib/availability/types.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Availability Contract \u2014 base types.\n *\n * Zero module dependencies, so `ecommerce`, `cart`, `checkout` and `catalog`\n * can consume this without a `requires` edge on either `availability` or `wms`.\n *\n * @see .ai/specs/2026-08-14-availability-contract.md \u00A74.1a\n */\n\nexport type AvailabilityState =\n | 'in_stock'\n | 'low_stock'\n | 'out_of_stock'\n | 'backorder'\n | 'preorder'\n | 'not_tracked'\n\nexport type AvailabilityItemQuery = {\n catalogProductId: string\n /** `null` or omitted \u2192 product-level rollup over active variants. */\n catalogVariantId?: string | null\n /** The quantity being asked about; state is relative to it. */\n quantity: number\n}\n\nexport type AvailabilityQuery = {\n tenantId: string\n organizationId: string\n items: AvailabilityItemQuery[]\n /** Selects the policy chain. */\n storeId?: string | null\n channelId?: string | null\n /** `null`/omitted \u2192 every in-scope location. */\n locationIds?: string[] | null\n /**\n * Additive, optional. When true, a provider MUST skip any internal read\n * cache and compute a live result \u2014 the \u00A76 \"cart re-validation\" row.\n */\n bypassCache?: boolean\n}\n\nexport type AvailabilityItemResult = {\n state: AvailabilityState\n /** Sellable quantity; `null` when not tracked. */\n availableQuantity: number | null\n /** Whether the requested quantity can be met, including a backorder/preorder path. */\n canFulfil: boolean\n /** Set for `'backorder'`. */\n leadTimeDays: number | null\n /** ISO-8601; set for `'preorder'`. */\n releaseAt: string | null\n /** `false` for a cached browse-time read \u2014 never a guarantee. */\n isAuthoritative: boolean\n /** The `AvailabilityPolicy` row that decided, or `null` for a module default. */\n policySourceId: string | null\n}\n\nexport type AvailabilityResult = {\n /** Key: `${catalogProductId}:${catalogVariantId ?? ''}` \u2014 stable and caller-derivable. */\n byItem: Record<string, AvailabilityItemResult>\n}\n\nexport interface AvailabilityProvider {\n id: string\n getAvailability(query: AvailabilityQuery): Promise<AvailabilityResult>\n}\n\n/** Builds the stable `AvailabilityResult.byItem` key for an item. */\nexport function availabilityItemKey(item: { catalogProductId: string; catalogVariantId?: string | null }): string {\n return `${item.catalogProductId}:${item.catalogVariantId ?? ''}`\n}\n"],
|
|
5
|
-
"mappings": "AAoEO,SAAS,oBAAoB,MAA8E;AAChH,SAAO,GAAG,KAAK,gBAAgB,IAAI,KAAK,oBAAoB,EAAE;AAChE;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
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('treats is_stock_managed true (no real data source) as out_of_stock rather than in_stock', async () => {
|
|
75
|
-
setCatalogOnlyPolicyLookup(async () => ({
|
|
76
|
-
'product-1:variant-1': { isStockManaged: true, policySourceId: 'policy-3' },
|
|
77
|
-
}))
|
|
78
|
-
const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
|
|
79
|
-
const result = await provider.getAvailability(makeQuery())
|
|
80
|
-
const item = result.byItem['product-1:variant-1']
|
|
81
|
-
expect(item.state).toBe('out_of_stock')
|
|
82
|
-
expect(item.canFulfil).toBe(false)
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
it('degrades to the pure fallback when the policy hook throws', async () => {
|
|
86
|
-
setCatalogOnlyPolicyLookup(async () => {
|
|
87
|
-
throw new Error('boom')
|
|
88
|
-
})
|
|
89
|
-
const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
|
|
90
|
-
const result = await provider.getAvailability(makeQuery())
|
|
91
|
-
expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
it('is reachable end-to-end via resolveAvailability', async () => {
|
|
95
|
-
const result = await resolveAvailability(makeQuery())
|
|
96
|
-
expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
|
|
97
|
-
})
|
|
98
|
-
})
|
|
@@ -1,115 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,119 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,18 +0,0 @@
|
|
|
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'
|
|
@@ -1,120 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
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
|
-
}
|