@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.
Files changed (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -0
  3. package/dist/lib/auth/jwt.js +4 -0
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/organizationScope.js +15 -1
  6. package/dist/lib/auth/organizationScope.js.map +2 -2
  7. package/dist/lib/availability/catalogOnlyProvider.js +77 -0
  8. package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
  9. package/dist/lib/availability/index.js +8 -0
  10. package/dist/lib/availability/index.js.map +7 -0
  11. package/dist/lib/availability/registry.js +47 -0
  12. package/dist/lib/availability/registry.js.map +7 -0
  13. package/dist/lib/availability/types.js +7 -0
  14. package/dist/lib/availability/types.js.map +7 -0
  15. package/dist/lib/catalog-visibility/index.js +11 -0
  16. package/dist/lib/catalog-visibility/index.js.map +7 -0
  17. package/dist/lib/catalog-visibility/intersectScopes.js +49 -0
  18. package/dist/lib/catalog-visibility/intersectScopes.js.map +7 -0
  19. package/dist/lib/catalog-visibility/matchesOne.js +27 -0
  20. package/dist/lib/catalog-visibility/matchesOne.js.map +7 -0
  21. package/dist/lib/catalog-visibility/matchesScope.js +9 -0
  22. package/dist/lib/catalog-visibility/matchesScope.js.map +7 -0
  23. package/dist/lib/catalog-visibility/types.js +1 -0
  24. package/dist/lib/catalog-visibility/types.js.map +7 -0
  25. package/dist/lib/catalog-visibility/unionScopes.js +9 -0
  26. package/dist/lib/catalog-visibility/unionScopes.js.map +7 -0
  27. package/dist/lib/commands/command-bus.js +383 -125
  28. package/dist/lib/commands/command-bus.js.map +3 -3
  29. package/dist/lib/commands/flush.js +16 -0
  30. package/dist/lib/commands/flush.js.map +2 -2
  31. package/dist/lib/commands/index.js +8 -0
  32. package/dist/lib/commands/index.js.map +2 -2
  33. package/dist/lib/commands/redo.js +2 -7
  34. package/dist/lib/commands/redo.js.map +2 -2
  35. package/dist/lib/commands/transaction-lifetime.js +67 -0
  36. package/dist/lib/commands/transaction-lifetime.js.map +7 -0
  37. package/dist/lib/commands/types.js.map +2 -2
  38. package/dist/lib/commands/undo.js +54 -2
  39. package/dist/lib/commands/undo.js.map +2 -2
  40. package/dist/lib/crud/enricher-runner.js +64 -9
  41. package/dist/lib/crud/enricher-runner.js.map +2 -2
  42. package/dist/lib/crud/factory.js +42 -18
  43. package/dist/lib/crud/factory.js.map +2 -2
  44. package/dist/lib/data/engine.js +12 -3
  45. package/dist/lib/data/engine.js.map +2 -2
  46. package/dist/lib/db/accentInsensitiveSearch.js +21 -0
  47. package/dist/lib/db/accentInsensitiveSearch.js.map +7 -0
  48. package/dist/lib/encryption/kms.js +1 -5
  49. package/dist/lib/encryption/kms.js.map +2 -2
  50. package/dist/lib/encryption/likeFilterWarning.js +3 -1
  51. package/dist/lib/encryption/likeFilterWarning.js.map +2 -2
  52. package/dist/lib/encryption/migration-backfill.js +69 -0
  53. package/dist/lib/encryption/migration-backfill.js.map +7 -0
  54. package/dist/lib/encryption/policyMemo.js +28 -0
  55. package/dist/lib/encryption/policyMemo.js.map +7 -0
  56. package/dist/lib/encryption/subscriber.js +30 -3
  57. package/dist/lib/encryption/subscriber.js.map +2 -2
  58. package/dist/lib/encryption/tenantDataEncryptionService.js +113 -200
  59. package/dist/lib/encryption/tenantDataEncryptionService.js.map +3 -3
  60. package/dist/lib/modules/api-route-identity.js +102 -0
  61. package/dist/lib/modules/api-route-identity.js.map +7 -0
  62. package/dist/lib/telemetry/runtime.js.map +2 -2
  63. package/dist/lib/version.js +1 -1
  64. package/dist/lib/version.js.map +1 -1
  65. package/dist/modules/events/factory.js +20 -0
  66. package/dist/modules/events/factory.js.map +2 -2
  67. package/dist/modules/registry.js +3 -1
  68. package/dist/modules/registry.js.map +2 -2
  69. package/package.json +12 -5
  70. package/src/lib/auth/__tests__/jwt.test.ts +12 -0
  71. package/src/lib/auth/__tests__/organizationScope.test.ts +27 -0
  72. package/src/lib/auth/jwt.ts +9 -0
  73. package/src/lib/auth/organizationScope.ts +39 -0
  74. package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +166 -0
  75. package/src/lib/availability/__tests__/registry.test.ts +130 -0
  76. package/src/lib/availability/catalogOnlyProvider.ts +152 -0
  77. package/src/lib/availability/index.ts +18 -0
  78. package/src/lib/availability/registry.ts +131 -0
  79. package/src/lib/availability/types.ts +86 -0
  80. package/src/lib/catalog-visibility/__tests__/intersectScopes.test.ts +168 -0
  81. package/src/lib/catalog-visibility/__tests__/matchesOne.test.ts +116 -0
  82. package/src/lib/catalog-visibility/__tests__/matchesScope.test.ts +35 -0
  83. package/src/lib/catalog-visibility/__tests__/testFixtures.ts +66 -0
  84. package/src/lib/catalog-visibility/__tests__/unionScopes.test.ts +62 -0
  85. package/src/lib/catalog-visibility/index.ts +5 -0
  86. package/src/lib/catalog-visibility/intersectScopes.ts +93 -0
  87. package/src/lib/catalog-visibility/matchesOne.ts +28 -0
  88. package/src/lib/catalog-visibility/matchesScope.ts +8 -0
  89. package/src/lib/catalog-visibility/types.ts +31 -0
  90. package/src/lib/catalog-visibility/unionScopes.ts +16 -0
  91. package/src/lib/commands/__tests__/command-bus.atomic-replay.test.ts +650 -0
  92. package/src/lib/commands/__tests__/command-bus.test.ts +175 -0
  93. package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +1 -1
  94. package/src/lib/commands/__tests__/redo.test.ts +4 -0
  95. package/src/lib/commands/__tests__/transaction-lifetime.test.ts +118 -0
  96. package/src/lib/commands/__tests__/undo.test.ts +90 -1
  97. package/src/lib/commands/command-bus.ts +463 -148
  98. package/src/lib/commands/flush.ts +16 -0
  99. package/src/lib/commands/index.ts +7 -0
  100. package/src/lib/commands/redo.ts +4 -8
  101. package/src/lib/commands/transaction-lifetime.ts +88 -0
  102. package/src/lib/commands/types.ts +91 -0
  103. package/src/lib/commands/undo.ts +79 -1
  104. package/src/lib/crud/__tests__/crud-factory.cache-tenant-level.test.ts +149 -0
  105. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +48 -6
  106. package/src/lib/crud/__tests__/crud-factory.test.ts +224 -27
  107. package/src/lib/crud/__tests__/enricher-runner.test.ts +270 -5
  108. package/src/lib/crud/enricher-runner.ts +83 -9
  109. package/src/lib/crud/factory.ts +46 -19
  110. package/src/lib/data/__tests__/engine.pin-organization-id.test.ts +102 -0
  111. package/src/lib/data/engine.ts +12 -0
  112. package/src/lib/db/__tests__/accentInsensitiveSearch.test.ts +51 -0
  113. package/src/lib/db/accentInsensitiveSearch.ts +70 -0
  114. package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +11 -9
  115. package/src/lib/encryption/__tests__/fixtures/policy-process-worker.ts +96 -0
  116. package/src/lib/encryption/__tests__/kms.test.ts +45 -3
  117. package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +43 -0
  118. package/src/lib/encryption/__tests__/migration-backfill.test.ts +57 -0
  119. package/src/lib/encryption/__tests__/process-policy-coherence.test.ts +128 -0
  120. package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +66 -1
  121. package/src/lib/encryption/__tests__/subscriber.test.ts +3 -0
  122. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +412 -51
  123. package/src/lib/encryption/kms.ts +1 -6
  124. package/src/lib/encryption/likeFilterWarning.ts +10 -2
  125. package/src/lib/encryption/migration-backfill.ts +90 -0
  126. package/src/lib/encryption/policyMemo.ts +51 -0
  127. package/src/lib/encryption/subscriber.ts +41 -4
  128. package/src/lib/encryption/tenantDataEncryptionService.ts +153 -219
  129. package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +20 -10
  130. package/src/lib/modules/api-route-identity.ts +116 -0
  131. package/src/lib/telemetry/runtime.ts +8 -0
  132. package/src/modules/__tests__/registry.test.ts +92 -0
  133. package/src/modules/events/__tests__/broadcast-coalescing-declaration.test.ts +53 -0
  134. package/src/modules/events/factory.ts +31 -0
  135. package/src/modules/events/types.ts +13 -0
  136. package/src/modules/notifications/types.ts +1 -0
  137. 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
+ }