@open-mercato/shared 0.9.1-develop.7575.1.8480826a9b → 0.10.1-develop.7577.1.477f2486e5
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 +4 -4
- package/dist/lib/auth/server.js +1 -1
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/dynamicLoader.js +46 -6
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/bootstrap/factory.js +4 -3
- package/dist/lib/bootstrap/factory.js.map +2 -2
- package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js +6 -0
- package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js.map +7 -0
- package/dist/lib/bootstrap/next-subpath-resolve-hook.js +62 -0
- package/dist/lib/bootstrap/next-subpath-resolve-hook.js.map +7 -0
- package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js +33 -0
- package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js.map +7 -0
- package/dist/lib/catalog-visibility/hashEffectiveScope.js +9 -0
- package/dist/lib/catalog-visibility/hashEffectiveScope.js.map +7 -0
- package/dist/lib/catalog-visibility/index.js +4 -0
- package/dist/lib/catalog-visibility/index.js.map +2 -2
- package/dist/lib/commands/command-bus.js +45 -13
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/crud/exporters.js +1 -1
- package/dist/lib/crud/exporters.js.map +2 -2
- package/dist/lib/crud/ids.js +13 -1
- package/dist/lib/crud/ids.js.map +2 -2
- package/dist/lib/db/advisoryLock.js +172 -0
- package/dist/lib/db/advisoryLock.js.map +7 -0
- package/dist/lib/db/pg-errors.js +14 -0
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/localization/index.js +9 -1
- package/dist/lib/localization/index.js.map +2 -2
- package/dist/lib/localization/translatable-fields.js +26 -0
- package/dist/lib/localization/translatable-fields.js.map +2 -2
- package/dist/lib/number.js +6 -0
- package/dist/lib/number.js.map +2 -2
- package/dist/lib/openapi/generator.js +84 -67
- package/dist/lib/openapi/generator.js.map +2 -2
- package/dist/lib/query/engine.js +45 -10
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/join-utils.js +1 -1
- package/dist/lib/query/join-utils.js.map +2 -2
- package/dist/lib/query/overlap.js +58 -0
- package/dist/lib/query/overlap.js.map +7 -0
- package/dist/lib/query/types.js.map +2 -2
- package/dist/lib/url.js +24 -0
- package/dist/lib/url.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +7 -3
- package/dist/lib/webhooks/body.js.map +2 -2
- package/dist/lib/webhooks/index.js +2 -0
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/modules/integrations/types.js.map +2 -2
- package/dist/modules/notifications/safeHref.js +20 -0
- package/dist/modules/notifications/safeHref.js.map +7 -0
- package/dist/modules/notifications/types.js +4 -0
- package/dist/modules/notifications/types.js.map +3 -3
- package/dist/modules/payment_gateways/types.js +14 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/search.js.map +2 -2
- package/dist/modules/widgets/injection-loader.js +14 -3
- package/dist/modules/widgets/injection-loader.js.map +2 -2
- package/package.json +10 -3
- package/src/lib/__tests__/number.test.ts +24 -1
- package/src/lib/__tests__/url.cross-site.test.ts +140 -0
- package/src/lib/auth/__tests__/server.test.ts +14 -0
- package/src/lib/auth/server.ts +1 -1
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.appModuleOverrides.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.cacheRecovery.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.externalSubpath.test.ts +141 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/factory.test.ts +18 -6
- package/src/lib/bootstrap/__tests__/next-subpath-resolve-hook.test.ts +174 -0
- package/src/lib/bootstrap/dynamicLoader.ts +63 -5
- package/src/lib/bootstrap/factory.ts +8 -3
- package/src/lib/bootstrap/next-subpath-resolve-hook-worker.ts +3 -0
- package/src/lib/bootstrap/next-subpath-resolve-hook.ts +130 -0
- package/src/lib/bootstrap/types.ts +5 -1
- package/src/lib/catalog-visibility/__tests__/hashEffectiveScope.test.ts +121 -0
- package/src/lib/catalog-visibility/canonicalizeEffectiveScope.ts +41 -0
- package/src/lib/catalog-visibility/hashEffectiveScope.ts +8 -0
- package/src/lib/catalog-visibility/index.ts +2 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +100 -1
- package/src/lib/commands/command-bus.ts +42 -10
- package/src/lib/commands/command-interceptor.ts +4 -0
- package/src/lib/commands/types.ts +14 -0
- package/src/lib/crud/__tests__/exporters.test.ts +18 -0
- package/src/lib/crud/__tests__/ids.test.ts +34 -1
- package/src/lib/crud/exporters.ts +1 -1
- package/src/lib/crud/ids.ts +24 -0
- package/src/lib/db/__tests__/advisoryLock.integration.test.ts +343 -0
- package/src/lib/db/__tests__/advisoryLock.test.ts +564 -0
- package/src/lib/db/__tests__/pg-errors.test.ts +68 -1
- package/src/lib/db/advisoryLock.ts +271 -0
- package/src/lib/db/pg-errors.ts +35 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +23 -0
- package/src/lib/localization/__tests__/translatable-fields.test.ts +17 -0
- package/src/lib/localization/index.ts +8 -1
- package/src/lib/localization/translatable-fields.ts +46 -0
- package/src/lib/number.ts +13 -0
- package/src/lib/openapi/__tests__/optional-path-params.test.ts +102 -0
- package/src/lib/openapi/generator.ts +106 -77
- package/src/lib/query/__tests__/empty-ids-filter.pg.test.ts +89 -0
- package/src/lib/query/__tests__/engine.empty-in-filter.test.ts +79 -0
- package/src/lib/query/__tests__/overlap.test.ts +219 -0
- package/src/lib/query/engine.ts +58 -8
- package/src/lib/query/join-utils.ts +1 -1
- package/src/lib/query/overlap.ts +106 -0
- package/src/lib/query/types.ts +13 -1
- package/src/lib/url.ts +30 -0
- package/src/lib/webhooks/__tests__/body.test.ts +48 -0
- package/src/lib/webhooks/body.ts +11 -4
- package/src/lib/webhooks/index.ts +1 -0
- package/src/modules/integrations/types.ts +6 -0
- package/src/modules/notifications/safeHref.ts +19 -0
- package/src/modules/notifications/types.ts +7 -0
- package/src/modules/payment_gateways/__tests__/types.test.ts +44 -0
- package/src/modules/payment_gateways/types.ts +52 -6
- package/src/modules/registry.ts +5 -0
- package/src/modules/search.ts +39 -0
- package/src/modules/widgets/__tests__/injection-loader.partitioned-registration.test.ts +78 -0
- package/src/modules/widgets/injection-loader.ts +33 -3
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { canonicalizeEffectiveScope } from '../canonicalizeEffectiveScope'
|
|
2
|
+
import { hashEffectiveScope } from '../hashEffectiveScope'
|
|
3
|
+
import type { AssortmentScope, EffectiveAssortmentScope } from '../types'
|
|
4
|
+
import { mulberry32, randomAssortmentScope } from './testFixtures'
|
|
5
|
+
|
|
6
|
+
function shuffle<T>(rng: () => number, items: T[]): T[] {
|
|
7
|
+
const copy = [...items]
|
|
8
|
+
for (let index = copy.length - 1; index > 0; index -= 1) {
|
|
9
|
+
const swap = Math.floor(rng() * (index + 1))
|
|
10
|
+
const held = copy[index]
|
|
11
|
+
copy[index] = copy[swap]
|
|
12
|
+
copy[swap] = held
|
|
13
|
+
}
|
|
14
|
+
return copy
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function shuffleScope(rng: () => number, scope: AssortmentScope): AssortmentScope {
|
|
18
|
+
const entries = shuffle(rng, Object.entries(scope)).map(([key, value]) => {
|
|
19
|
+
if (key === 'allOf') {
|
|
20
|
+
return [key, shuffle(rng, (value as AssortmentScope[]).map((nested) => shuffleScope(rng, nested)))]
|
|
21
|
+
}
|
|
22
|
+
return [key, shuffle(rng, value as string[])]
|
|
23
|
+
})
|
|
24
|
+
return Object.fromEntries(entries) as AssortmentScope
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function randomEffective(rng: () => number): EffectiveAssortmentScope {
|
|
28
|
+
if (rng() < 0.1) return null
|
|
29
|
+
const branches: AssortmentScope[] = []
|
|
30
|
+
const count = Math.floor(rng() * 4)
|
|
31
|
+
for (let index = 0; index < count; index += 1) {
|
|
32
|
+
const branch = randomAssortmentScope(rng)
|
|
33
|
+
if (rng() < 0.3) {
|
|
34
|
+
branch.allOf = [randomAssortmentScope(rng), randomAssortmentScope(rng)]
|
|
35
|
+
}
|
|
36
|
+
branches.push(branch)
|
|
37
|
+
}
|
|
38
|
+
return branches
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('canonicalizeEffectiveScope', () => {
|
|
42
|
+
it('distinguishes unrestricted (null) from deny-all ([])', () => {
|
|
43
|
+
expect(canonicalizeEffectiveScope(null)).toBe('null')
|
|
44
|
+
expect(canonicalizeEffectiveScope([])).toBe('[]')
|
|
45
|
+
expect(hashEffectiveScope(null)).not.toBe(hashEffectiveScope([]))
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('treats empty arrays as absent keys', () => {
|
|
49
|
+
expect(canonicalizeEffectiveScope([{ categoryIds: [], tagIds: ['t1'], excludeTagIds: [] }])).toBe(
|
|
50
|
+
canonicalizeEffectiveScope([{ tagIds: ['t1'] }]),
|
|
51
|
+
)
|
|
52
|
+
expect(canonicalizeEffectiveScope([{ categoryIds: [], allOf: [] }])).toBe(canonicalizeEffectiveScope([{}]))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('keeps an empty branch (matches everything) distinct from no branches', () => {
|
|
56
|
+
expect(canonicalizeEffectiveScope([{}])).toBe('[{}]')
|
|
57
|
+
expect(canonicalizeEffectiveScope([{}])).not.toBe(canonicalizeEffectiveScope([]))
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('is invariant to key order, id order and duplicate ids', () => {
|
|
61
|
+
const left: EffectiveAssortmentScope = [{ tagIds: ['b', 'a', 'a'], categoryIds: ['y', 'x'] }]
|
|
62
|
+
const right: EffectiveAssortmentScope = [{ categoryIds: ['x', 'y'], tagIds: ['a', 'b'] }]
|
|
63
|
+
expect(canonicalizeEffectiveScope(left)).toBe(canonicalizeEffectiveScope(right))
|
|
64
|
+
expect(hashEffectiveScope(left)).toBe(hashEffectiveScope(right))
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('is invariant to branch order and collapses identical branches', () => {
|
|
68
|
+
const forward: EffectiveAssortmentScope = [{ categoryIds: ['a'] }, { tagIds: ['b'] }]
|
|
69
|
+
const reversed: EffectiveAssortmentScope = [{ tagIds: ['b'] }, { categoryIds: ['a'] }, { categoryIds: ['a'] }]
|
|
70
|
+
expect(canonicalizeEffectiveScope(forward)).toBe(canonicalizeEffectiveScope(reversed))
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('canonicalizes nested allOf entries and their order', () => {
|
|
74
|
+
const left: EffectiveAssortmentScope = [
|
|
75
|
+
{ categoryIds: ['a'], allOf: [{ tagIds: ['t2', 't1'] }, { excludeProductIds: ['p1'], allOf: [{ categoryIds: ['c2', 'c1'] }] }] },
|
|
76
|
+
]
|
|
77
|
+
const right: EffectiveAssortmentScope = [
|
|
78
|
+
{ allOf: [{ allOf: [{ categoryIds: ['c1', 'c2'] }], excludeProductIds: ['p1'] }, { tagIds: ['t1', 't2'] }], categoryIds: ['a'] },
|
|
79
|
+
]
|
|
80
|
+
expect(canonicalizeEffectiveScope(left)).toBe(canonicalizeEffectiveScope(right))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('distinguishes scopes that match differently', () => {
|
|
84
|
+
expect(canonicalizeEffectiveScope([{ categoryIds: ['a'] }])).not.toBe(canonicalizeEffectiveScope([{ tagIds: ['a'] }]))
|
|
85
|
+
expect(canonicalizeEffectiveScope([{ categoryIds: ['a'], tagIds: ['b'] }])).not.toBe(
|
|
86
|
+
canonicalizeEffectiveScope([{ categoryIds: ['a'] }, { tagIds: ['b'] }]),
|
|
87
|
+
)
|
|
88
|
+
expect(canonicalizeEffectiveScope([{ categoryIds: ['a'] }])).not.toBe(
|
|
89
|
+
canonicalizeEffectiveScope([{ excludeCategoryIds: ['a'] }]),
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('hashEffectiveScope', () => {
|
|
95
|
+
it('returns 16 lowercase hex characters', () => {
|
|
96
|
+
expect(hashEffectiveScope([{ categoryIds: ['a'] }])).toMatch(/^[0-9a-f]{16}$/)
|
|
97
|
+
expect(hashEffectiveScope(null)).toMatch(/^[0-9a-f]{16}$/)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('property: shuffling branch, key and id order preserves the hash', () => {
|
|
101
|
+
for (let seed = 1; seed <= 200; seed += 1) {
|
|
102
|
+
const rng = mulberry32(seed)
|
|
103
|
+
const scope = randomEffective(rng)
|
|
104
|
+
const shuffled = scope === null ? null : shuffle(rng, scope.map((branch) => shuffleScope(rng, branch)))
|
|
105
|
+
expect(hashEffectiveScope(shuffled)).toBe(hashEffectiveScope(scope))
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('property: equal canonical form if and only if equal hash across random pairs', () => {
|
|
110
|
+
const rng = mulberry32(4242)
|
|
111
|
+
const scopes: EffectiveAssortmentScope[] = []
|
|
112
|
+
for (let index = 0; index < 60; index += 1) scopes.push(randomEffective(rng))
|
|
113
|
+
for (const left of scopes) {
|
|
114
|
+
for (const right of scopes) {
|
|
115
|
+
const sameCanonical = canonicalizeEffectiveScope(left) === canonicalizeEffectiveScope(right)
|
|
116
|
+
const sameHash = hashEffectiveScope(left) === hashEffectiveScope(right)
|
|
117
|
+
expect(sameHash).toBe(sameCanonical)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { AssortmentScope, EffectiveAssortmentScope } from './types'
|
|
2
|
+
|
|
3
|
+
const ID_LIST_KEYS = [
|
|
4
|
+
'categoryIds',
|
|
5
|
+
'excludeCategoryIds',
|
|
6
|
+
'excludeProductIds',
|
|
7
|
+
'excludeTagIds',
|
|
8
|
+
'tagIds',
|
|
9
|
+
] as const
|
|
10
|
+
|
|
11
|
+
function compareStrings(left: string, right: string): number {
|
|
12
|
+
if (left < right) return -1
|
|
13
|
+
if (left > right) return 1
|
|
14
|
+
return 0
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sortedUnique(values: string[]): string[] {
|
|
18
|
+
return Array.from(new Set(values)).sort(compareStrings)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function canonicalizeBranch(scope: AssortmentScope): string {
|
|
22
|
+
const members: string[] = []
|
|
23
|
+
const nested = (scope.allOf ?? []).map(canonicalizeBranch)
|
|
24
|
+
if (nested.length > 0) members.push(`"allOf":[${sortedUnique(nested).join(',')}]`)
|
|
25
|
+
for (const key of ID_LIST_KEYS) {
|
|
26
|
+
const ids = scope[key] ?? []
|
|
27
|
+
if (ids.length > 0) members.push(`${JSON.stringify(key)}:${JSON.stringify(sortedUnique(ids))}`)
|
|
28
|
+
}
|
|
29
|
+
return `{${members.join(',')}}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deterministic serialization of a resolved scope. `null` (unrestricted) serializes as `null`
|
|
34
|
+
* and `[]` (deny-all) as `[]`, so the two never collide. Empty and absent arrays are
|
|
35
|
+
* equivalent, id lists are sorted and deduplicated, and OR-branches and `allOf` entries are
|
|
36
|
+
* ordered by their own canonical form with identical ones collapsed.
|
|
37
|
+
*/
|
|
38
|
+
export function canonicalizeEffectiveScope(scope: EffectiveAssortmentScope): string {
|
|
39
|
+
if (scope === null) return 'null'
|
|
40
|
+
return `[${sortedUnique(scope.map(canonicalizeBranch)).join(',')}]`
|
|
41
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { canonicalizeEffectiveScope } from './canonicalizeEffectiveScope'
|
|
3
|
+
import type { EffectiveAssortmentScope } from './types'
|
|
4
|
+
|
|
5
|
+
/** 16-hex-character sha256 digest of the canonical scope (SPEC-029 §6.1 truncation). Server-only: uses node:crypto. */
|
|
6
|
+
export function hashEffectiveScope(scope: EffectiveAssortmentScope): string {
|
|
7
|
+
return createHash('sha256').update(canonicalizeEffectiveScope(scope)).digest('hex').slice(0, 16)
|
|
8
|
+
}
|
|
@@ -3,3 +3,5 @@ export { matchesOne } from './matchesOne'
|
|
|
3
3
|
export { matchesScope } from './matchesScope'
|
|
4
4
|
export { unionScopes } from './unionScopes'
|
|
5
5
|
export { intersectScopes } from './intersectScopes'
|
|
6
|
+
export { canonicalizeEffectiveScope } from './canonicalizeEffectiveScope'
|
|
7
|
+
export { hashEffectiveScope } from './hashEffectiveScope'
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
isCommandInterceptorError,
|
|
8
8
|
} from '@open-mercato/shared/lib/commands'
|
|
9
9
|
import { registerCommandInterceptors } from '@open-mercato/shared/lib/commands/command-interceptor-store'
|
|
10
|
-
import type { CommandInterceptor } from '@open-mercato/shared/lib/commands/command-interceptor'
|
|
10
|
+
import type { CommandInterceptor, CommandInterceptorContext } from '@open-mercato/shared/lib/commands/command-interceptor'
|
|
11
11
|
|
|
12
12
|
describe('CommandBus', () => {
|
|
13
13
|
afterEach(() => {
|
|
@@ -296,6 +296,57 @@ describe('CommandBus', () => {
|
|
|
296
296
|
)
|
|
297
297
|
})
|
|
298
298
|
|
|
299
|
+
it('defers opted-in before snapshots until after the transactional write guard', async () => {
|
|
300
|
+
const calls: string[] = []
|
|
301
|
+
const transactionalEm = { id: 'transaction-em' }
|
|
302
|
+
const buildLogMock = jest.fn(() => ({
|
|
303
|
+
actionLabel: 'Transaction snapshot',
|
|
304
|
+
resourceKind: 'test',
|
|
305
|
+
resourceId: 'transactional',
|
|
306
|
+
}))
|
|
307
|
+
const prepare = jest.fn(async (_input, ctx) => {
|
|
308
|
+
calls.push('prepare')
|
|
309
|
+
expect(ctx.transactionalEm).toBe(transactionalEm)
|
|
310
|
+
return { before: { state: 'locked-before' } }
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
registerCommand({
|
|
314
|
+
id: 'test.command.transactional-snapshot',
|
|
315
|
+
prepare,
|
|
316
|
+
prepareSnapshotInsideTransaction: true,
|
|
317
|
+
execute: jest.fn(async (_input, ctx) => {
|
|
318
|
+
calls.push('execute')
|
|
319
|
+
await ctx.beforeTransactionalWrite?.(transactionalEm as never)
|
|
320
|
+
calls.push('mutate')
|
|
321
|
+
return { ok: true }
|
|
322
|
+
}),
|
|
323
|
+
buildLog: buildLogMock,
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
const logMock = jest.fn(async () => ({ id: 'transaction-log' }))
|
|
327
|
+
const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
|
|
328
|
+
container.register({ actionLogService: asValue({ log: logMock }) })
|
|
329
|
+
const bus = new CommandBus()
|
|
330
|
+
const ctx = {
|
|
331
|
+
container,
|
|
332
|
+
auth: { sub: 'user-transaction', tenantId: 'tenant-transaction', orgId: null },
|
|
333
|
+
organizationScope: null,
|
|
334
|
+
selectedOrganizationId: null,
|
|
335
|
+
organizationIds: null,
|
|
336
|
+
beforeTransactionalWrite: jest.fn(async () => {
|
|
337
|
+
calls.push('lock')
|
|
338
|
+
}),
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
await bus.execute('test.command.transactional-snapshot', { input: {}, ctx })
|
|
342
|
+
|
|
343
|
+
expect(calls).toEqual(['execute', 'lock', 'prepare', 'mutate'])
|
|
344
|
+
expect(prepare).toHaveBeenCalledTimes(1)
|
|
345
|
+
expect(buildLogMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
346
|
+
snapshots: { before: { state: 'locked-before' }, after: undefined },
|
|
347
|
+
}))
|
|
348
|
+
})
|
|
349
|
+
|
|
299
350
|
it('loads a command file lazily before execution', async () => {
|
|
300
351
|
const execute = jest.fn(async () => ({ ok: true }))
|
|
301
352
|
registerCommandLoaders([
|
|
@@ -538,6 +589,54 @@ describe('CommandBus', () => {
|
|
|
538
589
|
})
|
|
539
590
|
|
|
540
591
|
describe('interceptor rejections', () => {
|
|
592
|
+
it('passes the original request to beforeExecute for preflight version checks', async () => {
|
|
593
|
+
registerCommand({ id: 'test.request-context', execute: jest.fn(async () => ({ ok: true })) })
|
|
594
|
+
const beforeExecute = jest.fn(async (_input: unknown, context: { request?: Request | null }) => {
|
|
595
|
+
expect(context.request?.headers.get('x-om-ext-optimistic-lock-expected-updated-at')).toBe('2026-09-29T11:00:00.000Z')
|
|
596
|
+
return { ok: false, status: 422 }
|
|
597
|
+
})
|
|
598
|
+
registerCommandInterceptors([{ moduleId: 'test', interceptors: [{
|
|
599
|
+
id: 'test.request-context-interceptor', targetCommand: 'test.request-context', beforeExecute,
|
|
600
|
+
}] }])
|
|
601
|
+
const request = new Request('http://localhost/test', { headers: { 'x-om-ext-optimistic-lock-expected-updated-at': '2026-09-29T11:00:00.000Z' } })
|
|
602
|
+
const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
|
|
603
|
+
const bus = new CommandBus()
|
|
604
|
+
await expect(bus.execute('test.request-context', { input: {}, ctx: {
|
|
605
|
+
container,
|
|
606
|
+
auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: null },
|
|
607
|
+
organizationScope: null,
|
|
608
|
+
selectedOrganizationId: null,
|
|
609
|
+
organizationIds: null,
|
|
610
|
+
request,
|
|
611
|
+
} })).rejects.toMatchObject({ status: 422 })
|
|
612
|
+
expect(beforeExecute).toHaveBeenCalledTimes(1)
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it('passes request and organization scope to both execution lifecycle hooks', async () => {
|
|
616
|
+
registerCommand({ id: 'test.lifecycle-context', execute: async () => ({ ok: true }) })
|
|
617
|
+
const request = new Request('http://localhost/test')
|
|
618
|
+
const organizationScope = { selectedId: 'parent', filterIds: ['parent', 'child'], allowedIds: ['parent', 'child'], tenantId: 'tenant-1' }
|
|
619
|
+
const beforeExecute = jest.fn(async (_input: unknown, context: CommandInterceptorContext) => {
|
|
620
|
+
expect(context.request).toBe(request)
|
|
621
|
+
expect(context.organizationScope).toBe(organizationScope)
|
|
622
|
+
return { ok: true }
|
|
623
|
+
})
|
|
624
|
+
const afterExecute = jest.fn(async (_input: unknown, _result: unknown, context: CommandInterceptorContext) => {
|
|
625
|
+
expect(context.request).toBe(request)
|
|
626
|
+
expect(context.organizationScope).toBe(organizationScope)
|
|
627
|
+
})
|
|
628
|
+
registerCommandInterceptors([{ moduleId: 'test', interceptors: [{
|
|
629
|
+
id: 'test.lifecycle-context-interceptor', targetCommand: 'test.lifecycle-context', beforeExecute, afterExecute,
|
|
630
|
+
}] }])
|
|
631
|
+
await new CommandBus().execute('test.lifecycle-context', { input: {}, ctx: {
|
|
632
|
+
container: createContainer({ injectionMode: InjectionMode.CLASSIC }),
|
|
633
|
+
auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: 'parent' },
|
|
634
|
+
organizationScope, selectedOrganizationId: 'parent', organizationIds: ['parent', 'child'], request,
|
|
635
|
+
} })
|
|
636
|
+
expect(beforeExecute).toHaveBeenCalledTimes(1)
|
|
637
|
+
expect(afterExecute).toHaveBeenCalledTimes(1)
|
|
638
|
+
})
|
|
639
|
+
|
|
541
640
|
const blockingInterceptor = (result: Record<string, unknown>): CommandInterceptor => ({
|
|
542
641
|
id: 'test.block',
|
|
543
642
|
targetCommand: 'test.*',
|
|
@@ -297,6 +297,8 @@ export class CommandBus {
|
|
|
297
297
|
auth: baseOptions.ctx.auth ?? null,
|
|
298
298
|
selectedOrganizationId: baseOptions.ctx.selectedOrganizationId ?? baseOptions.ctx.auth?.orgId ?? null,
|
|
299
299
|
container: baseOptions.ctx.container,
|
|
300
|
+
organizationScope: baseOptions.ctx.organizationScope ?? null,
|
|
301
|
+
request: baseOptions.ctx.request ?? null,
|
|
300
302
|
}
|
|
301
303
|
const beforeResult = await runCommandInterceptorsBefore(
|
|
302
304
|
allInterceptors, commandId, baseOptions.input, interceptorCtx, resolvedUserFeatures,
|
|
@@ -338,16 +340,40 @@ export class CommandBus {
|
|
|
338
340
|
}
|
|
339
341
|
|
|
340
342
|
const executeCore = async (coreOptions: CommandExecutionOptions<TInput>) => {
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
+
const snapshots: { before?: unknown } = {}
|
|
344
|
+
let executionOptions = coreOptions
|
|
345
|
+
if (handler.prepareSnapshotInsideTransaction && handler.prepare) {
|
|
346
|
+
const prepare = handler.prepare
|
|
347
|
+
const originalCtx = coreOptions.ctx
|
|
348
|
+
const originalBeforeTransactionalWrite = originalCtx.beforeTransactionalWrite
|
|
349
|
+
let prepared = false
|
|
350
|
+
let transactionalCtx: CommandRuntimeContext
|
|
351
|
+
transactionalCtx = {
|
|
352
|
+
...originalCtx,
|
|
353
|
+
beforeTransactionalWrite: async (em, input) => {
|
|
354
|
+
await originalBeforeTransactionalWrite?.(em, input)
|
|
355
|
+
if (prepared) return
|
|
356
|
+
Object.assign(
|
|
357
|
+
snapshots,
|
|
358
|
+
(await prepare(coreOptions.input, { ...transactionalCtx, transactionalEm: em })) ?? {},
|
|
359
|
+
)
|
|
360
|
+
prepared = true
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
executionOptions = { ...coreOptions, ctx: transactionalCtx }
|
|
364
|
+
} else {
|
|
365
|
+
Object.assign(snapshots, await this.prepareSnapshots(handler, coreOptions))
|
|
366
|
+
}
|
|
367
|
+
effectiveOptions = executionOptions
|
|
368
|
+
const sourceLog = executionOptions.redoLogEntry ?? null
|
|
343
369
|
const result =
|
|
344
370
|
sourceLog && typeof handler.redo === 'function'
|
|
345
|
-
? await handler.redo({ input:
|
|
346
|
-
: await handler.execute(
|
|
347
|
-
const afterSnapshot = await this.captureAfter(handler,
|
|
371
|
+
? await handler.redo({ input: executionOptions.input, ctx: executionOptions.ctx, logEntry: sourceLog })
|
|
372
|
+
: await handler.execute(executionOptions.input, executionOptions.ctx)
|
|
373
|
+
const afterSnapshot = await this.captureAfter(handler, executionOptions, result)
|
|
348
374
|
const snapshotsWithAfter = { ...snapshots, after: afterSnapshot }
|
|
349
|
-
const logMeta = await this.buildLog(handler,
|
|
350
|
-
let mergedMeta = this.mergeMetadata(
|
|
375
|
+
const logMeta = await this.buildLog(handler, executionOptions, result, snapshotsWithAfter)
|
|
376
|
+
let mergedMeta = this.mergeMetadata(executionOptions.metadata, logMeta)
|
|
351
377
|
// Interceptors opt into audit-log enrichment with a reserved `logContext` key rather
|
|
352
378
|
// than the generic `context` one, so the metadata an interceptor already passes to its
|
|
353
379
|
// own afterExecute hook is never silently promoted into audit storage.
|
|
@@ -362,7 +388,7 @@ export class CommandBus {
|
|
|
362
388
|
...logContextRecord,
|
|
363
389
|
}
|
|
364
390
|
}
|
|
365
|
-
const baseContext = asRecord(
|
|
391
|
+
const baseContext = asRecord(executionOptions.metadata?.context) ?? {}
|
|
366
392
|
const logMetaContext = asRecord(logMeta?.context) ?? {}
|
|
367
393
|
if (Object.keys(interceptorContextMerged).length > 0 || Object.keys(baseContext).length > 0 || Object.keys(logMetaContext).length > 0) {
|
|
368
394
|
mergedMeta = mergedMeta ?? {}
|
|
@@ -376,7 +402,7 @@ export class CommandBus {
|
|
|
376
402
|
if (undoable && mergedMeta?.replayable !== false) {
|
|
377
403
|
mergedMeta = mergedMeta ?? {}
|
|
378
404
|
if (!mergedMeta.undoToken) mergedMeta.undoToken = defaultUndoToken()
|
|
379
|
-
if (mergedMeta.actorUserId === undefined) mergedMeta.actorUserId =
|
|
405
|
+
if (mergedMeta.actorUserId === undefined) mergedMeta.actorUserId = executionOptions.ctx.auth?.sub ?? null
|
|
380
406
|
} else if (mergedMeta?.replayable === false) {
|
|
381
407
|
mergedMeta.undoToken = null
|
|
382
408
|
}
|
|
@@ -405,7 +431,7 @@ export class CommandBus {
|
|
|
405
431
|
if (inferred) mergedMeta.changes = inferred
|
|
406
432
|
}
|
|
407
433
|
}
|
|
408
|
-
const logEntry = await this.persistLog(commandId,
|
|
434
|
+
const logEntry = await this.persistLog(commandId, executionOptions, mergedMeta)
|
|
409
435
|
return { result, mergedMeta, logEntry }
|
|
410
436
|
}
|
|
411
437
|
|
|
@@ -474,6 +500,8 @@ export class CommandBus {
|
|
|
474
500
|
auth: effectiveOptions.ctx.auth ?? null,
|
|
475
501
|
selectedOrganizationId: effectiveOptions.ctx.selectedOrganizationId ?? effectiveOptions.ctx.auth?.orgId ?? null,
|
|
476
502
|
container: effectiveOptions.ctx.container,
|
|
503
|
+
organizationScope: effectiveOptions.ctx.organizationScope ?? null,
|
|
504
|
+
request: effectiveOptions.ctx.request ?? null,
|
|
477
505
|
}
|
|
478
506
|
const afterResult = await runCommandInterceptorsAfter(
|
|
479
507
|
allInterceptors, commandId, effectiveOptions.input, result, interceptorCtx,
|
|
@@ -545,6 +573,8 @@ export class CommandBus {
|
|
|
545
573
|
auth: replayCtx.auth ?? null,
|
|
546
574
|
selectedOrganizationId: replayCtx.selectedOrganizationId ?? replayCtx.auth?.orgId ?? null,
|
|
547
575
|
container: replayCtx.container,
|
|
576
|
+
organizationScope: replayCtx.organizationScope ?? null,
|
|
577
|
+
request: replayCtx.request ?? null,
|
|
548
578
|
}
|
|
549
579
|
const beforeResult = await runCommandInterceptorsBeforeUndo(
|
|
550
580
|
allInterceptors, log.commandId, undoCtx, interceptorCtx, resolvedUserFeatures,
|
|
@@ -659,6 +689,8 @@ export class CommandBus {
|
|
|
659
689
|
auth: ctx.auth ?? null,
|
|
660
690
|
selectedOrganizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
|
|
661
691
|
container: ctx.container,
|
|
692
|
+
organizationScope: ctx.organizationScope ?? null,
|
|
693
|
+
request: ctx.request ?? null,
|
|
662
694
|
}
|
|
663
695
|
await runCommandInterceptorsAfterUndo(
|
|
664
696
|
allInterceptors, log.commandId, undoCtx, interceptorCtx,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
import type { OrganizationScope } from '@open-mercato/shared/lib/auth/principal-service'
|
|
2
3
|
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
3
4
|
|
|
4
5
|
export interface CommandInterceptor {
|
|
@@ -47,8 +48,11 @@ export interface CommandInterceptorContext {
|
|
|
47
48
|
auth: AuthContext | null
|
|
48
49
|
/** Selected organization ID */
|
|
49
50
|
selectedOrganizationId: string | null
|
|
51
|
+
organizationScope?: OrganizationScope | null
|
|
50
52
|
/** DI container (read-only usage recommended) */
|
|
51
53
|
container: AwilixContainer
|
|
54
|
+
/** Original request, when the command was called through HTTP. */
|
|
55
|
+
request?: Request | null
|
|
52
56
|
/** Metadata passthrough from beforeExecute to afterExecute (or beforeUndo to afterUndo) */
|
|
53
57
|
metadata?: Record<string, unknown>
|
|
54
58
|
}
|
|
@@ -67,6 +67,14 @@ export type CommandRuntimeContext = {
|
|
|
67
67
|
* surrounding work as a single atomic, single-locked operation.
|
|
68
68
|
*/
|
|
69
69
|
transactionalEm?: EntityManager
|
|
70
|
+
/**
|
|
71
|
+
* Optional server-side guard that supported command handlers invoke after
|
|
72
|
+
* opening their write transaction and before reading or mutating records.
|
|
73
|
+
* The guard receives that same transaction-bound EntityManager and, when the
|
|
74
|
+
* handler provides it, the final input after command interceptors. The input
|
|
75
|
+
* stays optional so existing handlers and callers remain source-compatible.
|
|
76
|
+
*/
|
|
77
|
+
beforeTransactionalWrite?: (em: EntityManager, input?: unknown) => Promise<void>
|
|
70
78
|
/**
|
|
71
79
|
* Identifies the owner of an already-active {@link transactionalEm} lifetime.
|
|
72
80
|
* Atomic replay accepts an ambient EntityManager only when this is the exact
|
|
@@ -243,6 +251,12 @@ export interface CommandHandler<TInput = unknown, TResult = unknown> {
|
|
|
243
251
|
*/
|
|
244
252
|
readonly outputSchema?: ZodTypeAny
|
|
245
253
|
prepare?(input: TInput, ctx: CommandRuntimeContext): Promise<{ before?: unknown } | null> | { before?: unknown } | null
|
|
254
|
+
/**
|
|
255
|
+
* Opt in to taking the undo snapshot inside the write transaction. The bus
|
|
256
|
+
* defers `prepare()` into `ctx.beforeTransactionalWrite`, after the handler's
|
|
257
|
+
* transaction guard and before its first read or mutation.
|
|
258
|
+
*/
|
|
259
|
+
readonly prepareSnapshotInsideTransaction?: boolean
|
|
246
260
|
execute(input: TInput, ctx: CommandRuntimeContext): Promise<TResult> | TResult
|
|
247
261
|
buildLog?(args: CommandLogBuilderArgs<TInput, TResult>): Promise<CommandLogMetadata | null | undefined> | CommandLogMetadata | null | undefined
|
|
248
262
|
captureAfter?(input: TInput, result: TResult, ctx: CommandRuntimeContext): Promise<unknown> | unknown
|
|
@@ -28,4 +28,22 @@ describe('CRUD export serializers', () => {
|
|
|
28
28
|
'"\'\t=HYPERLINK(""https://example.invalid"")","\'\r+1+1",\'-7',
|
|
29
29
|
])
|
|
30
30
|
})
|
|
31
|
+
|
|
32
|
+
it('escapes and neutralizes CSV header cells the same way as data cells', () => {
|
|
33
|
+
const serialized = serializeExport({
|
|
34
|
+
columns: [
|
|
35
|
+
{ field: 'company', header: 'Name, Inc.' },
|
|
36
|
+
{ field: 'formula', header: '=HEADER' },
|
|
37
|
+
{ field: 'plain', header: 'Balance' },
|
|
38
|
+
],
|
|
39
|
+
rows: [
|
|
40
|
+
{ company: 'Acme', formula: 'ok', plain: 1 },
|
|
41
|
+
],
|
|
42
|
+
}, 'csv')
|
|
43
|
+
|
|
44
|
+
expect(serialized.body.split('\n')).toEqual([
|
|
45
|
+
'"Name, Inc.",\'=HEADER,Balance',
|
|
46
|
+
'Acme,ok,1',
|
|
47
|
+
])
|
|
48
|
+
})
|
|
31
49
|
})
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
MAX_IDS_PER_REQUEST,
|
|
3
|
+
mergeIdFilter,
|
|
4
|
+
parseIdsParam,
|
|
5
|
+
isIdsParamProvided,
|
|
6
|
+
isUuidListParam,
|
|
7
|
+
splitIdListParam,
|
|
8
|
+
} from '@open-mercato/shared/lib/crud/ids'
|
|
2
9
|
|
|
3
10
|
describe('crud ids helpers', () => {
|
|
4
11
|
const idA = '550e8400-e29b-41d4-a716-446655440001'
|
|
@@ -168,4 +175,30 @@ describe('crud ids helpers', () => {
|
|
|
168
175
|
tenantId: 't1',
|
|
169
176
|
})
|
|
170
177
|
})
|
|
178
|
+
|
|
179
|
+
it('splitIdListParam trims entries and drops empty ones', () => {
|
|
180
|
+
expect(splitIdListParam(` ${idA} ,, ${idB} ,`)).toEqual([idA, idB])
|
|
181
|
+
expect(splitIdListParam(undefined)).toEqual([])
|
|
182
|
+
expect(splitIdListParam(['not', 'a', 'string'])).toEqual([])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('isUuidListParam accepts absent, empty, and all-UUID lists', () => {
|
|
186
|
+
expect(isUuidListParam(undefined)).toBe(true)
|
|
187
|
+
expect(isUuidListParam('')).toBe(true)
|
|
188
|
+
expect(isUuidListParam(` ${idA}, ${idB.toUpperCase()} `)).toBe(true)
|
|
189
|
+
expect(isUuidListParam('00000000-0000-0000-0000-000000000000')).toBe(true)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('isUuidListParam rejects malformed and mixed lists', () => {
|
|
193
|
+
expect(isUuidListParam('not-a-uuid')).toBe(false)
|
|
194
|
+
expect(isUuidListParam(`${idA},not-a-uuid`)).toBe(false)
|
|
195
|
+
expect(isUuidListParam(`${idA}0`)).toBe(false)
|
|
196
|
+
expect(isUuidListParam(`{${idA}}`)).toBe(false)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('isUuidListParam fails closed on non-string values', () => {
|
|
200
|
+
expect(isUuidListParam(['not-a-uuid'])).toBe(false)
|
|
201
|
+
expect(isUuidListParam([idA])).toBe(false)
|
|
202
|
+
expect(isUuidListParam(null)).toBe(false)
|
|
203
|
+
})
|
|
171
204
|
})
|
|
@@ -81,7 +81,7 @@ function escapeXmlValue(value: string): string {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
function serializeCsv(prepared: PreparedExport): SerializedExport {
|
|
84
|
-
const headers = prepared.columns.map((col) => col.header)
|
|
84
|
+
const headers = prepared.columns.map((col) => escapeCsv(neutralizeSpreadsheetFormula(col.header)))
|
|
85
85
|
const lines = [headers.join(',')]
|
|
86
86
|
for (const row of prepared.rows) {
|
|
87
87
|
const values = prepared.columns.map((col) => {
|
package/src/lib/crud/ids.ts
CHANGED
|
@@ -51,6 +51,30 @@ export function parseIdsParam(raw: unknown, maxIds: number = MAX_IDS_PER_REQUEST
|
|
|
51
51
|
return parsed.slice(0, safeMax)
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
const UUID_SHAPE_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Splits a comma-separated id list query param into its trimmed, non-empty entries.
|
|
58
|
+
*/
|
|
59
|
+
export function splitIdListParam(raw: unknown): string[] {
|
|
60
|
+
if (typeof raw !== 'string') return []
|
|
61
|
+
return raw
|
|
62
|
+
.split(',')
|
|
63
|
+
.map((value) => value.trim())
|
|
64
|
+
.filter((value) => value.length > 0)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether every entry of a comma-separated id list query param is UUID-shaped, so it can be
|
|
69
|
+
* bound to a uuid column without a database error. Use it to reject a malformed or mixed list
|
|
70
|
+
* with a 400 instead of dropping the bad entries and widening the result set (#7265).
|
|
71
|
+
*/
|
|
72
|
+
export function isUuidListParam(raw: unknown): boolean {
|
|
73
|
+
if (raw === undefined) return true
|
|
74
|
+
if (typeof raw !== 'string') return false
|
|
75
|
+
return splitIdListParam(raw).every((value) => UUID_SHAPE_REGEX.test(value))
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
/**
|
|
55
79
|
* Whether an `?ids=` param was supplied at all (a non-empty string, or repeated
|
|
56
80
|
* `?ids=` occurrences), regardless of whether any value survived UUID
|