@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
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
/** Narrow DI port a provider resolves its per-call dependencies (`em`, cache, services) from. */
|
|
64
|
+
export type AvailabilityDependencyResolver = {
|
|
65
|
+
resolve: <T = unknown>(name: string) => T
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Per-call context handed to a provider. `container` is the calling
|
|
70
|
+
* request's DI container; a provider MUST resolve its dependencies from it
|
|
71
|
+
* (or build its own when absent) and never from a container captured at
|
|
72
|
+
* registration time — the registry is process-wide.
|
|
73
|
+
*/
|
|
74
|
+
export type AvailabilityProviderContext = {
|
|
75
|
+
container?: AvailabilityDependencyResolver | null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AvailabilityProvider {
|
|
79
|
+
id: string
|
|
80
|
+
getAvailability(query: AvailabilityQuery, context?: AvailabilityProviderContext): Promise<AvailabilityResult>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Builds the stable `AvailabilityResult.byItem` key for an item. */
|
|
84
|
+
export function availabilityItemKey(item: { catalogProductId: string; catalogVariantId?: string | null }): string {
|
|
85
|
+
return `${item.catalogProductId}:${item.catalogVariantId ?? ''}`
|
|
86
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { intersectScopes } from '../intersectScopes'
|
|
2
|
+
import { unionScopes } from '../unionScopes'
|
|
3
|
+
import { matchesOne } from '../matchesOne'
|
|
4
|
+
import { matchesScope } from '../matchesScope'
|
|
5
|
+
import type { AssortmentScope, EffectiveAssortmentScope, ScopedProduct } from '../types'
|
|
6
|
+
import { mulberry32, randomAssortmentScope, randomProduct, randomSource } from './testFixtures'
|
|
7
|
+
|
|
8
|
+
function expectedMatch(product: ScopedProduct, channel: AssortmentScope | null, group: EffectiveAssortmentScope): boolean {
|
|
9
|
+
const channelMatches = channel === null ? true : matchesOne(product, channel)
|
|
10
|
+
return channelMatches && matchesScope(product, group)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('intersectScopes — null handling (§11 item 4)', () => {
|
|
14
|
+
it('intersectScopes(A, null) is equivalent to [A]: matches iff matchesOne(product, A)', () => {
|
|
15
|
+
const channel: AssortmentScope = { categoryIds: ['cat-a'] }
|
|
16
|
+
const result = intersectScopes(channel, null)
|
|
17
|
+
expect(result).toEqual([channel])
|
|
18
|
+
|
|
19
|
+
const rng = mulberry32(11)
|
|
20
|
+
for (let i = 0; i < 100; i += 1) {
|
|
21
|
+
const product = randomProduct(rng, i)
|
|
22
|
+
expect(matchesScope(product, result)).toBe(matchesOne(product, channel))
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('intersectScopes(null, null) is null', () => {
|
|
27
|
+
expect(intersectScopes(null, null)).toBeNull()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('intersectScopes(null, group) returns group unchanged', () => {
|
|
31
|
+
const group: EffectiveAssortmentScope = [{ categoryIds: ['cat-a'] }, { tagIds: ['tag-b'] }]
|
|
32
|
+
expect(intersectScopes(null, group)).toBe(group)
|
|
33
|
+
expect(intersectScopes(null, [])).toEqual([])
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('intersectScopes — distributive law (§11 item 2, property-based)', () => {
|
|
38
|
+
const CASE_COUNT = 5000
|
|
39
|
+
|
|
40
|
+
it('holds exactly for every generated fixture: matchesScope(p, channel ∩ group) === matchesOne(p, channel) && matchesScope(p, group)', () => {
|
|
41
|
+
const rng = mulberry32(202)
|
|
42
|
+
let matchedCases = 0
|
|
43
|
+
for (let i = 0; i < CASE_COUNT; i += 1) {
|
|
44
|
+
const channel = rng() < 0.15 ? null : randomAssortmentScope(rng)
|
|
45
|
+
const sourceCount = Math.floor(rng() * 3)
|
|
46
|
+
const sources = Array.from({ length: sourceCount }, () => randomSource(rng))
|
|
47
|
+
const group = unionScopes(sources)
|
|
48
|
+
const product = randomProduct(rng, i)
|
|
49
|
+
|
|
50
|
+
const expected = expectedMatch(product, channel, group)
|
|
51
|
+
expect(matchesScope(product, intersectScopes(channel, group))).toBe(expected)
|
|
52
|
+
if (expected) matchedCases += 1
|
|
53
|
+
}
|
|
54
|
+
expect(matchedCases).toBeGreaterThan(CASE_COUNT / 20)
|
|
55
|
+
expect(matchedCases).toBeLessThan(CASE_COUNT)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('stays exact when composed repeatedly (branches that already carry allOf)', () => {
|
|
59
|
+
const rng = mulberry32(303)
|
|
60
|
+
for (let i = 0; i < CASE_COUNT; i += 1) {
|
|
61
|
+
const outer = rng() < 0.15 ? null : randomAssortmentScope(rng)
|
|
62
|
+
const inner = rng() < 0.15 ? null : randomAssortmentScope(rng)
|
|
63
|
+
const sourceCount = Math.floor(rng() * 3)
|
|
64
|
+
const group = unionScopes(Array.from({ length: sourceCount }, () => randomSource(rng)))
|
|
65
|
+
const product = randomProduct(rng, i)
|
|
66
|
+
|
|
67
|
+
const composed = intersectScopes(outer, intersectScopes(inner, group))
|
|
68
|
+
const expected = expectedMatch(product, outer, group) && (inner === null || matchesOne(product, inner))
|
|
69
|
+
expect(matchesScope(product, composed)).toBe(expected)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('never drops a branch: the result is [] only when the group itself is []', () => {
|
|
74
|
+
const rng = mulberry32(404)
|
|
75
|
+
for (let i = 0; i < CASE_COUNT; i += 1) {
|
|
76
|
+
const channel = randomAssortmentScope(rng)
|
|
77
|
+
const sourceCount = Math.floor(rng() * 3)
|
|
78
|
+
const group = unionScopes(Array.from({ length: sourceCount }, () => randomSource(rng)))
|
|
79
|
+
const result = intersectScopes(channel, group)
|
|
80
|
+
if (group === null) {
|
|
81
|
+
expect(result).toEqual([channel])
|
|
82
|
+
} else {
|
|
83
|
+
expect(result).toHaveLength(group.length)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
expect(intersectScopes({ categoryIds: ['cat-a'] }, [])).toEqual([])
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('intersectScopes — incomparable category/tag sets are ANDed exactly via allOf', () => {
|
|
91
|
+
it('regression: channel [Electronics] ∩ group [Clearance] grants a product in both categories', () => {
|
|
92
|
+
const channel: AssortmentScope = { categoryIds: ['electronics'] }
|
|
93
|
+
const group = unionScopes([{ categoryIds: ['clearance'] }])
|
|
94
|
+
const result = intersectScopes(channel, group)
|
|
95
|
+
|
|
96
|
+
expect(result).toEqual([{ categoryIds: ['electronics'], allOf: [{ categoryIds: ['clearance'] }] }])
|
|
97
|
+
|
|
98
|
+
const inBoth: ScopedProduct = { id: 'tv', categoryIds: ['electronics', 'clearance'], tagIds: [] }
|
|
99
|
+
const electronicsOnly: ScopedProduct = { id: 'phone', categoryIds: ['electronics'], tagIds: [] }
|
|
100
|
+
const clearanceOnly: ScopedProduct = { id: 'sofa', categoryIds: ['clearance'], tagIds: [] }
|
|
101
|
+
expect(matchesScope(inBoth, result)).toBe(true)
|
|
102
|
+
expect(matchesScope(electronicsOnly, result)).toBe(false)
|
|
103
|
+
expect(matchesScope(clearanceOnly, result)).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('a product satisfying channel and group via two different, non-overlapping tag ids is granted', () => {
|
|
107
|
+
const channel: AssortmentScope = { tagIds: ['channel-only'] }
|
|
108
|
+
const group = unionScopes([{ tagIds: ['group-only'] }])
|
|
109
|
+
const product: ScopedProduct = { id: 'p', categoryIds: [], tagIds: ['channel-only', 'group-only'] }
|
|
110
|
+
expect(matchesScope(product, intersectScopes(channel, group))).toBe(true)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('a product satisfying only one side is denied', () => {
|
|
114
|
+
const channel: AssortmentScope = { tagIds: ['channel-only'] }
|
|
115
|
+
const group = unionScopes([{ tagIds: ['group-only'] }])
|
|
116
|
+
const productChannelOnly: ScopedProduct = { id: 'p', categoryIds: [], tagIds: ['channel-only'] }
|
|
117
|
+
expect(matchesScope(productChannelOnly, intersectScopes(channel, group))).toBe(false)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('overlapping but non-subset sets match through different ids on each side', () => {
|
|
121
|
+
const channel: AssortmentScope = { tagIds: ['t2', 't4', 't5'] }
|
|
122
|
+
const group = unionScopes([{ tagIds: ['t2', 't3'] }])
|
|
123
|
+
const product: ScopedProduct = { id: 'p2', categoryIds: [], tagIds: ['t3', 't4'] }
|
|
124
|
+
expect(matchesScope(product, intersectScopes(channel, group))).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('keeps the other dimension and excludes merged flat alongside the allOf residual', () => {
|
|
128
|
+
const channel: AssortmentScope = { categoryIds: ['cat-a'], tagIds: ['tag-a'], excludeProductIds: ['p-x'] }
|
|
129
|
+
const group = unionScopes([{ categoryIds: ['cat-b'], excludeTagIds: ['tag-z'] }])
|
|
130
|
+
expect(intersectScopes(channel, group)).toEqual([
|
|
131
|
+
{
|
|
132
|
+
categoryIds: ['cat-a'],
|
|
133
|
+
tagIds: ['tag-a'],
|
|
134
|
+
excludeProductIds: ['p-x'],
|
|
135
|
+
excludeTagIds: ['tag-z'],
|
|
136
|
+
allOf: [{ categoryIds: ['cat-b'] }],
|
|
137
|
+
},
|
|
138
|
+
])
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('intersectScopes — exact flat merges', () => {
|
|
143
|
+
it('one side unrestricted on a dimension: the other side wins unmodified', () => {
|
|
144
|
+
const channel: AssortmentScope = { categoryIds: ['cat-a'] }
|
|
145
|
+
const group = unionScopes([{ tagIds: ['tag-a'] }])
|
|
146
|
+
const result = intersectScopes(channel, group)
|
|
147
|
+
expect(result).toEqual([{ categoryIds: ['cat-a'], tagIds: ['tag-a'] }])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('one set is a subset of the other: the subset wins, exactly', () => {
|
|
151
|
+
const channel: AssortmentScope = { categoryIds: ['cat-a', 'cat-b'] }
|
|
152
|
+
const group = unionScopes([{ categoryIds: ['cat-a'] }])
|
|
153
|
+
const result = intersectScopes(channel, group)
|
|
154
|
+
expect(result).toEqual([{ categoryIds: ['cat-a'] }])
|
|
155
|
+
|
|
156
|
+
const productWithOnlyCatA: ScopedProduct = { id: 'p', categoryIds: ['cat-a'], tagIds: [] }
|
|
157
|
+
const productWithOnlyCatB: ScopedProduct = { id: 'p2', categoryIds: ['cat-b'], tagIds: [] }
|
|
158
|
+
expect(matchesScope(productWithOnlyCatA, result)).toBe(true)
|
|
159
|
+
expect(matchesScope(productWithOnlyCatB, result)).toBe(false)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('excludes always union exactly', () => {
|
|
163
|
+
const channel: AssortmentScope = { excludeCategoryIds: ['cat-x'] }
|
|
164
|
+
const group = unionScopes([{ excludeTagIds: ['tag-y'] }])
|
|
165
|
+
const result = intersectScopes(channel, group)
|
|
166
|
+
expect(result).toEqual([{ excludeCategoryIds: ['cat-x'], excludeTagIds: ['tag-y'] }])
|
|
167
|
+
})
|
|
168
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { matchesOne } from '../matchesOne'
|
|
2
|
+
import type { ScopedProduct } from '../types'
|
|
3
|
+
|
|
4
|
+
const product = (overrides: Partial<ScopedProduct> = {}): ScopedProduct => ({
|
|
5
|
+
id: 'product-1',
|
|
6
|
+
categoryIds: ['cat-a'],
|
|
7
|
+
tagIds: ['tag-a'],
|
|
8
|
+
...overrides,
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe('matchesOne — within-one-scope AND/OR semantics (§11 item 6)', () => {
|
|
12
|
+
it('categoryIds OR-matches: any overlapping id is enough', () => {
|
|
13
|
+
expect(matchesOne(product({ categoryIds: ['cat-a', 'cat-b'] }), { categoryIds: ['cat-b', 'cat-c'] })).toBe(true)
|
|
14
|
+
expect(matchesOne(product({ categoryIds: ['cat-x'] }), { categoryIds: ['cat-b', 'cat-c'] })).toBe(false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('tagIds OR-matches: any overlapping id is enough', () => {
|
|
18
|
+
expect(matchesOne(product({ tagIds: ['tag-a', 'tag-b'] }), { tagIds: ['tag-b', 'tag-c'] })).toBe(true)
|
|
19
|
+
expect(matchesOne(product({ tagIds: ['tag-x'] }), { tagIds: ['tag-b', 'tag-c'] })).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('categoryIds AND tagIds both required when both are present on the scope', () => {
|
|
23
|
+
const scope = { categoryIds: ['cat-a'], tagIds: ['tag-a'] }
|
|
24
|
+
expect(matchesOne(product({ categoryIds: ['cat-a'], tagIds: ['tag-a'] }), scope)).toBe(true)
|
|
25
|
+
expect(matchesOne(product({ categoryIds: ['cat-a'], tagIds: ['tag-z'] }), scope)).toBe(false)
|
|
26
|
+
expect(matchesOne(product({ categoryIds: ['cat-z'], tagIds: ['tag-a'] }), scope)).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('excludeProductIds vetoes a match already granted by inclusion', () => {
|
|
30
|
+
const scope = { categoryIds: ['cat-a'], excludeProductIds: ['product-1'] }
|
|
31
|
+
expect(matchesOne(product({ id: 'product-1', categoryIds: ['cat-a'] }), scope)).toBe(false)
|
|
32
|
+
expect(matchesOne(product({ id: 'product-2', categoryIds: ['cat-a'] }), scope)).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('excludeCategoryIds vetoes a match already granted by inclusion', () => {
|
|
36
|
+
const scope = { tagIds: ['tag-a'], excludeCategoryIds: ['cat-x'] }
|
|
37
|
+
expect(matchesOne(product({ tagIds: ['tag-a'], categoryIds: ['cat-x'] }), scope)).toBe(false)
|
|
38
|
+
expect(matchesOne(product({ tagIds: ['tag-a'], categoryIds: ['cat-y'] }), scope)).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('excludeTagIds vetoes a match already granted by inclusion', () => {
|
|
42
|
+
const scope = { categoryIds: ['cat-a'], excludeTagIds: ['tag-x'] }
|
|
43
|
+
expect(matchesOne(product({ categoryIds: ['cat-a'], tagIds: ['tag-x'] }), scope)).toBe(false)
|
|
44
|
+
expect(matchesOne(product({ categoryIds: ['cat-a'], tagIds: ['tag-y'] }), scope)).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('excludes combine independently — each can veto on its own', () => {
|
|
48
|
+
const scope = {
|
|
49
|
+
excludeProductIds: ['product-1'],
|
|
50
|
+
excludeCategoryIds: ['cat-x'],
|
|
51
|
+
excludeTagIds: ['tag-x'],
|
|
52
|
+
}
|
|
53
|
+
expect(matchesOne(product({ id: 'other', categoryIds: ['cat-y'], tagIds: ['tag-y'] }), scope)).toBe(true)
|
|
54
|
+
expect(matchesOne(product({ id: 'product-1', categoryIds: ['cat-y'], tagIds: ['tag-y'] }), scope)).toBe(false)
|
|
55
|
+
expect(matchesOne(product({ id: 'other', categoryIds: ['cat-x'], tagIds: ['tag-y'] }), scope)).toBe(false)
|
|
56
|
+
expect(matchesOne(product({ id: 'other', categoryIds: ['cat-y'], tagIds: ['tag-x'] }), scope)).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('matchesOne — empty array and absent key equivalence (§11 item 7)', () => {
|
|
61
|
+
it('absent categoryIds and empty categoryIds both mean unrestricted', () => {
|
|
62
|
+
const p = product({ categoryIds: ['anything'] })
|
|
63
|
+
expect(matchesOne(p, {})).toBe(true)
|
|
64
|
+
expect(matchesOne(p, { categoryIds: [] })).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('absent tagIds and empty tagIds both mean unrestricted', () => {
|
|
68
|
+
const p = product({ tagIds: ['anything'] })
|
|
69
|
+
expect(matchesOne(p, {})).toBe(true)
|
|
70
|
+
expect(matchesOne(p, { tagIds: [] })).toBe(true)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('absent excludeProductIds and empty excludeProductIds both mean no veto', () => {
|
|
74
|
+
const p = product({ id: 'product-1' })
|
|
75
|
+
expect(matchesOne(p, {})).toBe(true)
|
|
76
|
+
expect(matchesOne(p, { excludeProductIds: [] })).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('absent excludeCategoryIds and empty excludeCategoryIds both mean no veto', () => {
|
|
80
|
+
const p = product({ categoryIds: ['cat-a'] })
|
|
81
|
+
expect(matchesOne(p, {})).toBe(true)
|
|
82
|
+
expect(matchesOne(p, { excludeCategoryIds: [] })).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('absent excludeTagIds and empty excludeTagIds both mean no veto', () => {
|
|
86
|
+
const p = product({ tagIds: ['tag-a'] })
|
|
87
|
+
expect(matchesOne(p, {})).toBe(true)
|
|
88
|
+
expect(matchesOne(p, { excludeTagIds: [] })).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('a fully empty scope object matches every product', () => {
|
|
92
|
+
expect(matchesOne(product({ categoryIds: [], tagIds: [] }), {})).toBe(true)
|
|
93
|
+
expect(matchesOne(product({ categoryIds: ['x', 'y'], tagIds: ['z'] }), {})).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('matchesOne — allOf conjunction', () => {
|
|
98
|
+
it('requires every allOf scope to match in addition to the scope itself', () => {
|
|
99
|
+
const scope = { categoryIds: ['cat-a'], allOf: [{ categoryIds: ['cat-b'] }, { tagIds: ['tag-a'] }] }
|
|
100
|
+
expect(matchesOne(product({ categoryIds: ['cat-a', 'cat-b'], tagIds: ['tag-a'] }), scope)).toBe(true)
|
|
101
|
+
expect(matchesOne(product({ categoryIds: ['cat-a'], tagIds: ['tag-a'] }), scope)).toBe(false)
|
|
102
|
+
expect(matchesOne(product({ categoryIds: ['cat-a', 'cat-b'], tagIds: ['tag-z'] }), scope)).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('an allOf entry can veto through its own excludes, including nested allOf', () => {
|
|
106
|
+
const scope = { allOf: [{ allOf: [{ excludeProductIds: ['product-1'] }] }] }
|
|
107
|
+
expect(matchesOne(product({ id: 'product-1' }), scope)).toBe(false)
|
|
108
|
+
expect(matchesOne(product({ id: 'product-2' }), scope)).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('absent and empty allOf both add no condition', () => {
|
|
112
|
+
const p = product()
|
|
113
|
+
expect(matchesOne(p, {})).toBe(true)
|
|
114
|
+
expect(matchesOne(p, { allOf: [] })).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { matchesScope } from '../matchesScope'
|
|
2
|
+
import type { ScopedProduct } from '../types'
|
|
3
|
+
import { CATEGORY_UNIVERSE, mulberry32, randomProduct } from './testFixtures'
|
|
4
|
+
|
|
5
|
+
describe('matchesScope — list-level OR and the null/[] distinction (§11 item 5)', () => {
|
|
6
|
+
it('null means unrestricted: matches every product', () => {
|
|
7
|
+
const rng = mulberry32(1)
|
|
8
|
+
for (let i = 0; i < 50; i += 1) {
|
|
9
|
+
expect(matchesScope(randomProduct(rng, i), null)).toBe(true)
|
|
10
|
+
}
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('[] (empty list) means deny-all: matches no product, for many varied products', () => {
|
|
14
|
+
const rng = mulberry32(2)
|
|
15
|
+
for (let i = 0; i < 200; i += 1) {
|
|
16
|
+
expect(matchesScope(randomProduct(rng, i), [])).toBe(false)
|
|
17
|
+
}
|
|
18
|
+
const emptyProduct: ScopedProduct = { id: 'empty', categoryIds: [], tagIds: [] }
|
|
19
|
+
expect(matchesScope(emptyProduct, [])).toBe(false)
|
|
20
|
+
const fullProduct: ScopedProduct = { id: 'full', categoryIds: [...CATEGORY_UNIVERSE], tagIds: [] }
|
|
21
|
+
expect(matchesScope(fullProduct, [])).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('matches if ANY branch matches (OR across the DNF list)', () => {
|
|
25
|
+
const product: ScopedProduct = { id: 'p', categoryIds: ['cat-b'], tagIds: [] }
|
|
26
|
+
const effective = [{ categoryIds: ['cat-a'] }, { categoryIds: ['cat-b'] }, { categoryIds: ['cat-c'] }]
|
|
27
|
+
expect(matchesScope(product, effective)).toBe(true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('matches nothing when no branch matches', () => {
|
|
31
|
+
const product: ScopedProduct = { id: 'p', categoryIds: ['cat-z'], tagIds: [] }
|
|
32
|
+
const effective = [{ categoryIds: ['cat-a'] }, { categoryIds: ['cat-b'] }]
|
|
33
|
+
expect(matchesScope(product, effective)).toBe(false)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { AssortmentScope, ScopedProduct } from '../types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic PRNG (mulberry32) so property-based runs are reproducible across CI runs
|
|
5
|
+
* without pulling in a third-party dependency — no `fast-check`-equivalent library is
|
|
6
|
+
* present anywhere in this monorepo's workspaces (checked via `yarn why fast-check` /
|
|
7
|
+
* grepping every `package.json`), so this hand-rolled generator is the documented fallback
|
|
8
|
+
* per `.ai/specs/2026-04-24-agentic-property-based-testing.md`.
|
|
9
|
+
*/
|
|
10
|
+
export function mulberry32(seed: number): () => number {
|
|
11
|
+
let state = seed | 0
|
|
12
|
+
return function next(): number {
|
|
13
|
+
state = (state + 0x6d2b79f5) | 0
|
|
14
|
+
let t = Math.imul(state ^ (state >>> 15), 1 | state)
|
|
15
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
16
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const CATEGORY_UNIVERSE = ['c1', 'c2', 'c3', 'c4', 'c5']
|
|
21
|
+
export const TAG_UNIVERSE = ['t1', 't2', 't3', 't4', 't5']
|
|
22
|
+
export const PRODUCT_ID_UNIVERSE = ['p1', 'p2', 'p3', 'p4', 'p5']
|
|
23
|
+
|
|
24
|
+
function randomSubset(rng: () => number, universe: string[], inclusionRate = 0.4): string[] {
|
|
25
|
+
return universe.filter(() => rng() < inclusionRate)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Generates a random `AssortmentScope | null` "source" (as `unionScopes` consumes). */
|
|
29
|
+
export function randomSource(rng: () => number): AssortmentScope | null {
|
|
30
|
+
if (rng() < 0.2) return null
|
|
31
|
+
const scope: AssortmentScope = {}
|
|
32
|
+
if (rng() < 0.6) {
|
|
33
|
+
const categoryIds = randomSubset(rng, CATEGORY_UNIVERSE)
|
|
34
|
+
if (categoryIds.length > 0) scope.categoryIds = categoryIds
|
|
35
|
+
}
|
|
36
|
+
if (rng() < 0.6) {
|
|
37
|
+
const tagIds = randomSubset(rng, TAG_UNIVERSE)
|
|
38
|
+
if (tagIds.length > 0) scope.tagIds = tagIds
|
|
39
|
+
}
|
|
40
|
+
if (rng() < 0.2) {
|
|
41
|
+
const excludeProductIds = randomSubset(rng, PRODUCT_ID_UNIVERSE)
|
|
42
|
+
if (excludeProductIds.length > 0) scope.excludeProductIds = excludeProductIds
|
|
43
|
+
}
|
|
44
|
+
if (rng() < 0.2) {
|
|
45
|
+
const excludeCategoryIds = randomSubset(rng, CATEGORY_UNIVERSE)
|
|
46
|
+
if (excludeCategoryIds.length > 0) scope.excludeCategoryIds = excludeCategoryIds
|
|
47
|
+
}
|
|
48
|
+
if (rng() < 0.2) {
|
|
49
|
+
const excludeTagIds = randomSubset(rng, TAG_UNIVERSE)
|
|
50
|
+
if (excludeTagIds.length > 0) scope.excludeTagIds = excludeTagIds
|
|
51
|
+
}
|
|
52
|
+
return scope
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Generates a random `AssortmentScope` (never `null`) for use as a channel scope. */
|
|
56
|
+
export function randomAssortmentScope(rng: () => number): AssortmentScope {
|
|
57
|
+
return (randomSource(rng) ?? {}) as AssortmentScope
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function randomProduct(rng: () => number, index: number): ScopedProduct {
|
|
61
|
+
return {
|
|
62
|
+
id: PRODUCT_ID_UNIVERSE[index % PRODUCT_ID_UNIVERSE.length],
|
|
63
|
+
categoryIds: randomSubset(rng, CATEGORY_UNIVERSE),
|
|
64
|
+
tagIds: randomSubset(rng, TAG_UNIVERSE),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { unionScopes } from '../unionScopes'
|
|
2
|
+
import { matchesScope } from '../matchesScope'
|
|
3
|
+
import type { ScopedProduct } from '../types'
|
|
4
|
+
|
|
5
|
+
describe('unionScopes — R2 regression: union is OR across dimensions, not flattened AND', () => {
|
|
6
|
+
it('keeps each source as its own unmodified OR-branch, exact shape', () => {
|
|
7
|
+
const result = unionScopes([{ categoryIds: ['A'] }, { tagIds: ['B'] }])
|
|
8
|
+
expect(result).toEqual([{ categoryIds: ['A'] }, { tagIds: ['B'] }])
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('a product in category A but NOT tagged B is granted by the category-A branch alone', () => {
|
|
12
|
+
const result = unionScopes([{ categoryIds: ['A'] }, { tagIds: ['B'] }])
|
|
13
|
+
const product: ScopedProduct = { id: 'p1', categoryIds: ['A'], tagIds: [] }
|
|
14
|
+
expect(matchesScope(product, result)).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('a product tagged B but NOT in category A is granted by the tag-B branch alone', () => {
|
|
18
|
+
const result = unionScopes([{ categoryIds: ['A'] }, { tagIds: ['B'] }])
|
|
19
|
+
const product: ScopedProduct = { id: 'p2', categoryIds: [], tagIds: ['B'] }
|
|
20
|
+
expect(matchesScope(product, result)).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('a product with neither category A nor tag B is granted by neither branch', () => {
|
|
24
|
+
const result = unionScopes([{ categoryIds: ['A'] }, { tagIds: ['B'] }])
|
|
25
|
+
const product: ScopedProduct = { id: 'p3', categoryIds: ['Z'], tagIds: ['Y'] }
|
|
26
|
+
expect(matchesScope(product, result)).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('the naive flattened-AND mistake would have wrongly rejected the category-A-only product', () => {
|
|
30
|
+
// Documented as a regression guard: the first draft merged sources into one flat
|
|
31
|
+
// AssortmentScope ({ categoryIds: ['A'], tagIds: ['B'] }), which AND's the two
|
|
32
|
+
// dimensions together instead of OR-ing the two sources. Assert that shape is NOT
|
|
33
|
+
// what unionScopes produces, and that evaluating against it would give the wrong answer.
|
|
34
|
+
const flattenedWrong = { categoryIds: ['A'], tagIds: ['B'] }
|
|
35
|
+
const product: ScopedProduct = { id: 'p1', categoryIds: ['A'], tagIds: [] }
|
|
36
|
+
expect(matchesScope(product, [flattenedWrong])).toBe(false) // the bug: wrongly denied
|
|
37
|
+
expect(matchesScope(product, unionScopes([{ categoryIds: ['A'] }, { tagIds: ['B'] }]))).toBe(true) // the fix
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('unionScopes — null propagation (§11 item 3)', () => {
|
|
42
|
+
it('any unrestricted (null) source makes the whole union unrestricted', () => {
|
|
43
|
+
expect(unionScopes([null, { categoryIds: ['A'] }])).toBeNull()
|
|
44
|
+
expect(unionScopes([{ categoryIds: ['A'] }, null])).toBeNull()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('no sources at all is unrestricted', () => {
|
|
48
|
+
expect(unionScopes([])).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('multiple non-null sources with no null among them stay a DNF list', () => {
|
|
52
|
+
const result = unionScopes([{ categoryIds: ['A'] }, { categoryIds: ['B'] }])
|
|
53
|
+
expect(result).toEqual([{ categoryIds: ['A'] }, { categoryIds: ['B'] }])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("returns a fresh array, never the caller's own list", () => {
|
|
57
|
+
const input = [{ categoryIds: ['A'] }, { tagIds: ['B'] }]
|
|
58
|
+
const result = unionScopes(input)
|
|
59
|
+
expect(result).not.toBe(input)
|
|
60
|
+
expect(result).toEqual(input)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { AssortmentScope, EffectiveAssortmentScope, ScopedProduct } from './types'
|
|
2
|
+
export { matchesOne } from './matchesOne'
|
|
3
|
+
export { matchesScope } from './matchesScope'
|
|
4
|
+
export { unionScopes } from './unionScopes'
|
|
5
|
+
export { intersectScopes } from './intersectScopes'
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { AssortmentScope, EffectiveAssortmentScope } from './types'
|
|
2
|
+
|
|
3
|
+
type FieldCombination = { ids: string[] | undefined; residual: string[] | undefined }
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Combines one dimension (categoryIds or tagIds) from two independent AND-scopes. Every
|
|
7
|
+
* outcome is exact — `overlap(P, ids) && overlap(P, residual)` equals
|
|
8
|
+
* `overlap(P, channelIds) && overlap(P, groupIds)` for every product:
|
|
9
|
+
*
|
|
10
|
+
* - Both absent/empty -> unrestricted (`undefined`).
|
|
11
|
+
* - Only one side restricts -> that side's set, unmodified (the other side is vacuously true).
|
|
12
|
+
* - Both restrict and one set is a subset of the other (including equal sets) -> the subset,
|
|
13
|
+
* because overlapping the subset already implies overlapping the superset.
|
|
14
|
+
* - Both restrict with incomparable sets -> the channel's set stays on the branch and the
|
|
15
|
+
* group's set is returned as `residual`, to be ANDed via `allOf`. Set-intersecting the two
|
|
16
|
+
* would wrongly deny a product that satisfies each side through a different id (e.g. a
|
|
17
|
+
* product in both Electronics and Clearance under channel [Electronics] ∩ group [Clearance]).
|
|
18
|
+
*/
|
|
19
|
+
function combineField(channelIds: string[] | undefined, groupIds: string[] | undefined): FieldCombination {
|
|
20
|
+
const a = channelIds && channelIds.length > 0 ? channelIds : undefined
|
|
21
|
+
const b = groupIds && groupIds.length > 0 ? groupIds : undefined
|
|
22
|
+
if (!a && !b) return { ids: undefined, residual: undefined }
|
|
23
|
+
if (!a) return { ids: b, residual: undefined }
|
|
24
|
+
if (!b) return { ids: a, residual: undefined }
|
|
25
|
+
const bSet = new Set(b)
|
|
26
|
+
if (a.every((id) => bSet.has(id))) return { ids: a, residual: undefined }
|
|
27
|
+
const aSet = new Set(a)
|
|
28
|
+
if (b.every((id) => aSet.has(id))) return { ids: b, residual: undefined }
|
|
29
|
+
return { ids: a, residual: b }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Excludes are always safe to union: `NOT excluded_a AND NOT excluded_b` is exactly
|
|
34
|
+
* `NOT (excluded_a OR excluded_b)`, i.e. avoiding the union of both exclude sets.
|
|
35
|
+
*/
|
|
36
|
+
function unionIds(a: string[] | undefined, b: string[] | undefined): string[] | undefined {
|
|
37
|
+
const merged = [...(a ?? []), ...(b ?? [])]
|
|
38
|
+
if (merged.length === 0) return undefined
|
|
39
|
+
return Array.from(new Set(merged))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Builds one branch equivalent to "matches `channel` AND matches `group`". Dimensions and
|
|
44
|
+
* excludes are merged into flat fields where that is exact; whatever cannot be merged
|
|
45
|
+
* exactly (incomparable inclusion sets, and any `allOf` either side already carries) is
|
|
46
|
+
* kept as `allOf` entries, which `matchesOne` ANDs at match time. Never returns an
|
|
47
|
+
* unsatisfiable-looking or dropped branch.
|
|
48
|
+
*/
|
|
49
|
+
function mergeAsConjunction(channel: AssortmentScope, group: AssortmentScope): AssortmentScope {
|
|
50
|
+
const categoryCombination = combineField(channel.categoryIds, group.categoryIds)
|
|
51
|
+
const tagCombination = combineField(channel.tagIds, group.tagIds)
|
|
52
|
+
|
|
53
|
+
const merged: AssortmentScope = {}
|
|
54
|
+
if (categoryCombination.ids) merged.categoryIds = categoryCombination.ids
|
|
55
|
+
if (tagCombination.ids) merged.tagIds = tagCombination.ids
|
|
56
|
+
|
|
57
|
+
const excludeProductIds = unionIds(channel.excludeProductIds, group.excludeProductIds)
|
|
58
|
+
if (excludeProductIds) merged.excludeProductIds = excludeProductIds
|
|
59
|
+
const excludeCategoryIds = unionIds(channel.excludeCategoryIds, group.excludeCategoryIds)
|
|
60
|
+
if (excludeCategoryIds) merged.excludeCategoryIds = excludeCategoryIds
|
|
61
|
+
const excludeTagIds = unionIds(channel.excludeTagIds, group.excludeTagIds)
|
|
62
|
+
if (excludeTagIds) merged.excludeTagIds = excludeTagIds
|
|
63
|
+
|
|
64
|
+
const residual: AssortmentScope = {}
|
|
65
|
+
if (categoryCombination.residual) residual.categoryIds = categoryCombination.residual
|
|
66
|
+
if (tagCombination.residual) residual.tagIds = tagCombination.residual
|
|
67
|
+
|
|
68
|
+
const allOf: AssortmentScope[] = [
|
|
69
|
+
...(Object.keys(residual).length > 0 ? [residual] : []),
|
|
70
|
+
...(channel.allOf ?? []),
|
|
71
|
+
...(group.allOf ?? []),
|
|
72
|
+
]
|
|
73
|
+
if (allOf.length > 0) merged.allOf = allOf
|
|
74
|
+
|
|
75
|
+
return merged
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Distributes a single channel-level scope across every branch of an already-unioned
|
|
80
|
+
* group-level effective scope: (channel) ∩ (g1 ∪ g2 ∪ ... ∪ gn) = (channel∩g1) ∪ (channel∩g2) ∪ ...
|
|
81
|
+
* Each branch is exact by construction (spec §3.3 / §11):
|
|
82
|
+
* `matchesScope(p, intersectScopes(channel, group)) === (channel === null || matchesOne(p, channel)) && matchesScope(p, group)`
|
|
83
|
+
* for every product. See `mergeAsConjunction` for how a branch carries both conditions.
|
|
84
|
+
*
|
|
85
|
+
* Branches are never dropped, so the result is `[]` (deny-all) only when `group` is `[]`.
|
|
86
|
+
* Merging raw category/tag arrays by plain union or intersection is wrong for the same
|
|
87
|
+
* structural reason flattening the union was wrong (R2) — do not reintroduce it.
|
|
88
|
+
*/
|
|
89
|
+
export function intersectScopes(channel: AssortmentScope | null, group: EffectiveAssortmentScope): EffectiveAssortmentScope {
|
|
90
|
+
if (channel === null) return group
|
|
91
|
+
if (group === null) return [channel]
|
|
92
|
+
return group.map((branch) => mergeAsConjunction(channel, branch))
|
|
93
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AssortmentScope, ScopedProduct } from './types'
|
|
2
|
+
|
|
3
|
+
function overlaps(productIds: string[], scopeIds: string[]): boolean {
|
|
4
|
+
if (productIds.length === 0 || scopeIds.length === 0) return false
|
|
5
|
+
const set = new Set(scopeIds)
|
|
6
|
+
return productIds.some((id) => set.has(id))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function included(product: ScopedProduct, scope: AssortmentScope): boolean {
|
|
10
|
+
const categoryIds = scope.categoryIds ?? []
|
|
11
|
+
const tagIds = scope.tagIds ?? []
|
|
12
|
+
const categoryOk = categoryIds.length === 0 || overlaps(product.categoryIds, categoryIds)
|
|
13
|
+
const tagOk = tagIds.length === 0 || overlaps(product.tagIds, tagIds)
|
|
14
|
+
return categoryOk && tagOk
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** AND match of one scope, including every nested `allOf` scope. */
|
|
18
|
+
export function matchesOne(product: ScopedProduct, scope: AssortmentScope): boolean {
|
|
19
|
+
if (!included(product, scope)) return false
|
|
20
|
+
const excludeProductIds = scope.excludeProductIds ?? []
|
|
21
|
+
if (excludeProductIds.includes(product.id)) return false
|
|
22
|
+
const excludeCategoryIds = scope.excludeCategoryIds ?? []
|
|
23
|
+
if (excludeCategoryIds.length > 0 && overlaps(product.categoryIds, excludeCategoryIds)) return false
|
|
24
|
+
const excludeTagIds = scope.excludeTagIds ?? []
|
|
25
|
+
if (excludeTagIds.length > 0 && overlaps(product.tagIds, excludeTagIds)) return false
|
|
26
|
+
const allOf = scope.allOf ?? []
|
|
27
|
+
return allOf.every((nested) => matchesOne(product, nested))
|
|
28
|
+
}
|