@open-mercato/shared 0.6.7 → 0.6.8-develop.6874.1.982d6097d8
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 +57 -6
- package/build.mjs +2 -4
- package/dist/lib/ai/safety-identifier.js +32 -0
- package/dist/lib/ai/safety-identifier.js.map +7 -0
- package/dist/lib/auth/organizationScope.js +34 -0
- package/dist/lib/auth/organizationScope.js.map +7 -0
- package/dist/lib/auth/server.js +15 -2
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
- package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +117 -43
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/commands/command-bus.js +5 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/command-interceptor-runner.js +2 -2
- package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +2 -11
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +72 -9
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/interceptor-runner.js +2 -2
- package/dist/lib/crud/interceptor-runner.js.map +2 -2
- package/dist/lib/crud/mutation-guard-registry.js +2 -2
- package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
- package/dist/lib/crud/types.js +4 -0
- package/dist/lib/crud/types.js.map +3 -3
- package/dist/lib/data/consistency.js +19 -0
- package/dist/lib/data/consistency.js.map +7 -0
- package/dist/lib/data/engine.js +37 -11
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/entityMetadata.js +26 -0
- package/dist/lib/db/entityMetadata.js.map +7 -0
- package/dist/lib/db/pg-errors.js +42 -0
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/di/container.js +48 -6
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/email/send.js +56 -0
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +2 -10
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/i18n/config.js +1 -1
- package/dist/lib/i18n/config.js.map +2 -2
- package/dist/lib/i18n/server.js +4 -4
- package/dist/lib/i18n/server.js.map +3 -3
- package/dist/lib/logger/extension.js +29 -0
- package/dist/lib/logger/extension.js.map +7 -0
- package/dist/lib/logger/index.js +47 -3
- package/dist/lib/logger/index.js.map +3 -3
- package/dist/lib/modules/registry.js +5 -1
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/modules/surfaceFingerprint.js +47 -0
- package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
- package/dist/lib/query/ciphertext-search-warning.js +45 -0
- package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
- package/dist/lib/query/engine.js +54 -40
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/redis/connection.js +7 -2
- package/dist/lib/redis/connection.js.map +2 -2
- package/dist/lib/search/auto-indexing.js +14 -0
- package/dist/lib/search/auto-indexing.js.map +7 -0
- package/dist/lib/search/availability.js +107 -0
- package/dist/lib/search/availability.js.map +7 -0
- package/dist/lib/search/config.js +61 -2
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenLookup.js +46 -0
- package/dist/lib/search/tokenLookup.js.map +7 -0
- package/dist/lib/search/tokenize.js +23 -11
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/telemetry/runtime.js +35 -0
- package/dist/lib/telemetry/runtime.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +57 -0
- package/dist/lib/webhooks/body.js.map +7 -0
- package/dist/lib/webhooks/inbound-types.js +1 -0
- package/dist/lib/webhooks/inbound-types.js.map +7 -0
- package/dist/lib/webhooks/index.js +13 -1
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/lib/webhooks/verify.js +12 -7
- package/dist/lib/webhooks/verify.js.map +2 -2
- package/dist/modules/analytics.js.map +2 -2
- package/dist/modules/overrides.js +50 -1
- package/dist/modules/overrides.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +8 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js +1 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/extension-points.js +96 -0
- package/dist/modules/widgets/extension-points.js.map +7 -0
- package/dist/security/enabledModulesRegistry.js +56 -11
- package/dist/security/enabledModulesRegistry.js.map +2 -2
- package/dist/security/featurePolicy.js +62 -0
- package/dist/security/featurePolicy.js.map +7 -0
- package/jest.config.cjs +2 -1
- package/package.json +19 -9
- package/scripts/versionSource.cjs +65 -0
- package/src/lib/__tests__/versionSource.test.ts +46 -0
- package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
- package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
- package/src/lib/ai/llm-provider.ts +33 -0
- package/src/lib/ai/safety-identifier.ts +73 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
- package/src/lib/auth/__tests__/server.test.ts +23 -0
- package/src/lib/auth/organizationScope.ts +65 -0
- package/src/lib/auth/server.ts +23 -2
- package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
- package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
- package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +213 -65
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
- package/src/lib/commands/command-bus.ts +5 -1
- package/src/lib/commands/command-interceptor-runner.ts +2 -2
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +351 -0
- package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
- package/src/lib/crud/enricher-runner.ts +2 -11
- package/src/lib/crud/factory.ts +82 -6
- package/src/lib/crud/interceptor-runner.ts +2 -2
- package/src/lib/crud/mutation-guard-registry.ts +2 -2
- package/src/lib/crud/types.ts +3 -0
- package/src/lib/data/__tests__/consistency.test.ts +38 -0
- package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
- package/src/lib/data/consistency.ts +17 -0
- package/src/lib/data/engine.ts +50 -16
- package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
- package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
- package/src/lib/db/entityMetadata.ts +43 -0
- package/src/lib/db/pg-errors.ts +58 -0
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
- package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
- package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
- package/src/lib/di/container.ts +72 -8
- package/src/lib/email/__tests__/send.test.ts +37 -1
- package/src/lib/email/send.ts +79 -0
- package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
- package/src/lib/encryption/subscriber.ts +2 -14
- package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
- package/src/lib/i18n/__tests__/config.test.ts +8 -0
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
- package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
- package/src/lib/i18n/config.ts +2 -2
- package/src/lib/i18n/server.ts +6 -2
- package/src/lib/logger/__tests__/logger.test.ts +77 -0
- package/src/lib/logger/extension.ts +59 -0
- package/src/lib/logger/index.ts +59 -1
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
- package/src/lib/modules/registry.ts +10 -0
- package/src/lib/modules/surfaceFingerprint.ts +87 -0
- package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
- package/src/lib/query/__tests__/engine.test.ts +65 -13
- package/src/lib/query/ciphertext-search-warning.ts +95 -0
- package/src/lib/query/engine.ts +75 -58
- package/src/lib/query/types.ts +4 -5
- package/src/lib/redis/__tests__/connection.test.ts +16 -0
- package/src/lib/redis/connection.ts +10 -7
- package/src/lib/search/__tests__/availability.test.ts +211 -0
- package/src/lib/search/__tests__/config.test.ts +177 -0
- package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
- package/src/lib/search/__tests__/tokenize.test.ts +49 -0
- package/src/lib/search/auto-indexing.ts +22 -0
- package/src/lib/search/availability.ts +232 -0
- package/src/lib/search/config.ts +106 -8
- package/src/lib/search/tokenLookup.ts +133 -0
- package/src/lib/search/tokenize.ts +33 -11
- package/src/lib/telemetry/runtime.ts +72 -0
- package/src/lib/webhooks/__tests__/body.test.ts +95 -0
- package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
- package/src/lib/webhooks/body.ts +70 -0
- package/src/lib/webhooks/inbound-types.ts +125 -0
- package/src/lib/webhooks/index.ts +19 -1
- package/src/lib/webhooks/verify.ts +17 -9
- package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
- package/src/modules/__tests__/registry.test.ts +10 -2
- package/src/modules/analytics.ts +7 -0
- package/src/modules/customer-auth.ts +1 -0
- package/src/modules/encryption.ts +3 -0
- package/src/modules/navigation/backendChrome.ts +15 -0
- package/src/modules/overrides.ts +103 -0
- package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
- package/src/modules/payment_gateways/types.ts +41 -0
- package/src/modules/registry.ts +3 -0
- package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
- package/src/modules/widgets/extension-points.ts +524 -0
- package/src/security/__tests__/featurePolicy.test.ts +166 -0
- package/src/security/enabledModulesRegistry.ts +64 -12
- package/src/security/featurePolicy.ts +89 -0
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
InterceptorBeforeResult,
|
|
7
7
|
} from './api-interceptor'
|
|
8
8
|
import { getApiInterceptorsForRoute } from './interceptor-registry'
|
|
9
|
-
import {
|
|
9
|
+
import { authorizeFeatures } from '../../security/featurePolicy'
|
|
10
10
|
import { logInterceptorActivity } from '../umes/interceptor-activity'
|
|
11
11
|
|
|
12
12
|
const DEFAULT_TIMEOUT_MS = 5000
|
|
@@ -39,7 +39,7 @@ function sanitizeObject(input?: Record<string, unknown>): Record<string, unknown
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function hasRequiredFeatures(features: string[] | undefined, userFeatures: string[] | undefined): boolean {
|
|
42
|
-
return
|
|
42
|
+
return authorizeFeatures(features ?? [], { grantedFeatures: userFeatures ?? [] })
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
function timeoutPromise(ms: number, interceptorId: string): Promise<never> {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AwilixContainer } from 'awilix'
|
|
2
|
-
import {
|
|
2
|
+
import { authorizeFeatures } from '../../security/featurePolicy'
|
|
3
3
|
import { resolveCrudMutationGuardService } from './mutation-guard-service'
|
|
4
4
|
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
@@ -101,7 +101,7 @@ export async function runMutationGuards(
|
|
|
101
101
|
const matching = guards
|
|
102
102
|
.filter((g) => matchesEntity(g.targetEntity, input.resourceKind))
|
|
103
103
|
.filter((g) => g.operations.includes(input.operation))
|
|
104
|
-
.filter((g) =>
|
|
104
|
+
.filter((g) => authorizeFeatures(g.features ?? [], { grantedFeatures: context.userFeatures }))
|
|
105
105
|
.sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50))
|
|
106
106
|
|
|
107
107
|
let payload = input.mutationPayload
|
package/src/lib/crud/types.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export type CrudEventAction = 'created' | 'updated' | 'deleted'
|
|
2
2
|
|
|
3
|
+
/** Internal payload marker: the data engine owns this CRUD event's query-index decision. */
|
|
4
|
+
export const CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY = '__omQueryIndexManaged' as const
|
|
5
|
+
|
|
3
6
|
export type CrudEntityIdentifiers = {
|
|
4
7
|
id: string
|
|
5
8
|
organizationId: string | null
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__resetAlwaysConsistentCacheForTests,
|
|
3
|
+
isReadProjectionAlwaysConsistent,
|
|
4
|
+
parseAlwaysConsistentEnv,
|
|
5
|
+
} from '../consistency'
|
|
6
|
+
|
|
7
|
+
describe('read projection consistency flag', () => {
|
|
8
|
+
const originalEnv = process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (originalEnv === undefined) {
|
|
12
|
+
delete process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT
|
|
13
|
+
} else {
|
|
14
|
+
process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT = originalEnv
|
|
15
|
+
}
|
|
16
|
+
__resetAlwaysConsistentCacheForTests()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it.each([undefined, null, '', ' ', 'off', 'false', '0', 'no', 'disabled', 'none', 'unexpected'])(
|
|
20
|
+
'parses %p as OFF',
|
|
21
|
+
(raw) => {
|
|
22
|
+
expect(parseAlwaysConsistentEnv(raw)).toBe(false)
|
|
23
|
+
},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
it.each(['on', 'true', '1', 'yes', 'enabled'])('parses %p as ON', (raw) => {
|
|
27
|
+
expect(parseAlwaysConsistentEnv(raw)).toBe(true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('memoizes the env value until reset for tests', () => {
|
|
31
|
+
process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT = 'on'
|
|
32
|
+
expect(isReadProjectionAlwaysConsistent()).toBe(true)
|
|
33
|
+
process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT = 'off'
|
|
34
|
+
expect(isReadProjectionAlwaysConsistent()).toBe(true)
|
|
35
|
+
__resetAlwaysConsistentCacheForTests()
|
|
36
|
+
expect(isReadProjectionAlwaysConsistent()).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import type { AwilixContainer } from 'awilix'
|
|
2
2
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
3
|
import { DefaultDataEngine } from '../engine'
|
|
4
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,
|
|
6
|
+
type CrudEventsConfig,
|
|
7
|
+
type CrudIndexerConfig,
|
|
8
|
+
} from '../../crud/types'
|
|
5
9
|
|
|
6
10
|
// The bulk-import deferral (`suppress`) must gate the two per-record side effects `emitOrmEntityEvent`
|
|
7
11
|
// fans out — the `<module>.<entity>.<action>` domain event and the inline `query_index.upsert_one`
|
|
@@ -25,6 +29,15 @@ function buildEngine() {
|
|
|
25
29
|
return { engine, emitEvent, emittedNames }
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
function expectManagedDomainPayload(emitEvent: jest.Mock, eventName: string): void {
|
|
33
|
+
const call = emitEvent.mock.calls.find(([name]) => name === eventName)
|
|
34
|
+
expect(call).toBeDefined()
|
|
35
|
+
const payload = call?.[1] as Record<string, unknown>
|
|
36
|
+
expect(payload[CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY]).toBe(true)
|
|
37
|
+
expect(Object.keys(payload)).not.toContain(CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY)
|
|
38
|
+
expect(JSON.stringify(payload)).not.toContain(CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY)
|
|
39
|
+
}
|
|
40
|
+
|
|
28
41
|
describe('DefaultDataEngine bulk-import suppression', () => {
|
|
29
42
|
// The test events are intentionally not registered in the event registry; silence the
|
|
30
43
|
// one-time "undeclared event" warning so it doesn't clutter the suite output.
|
|
@@ -33,9 +46,10 @@ describe('DefaultDataEngine bulk-import suppression', () => {
|
|
|
33
46
|
afterAll(() => { warnSpy.mockRestore() })
|
|
34
47
|
|
|
35
48
|
it('emits both the domain event and the reindex when unsuppressed', async () => {
|
|
36
|
-
const { engine, emittedNames } = buildEngine()
|
|
49
|
+
const { engine, emitEvent, emittedNames } = buildEngine()
|
|
37
50
|
await engine.emitOrmEntityEvent({ action: 'created', entity: {}, events: EVENTS, indexer: INDEXER, identifiers: IDENTIFIERS })
|
|
38
51
|
expect(emittedNames()).toEqual(expect.arrayContaining(['sales.order.created', 'query_index.upsert_one']))
|
|
52
|
+
expectManagedDomainPayload(emitEvent, 'sales.order.created')
|
|
39
53
|
})
|
|
40
54
|
|
|
41
55
|
it('skips the domain event but keeps the reindex with skipEvents', async () => {
|
|
@@ -47,11 +61,12 @@ describe('DefaultDataEngine bulk-import suppression', () => {
|
|
|
47
61
|
})
|
|
48
62
|
|
|
49
63
|
it('skips the reindex but keeps the domain event with skipReindex', async () => {
|
|
50
|
-
const { engine, emittedNames } = buildEngine()
|
|
64
|
+
const { engine, emitEvent, emittedNames } = buildEngine()
|
|
51
65
|
await engine.emitOrmEntityEvent({ action: 'created', entity: {}, events: EVENTS, indexer: INDEXER, identifiers: IDENTIFIERS, suppress: { skipReindex: true } })
|
|
52
66
|
const names = emittedNames()
|
|
53
67
|
expect(names).toContain('sales.order.created')
|
|
54
68
|
expect(names).not.toContain('query_index.upsert_one')
|
|
69
|
+
expectManagedDomainPayload(emitEvent, 'sales.order.created')
|
|
55
70
|
})
|
|
56
71
|
|
|
57
72
|
it('emits nothing when both are suppressed', async () => {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { parseBooleanWithDefault } from '../boolean'
|
|
2
|
+
|
|
3
|
+
let alwaysConsistentFlag: boolean | null = null
|
|
4
|
+
|
|
5
|
+
export function parseAlwaysConsistentEnv(raw: string | undefined | null): boolean {
|
|
6
|
+
return parseBooleanWithDefault(raw, false)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isReadProjectionAlwaysConsistent(): boolean {
|
|
10
|
+
if (alwaysConsistentFlag !== null) return alwaysConsistentFlag
|
|
11
|
+
alwaysConsistentFlag = parseAlwaysConsistentEnv(process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT)
|
|
12
|
+
return alwaysConsistentFlag
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function __resetAlwaysConsistentCacheForTests(): void {
|
|
16
|
+
alwaysConsistentFlag = null
|
|
17
|
+
}
|
package/src/lib/data/engine.ts
CHANGED
|
@@ -6,11 +6,12 @@ import { setRecordCustomFields } from '@open-mercato/core/modules/entities/lib/h
|
|
|
6
6
|
import { validateCustomFieldValuesServer } from '@open-mercato/core/modules/entities/lib/validation'
|
|
7
7
|
import { sanitizeCustomFieldHtmlRichTextValuesServer } from '@open-mercato/core/modules/entities/lib/htmlRichTextSanitizer'
|
|
8
8
|
import type { EventBus } from '@open-mercato/events/types'
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
import {
|
|
10
|
+
CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,
|
|
11
|
+
type CrudEventAction,
|
|
12
|
+
type CrudEventsConfig,
|
|
13
|
+
type CrudIndexerConfig,
|
|
14
|
+
type CrudEntityIdentifiers,
|
|
14
15
|
} from '../crud/types'
|
|
15
16
|
import type { BulkImportSuppression } from '../commands/types'
|
|
16
17
|
import { CrudHttpError } from '../crud/errors'
|
|
@@ -18,6 +19,7 @@ import { resolveRegisteredEntityTableName } from '../query/engine'
|
|
|
18
19
|
import { getEntityIds } from '../encryption/entityIds'
|
|
19
20
|
import { normalizeCustomFieldValues } from '../custom-fields/normalize'
|
|
20
21
|
import { parseBooleanToken } from '../boolean'
|
|
22
|
+
import { isReadProjectionAlwaysConsistent } from './consistency'
|
|
21
23
|
import { isEventDeclared } from '../../modules/events'
|
|
22
24
|
import { createLogger } from '../logger'
|
|
23
25
|
|
|
@@ -598,7 +600,7 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
598
600
|
if (events && !suppress?.skipEvents) {
|
|
599
601
|
const eventName = `${events.module}.${events.entity}.${action}`
|
|
600
602
|
warnIfUndeclaredEvent(eventName, 'emitOrmEntityEvent')
|
|
601
|
-
const
|
|
603
|
+
const builtPayload = events.buildPayload
|
|
602
604
|
? events.buildPayload(ctx)
|
|
603
605
|
: {
|
|
604
606
|
id: ctx.identifiers.id,
|
|
@@ -606,6 +608,20 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
606
608
|
tenantId: ctx.identifiers.tenantId,
|
|
607
609
|
...(ctx.syncOrigin ? { syncOrigin: ctx.syncOrigin } : {}),
|
|
608
610
|
}
|
|
611
|
+
// A configured indexer means this data-engine call owns the query-index
|
|
612
|
+
// decision, including an explicit skipReindex suppression. Mark object
|
|
613
|
+
// payloads so the legacy domain-event bridge does not enqueue the same
|
|
614
|
+
// record a second time. Keep the marker non-enumerable so client broadcasts
|
|
615
|
+
// and persisted domain payloads retain their existing public shape.
|
|
616
|
+
// Primitive custom payloads cannot be bridged in any case because they do
|
|
617
|
+
// not expose the record id.
|
|
618
|
+
const payload = indexer && builtPayload && typeof builtPayload === 'object' && !Array.isArray(builtPayload)
|
|
619
|
+
? Object.defineProperty(
|
|
620
|
+
{ ...(builtPayload as Record<string, unknown>) },
|
|
621
|
+
CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,
|
|
622
|
+
{ value: true, enumerable: false },
|
|
623
|
+
)
|
|
624
|
+
: builtPayload
|
|
609
625
|
try {
|
|
610
626
|
await bus.emitEvent(eventName, payload, {
|
|
611
627
|
persistent: !!events.persistent,
|
|
@@ -618,6 +634,7 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
618
634
|
}
|
|
619
635
|
|
|
620
636
|
if (indexer && !suppress?.skipReindex) {
|
|
637
|
+
const alwaysConsistent = isReadProjectionAlwaysConsistent()
|
|
621
638
|
const resolveCoverageBaseDelta = (): number | undefined => {
|
|
622
639
|
if (action === 'created') return 1
|
|
623
640
|
if (action === 'deleted') return -1
|
|
@@ -643,9 +660,14 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
643
660
|
// returns. The subscriber removes the projection row + tokens synchronously and
|
|
644
661
|
// defers the coverage recompute + fulltext delete, so this stays bounded.
|
|
645
662
|
// Errors are logged, not thrown — index drift never fails the originating write.
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
663
|
+
// Always-consistent mode rethrows so drift is loud and retryable.
|
|
664
|
+
if (alwaysConsistent) {
|
|
665
|
+
await bus.emitEvent('query_index.delete_one', enrichedPayload, { rethrowHandlerErrors: true })
|
|
666
|
+
} else {
|
|
667
|
+
await bus.emitEvent('query_index.delete_one', enrichedPayload).catch((err: unknown) => {
|
|
668
|
+
logger.error('query_index.delete_one emit failed', { err })
|
|
669
|
+
})
|
|
670
|
+
}
|
|
649
671
|
} else {
|
|
650
672
|
const payload = indexer.buildUpsertPayload
|
|
651
673
|
? indexer.buildUpsertPayload(ctx)
|
|
@@ -663,18 +685,27 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
663
685
|
// (see delete_one above). The subscriber updates `entity_indexes` synchronously
|
|
664
686
|
// and defers the heavy token-reindex pipeline (build doc + encrypt + decrypt +
|
|
665
687
|
// tokenize + DELETE + chunked INSERT) so write latency stays bounded.
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
}
|
|
688
|
+
if (alwaysConsistent) {
|
|
689
|
+
await bus.emitEvent('query_index.upsert_one', enrichedPayload, { rethrowHandlerErrors: true })
|
|
690
|
+
} else {
|
|
691
|
+
await bus.emitEvent('query_index.upsert_one', enrichedPayload).catch((err: unknown) => {
|
|
692
|
+
logger.error('query_index.upsert_one emit failed', { err })
|
|
693
|
+
})
|
|
694
|
+
}
|
|
669
695
|
}
|
|
670
696
|
|
|
671
|
-
if (shouldTriggerCoverageRefresh(indexer.entityType, ctx.identifiers.tenantId ?? null)) {
|
|
672
|
-
|
|
697
|
+
if (alwaysConsistent || shouldTriggerCoverageRefresh(indexer.entityType, ctx.identifiers.tenantId ?? null)) {
|
|
698
|
+
const coveragePayload = {
|
|
673
699
|
entityType: indexer.entityType,
|
|
674
700
|
tenantId: ctx.identifiers.tenantId ?? null,
|
|
675
701
|
organizationId: null,
|
|
676
702
|
delayMs: 0,
|
|
677
|
-
}
|
|
703
|
+
}
|
|
704
|
+
if (alwaysConsistent) {
|
|
705
|
+
await bus.emitEvent('query_index.coverage.refresh', coveragePayload, { rethrowHandlerErrors: true })
|
|
706
|
+
} else {
|
|
707
|
+
void bus.emitEvent('query_index.coverage.refresh', coveragePayload).catch(() => undefined)
|
|
708
|
+
}
|
|
678
709
|
}
|
|
679
710
|
}
|
|
680
711
|
}
|
|
@@ -739,7 +770,10 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
739
770
|
indexer: entry.indexer as CrudIndexerConfig<unknown>,
|
|
740
771
|
suppress,
|
|
741
772
|
})
|
|
742
|
-
} catch {
|
|
773
|
+
} catch (error) {
|
|
774
|
+
if (isReadProjectionAlwaysConsistent()) {
|
|
775
|
+
throw error
|
|
776
|
+
}
|
|
743
777
|
// best-effort; continue with remaining side effects
|
|
744
778
|
}
|
|
745
779
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { listEntityMetadata, listEntityMetadataFromRegistry } from '../entityMetadata'
|
|
2
|
+
|
|
3
|
+
const personMeta = { className: 'Person', tableName: 'people' }
|
|
4
|
+
const orderMeta = { className: 'Order', tableName: 'orders' }
|
|
5
|
+
|
|
6
|
+
describe('listEntityMetadataFromRegistry', () => {
|
|
7
|
+
it('reads the MikroORM v7 Map returned by getAll()', () => {
|
|
8
|
+
const registry = { getAll: () => new Map([[Object, personMeta], [Array, orderMeta]]) }
|
|
9
|
+
expect(listEntityMetadataFromRegistry(registry)).toEqual([personMeta, orderMeta])
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('reads the MikroORM v6 dictionary returned by getAll()', () => {
|
|
13
|
+
const registry = { getAll: () => ({ Person: personMeta, Order: orderMeta }) }
|
|
14
|
+
expect(listEntityMetadataFromRegistry(registry)).toEqual([personMeta, orderMeta])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('falls back to the metadata property when getAll() yields nothing', () => {
|
|
18
|
+
const registry = { getAll: () => new Map(), metadata: { Person: personMeta } }
|
|
19
|
+
expect(listEntityMetadataFromRegistry(registry)).toEqual([personMeta])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns an empty array for a missing registry', () => {
|
|
23
|
+
expect(listEntityMetadataFromRegistry(null)).toEqual([])
|
|
24
|
+
expect(listEntityMetadataFromRegistry({})).toEqual([])
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('listEntityMetadata', () => {
|
|
29
|
+
it('resolves the registry through getMetadata()', () => {
|
|
30
|
+
const em = { getMetadata: () => ({ getAll: () => new Map([[Object, personMeta]]) }) }
|
|
31
|
+
expect(listEntityMetadata(em)).toEqual([personMeta])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('returns an empty array when the source exposes no registry', () => {
|
|
35
|
+
expect(listEntityMetadata({})).toEqual([])
|
|
36
|
+
expect(listEntityMetadata(undefined)).toEqual([])
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isTransientDbError, isUniqueViolation } from '../pg-errors'
|
|
2
|
+
|
|
3
|
+
describe('isTransientDbError', () => {
|
|
4
|
+
it('is true for the max_connections SQLSTATE', () => {
|
|
5
|
+
expect(isTransientDbError({ code: '53300' })).toBe(true)
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
it('is true for connection-family SQLSTATEs', () => {
|
|
9
|
+
for (const code of ['53400', '57P01', '57P02', '57P03', '08000', '08001', '08003', '08006']) {
|
|
10
|
+
expect(isTransientDbError({ code })).toBe(true)
|
|
11
|
+
}
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('is true for driver/ORM-wrapped connection messages that drop the SQLSTATE', () => {
|
|
15
|
+
expect(isTransientDbError(new Error('sorry, too many clients already'))).toBe(true)
|
|
16
|
+
expect(isTransientDbError(new Error('Knex: Timeout acquiring a connection. The pool is probably full.'))).toBe(true)
|
|
17
|
+
expect(isTransientDbError(new Error('Unable to acquire a connection'))).toBe(true)
|
|
18
|
+
expect(isTransientDbError(new Error('Connection terminated unexpectedly'))).toBe(true)
|
|
19
|
+
expect(isTransientDbError(new Error('the database system is starting up'))).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('is false for bare socket errors with no Postgres signal (avoids false positives)', () => {
|
|
23
|
+
// Generic socket failures can come from any outbound connection (HTTP, cache,
|
|
24
|
+
// queue), so they must NOT be attributed to the database.
|
|
25
|
+
expect(isTransientDbError({ code: 'ECONNREFUSED' })).toBe(false)
|
|
26
|
+
expect(isTransientDbError({ code: 'ETIMEDOUT' })).toBe(false)
|
|
27
|
+
expect(isTransientDbError(Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }))).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('is false for a unique violation and other query-level errors', () => {
|
|
31
|
+
expect(isTransientDbError({ code: '23505' })).toBe(false)
|
|
32
|
+
expect(isTransientDbError({ code: '40P01' })).toBe(false) // deadlock — not a connection failure
|
|
33
|
+
expect(isTransientDbError({ code: '40001' })).toBe(false) // serialization — not a connection failure
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('is false for non-DB and empty errors', () => {
|
|
37
|
+
expect(isTransientDbError(new Error('something unrelated broke'))).toBe(false)
|
|
38
|
+
expect(isTransientDbError(null)).toBe(false)
|
|
39
|
+
expect(isTransientDbError(undefined)).toBe(false)
|
|
40
|
+
expect(isTransientDbError('a string')).toBe(false)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('does not overlap with isUniqueViolation', () => {
|
|
44
|
+
const uniqueErr = { code: '23505', message: 'duplicate key value violates unique constraint' }
|
|
45
|
+
expect(isUniqueViolation(uniqueErr)).toBe(true)
|
|
46
|
+
expect(isTransientDbError(uniqueErr)).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { EntityMetadata } from '@mikro-orm/core'
|
|
2
|
+
|
|
3
|
+
type MetadataRegistryLike = {
|
|
4
|
+
getAll?: () => unknown
|
|
5
|
+
metadata?: unknown
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type MetadataHolder = {
|
|
9
|
+
getMetadata?: () => unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* MikroORM v7 returns a `Map` from `MetadataStorage.getAll()`; v6 returned a plain
|
|
14
|
+
* dictionary. `Object.values(someMap)` evaluates to `[]`, so a v6-era read of the
|
|
15
|
+
* registry silently reports zero entities instead of failing loudly.
|
|
16
|
+
*/
|
|
17
|
+
function toMetadataList(value: unknown): EntityMetadata[] {
|
|
18
|
+
if (!value) return []
|
|
19
|
+
if (value instanceof Map) return Array.from(value.values()) as EntityMetadata[]
|
|
20
|
+
if (Array.isArray(value)) return value as EntityMetadata[]
|
|
21
|
+
if (typeof value === 'object') return Object.values(value as Record<string, EntityMetadata>)
|
|
22
|
+
return []
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function listEntityMetadataFromRegistry(registry: unknown): EntityMetadata[] {
|
|
26
|
+
const source = registry as MetadataRegistryLike | null | undefined
|
|
27
|
+
if (!source) return []
|
|
28
|
+
if (typeof source.getAll === 'function') {
|
|
29
|
+
const entries = toMetadataList(source.getAll())
|
|
30
|
+
if (entries.length) return entries
|
|
31
|
+
}
|
|
32
|
+
return toMetadataList(source.metadata)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* List every discovered entity metadata from an `EntityManager` or `MikroORM` instance.
|
|
37
|
+
* Returns an empty array when the source exposes no metadata registry.
|
|
38
|
+
*/
|
|
39
|
+
export function listEntityMetadata(source: unknown): EntityMetadata[] {
|
|
40
|
+
const holder = source as MetadataHolder | null | undefined
|
|
41
|
+
if (!holder || typeof holder.getMetadata !== 'function') return []
|
|
42
|
+
return listEntityMetadataFromRegistry(holder.getMetadata())
|
|
43
|
+
}
|
package/src/lib/db/pg-errors.ts
CHANGED
|
@@ -10,3 +10,61 @@ export function isUniqueViolation(err: unknown): boolean {
|
|
|
10
10
|
const message = (err as { message?: string }).message
|
|
11
11
|
return typeof message === 'string' && /duplicate key value|unique constraint/i.test(message)
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Postgres SQLSTATEs for transient connection / availability failures — the
|
|
16
|
+
* database (or its connection pool) is temporarily unreachable and the request
|
|
17
|
+
* can succeed on retry. Deliberately scoped to connection/availability codes;
|
|
18
|
+
* query-level conflicts (deadlock 40P01, serialization 40001, lock_not_available
|
|
19
|
+
* 55P03) are NOT included because they do not mean "service unavailable".
|
|
20
|
+
*/
|
|
21
|
+
const TRANSIENT_CONNECTION_SQLSTATES = new Set([
|
|
22
|
+
'53300', // too_many_connections
|
|
23
|
+
'53400', // configuration_limit_exceeded
|
|
24
|
+
'57P01', // admin_shutdown
|
|
25
|
+
'57P02', // crash_shutdown
|
|
26
|
+
'57P03', // cannot_connect_now (db starting up)
|
|
27
|
+
'08000', // connection_exception
|
|
28
|
+
'08001', // sqlclient_unable_to_establish_sqlconnection
|
|
29
|
+
'08003', // connection_does_not_exist
|
|
30
|
+
'08006', // connection_failure
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Postgres-driver / connection-pool messages that describe a transient DB
|
|
35
|
+
* connection failure when the SQLSTATE is dropped by the ORM wrapper. These are
|
|
36
|
+
* intentionally DB-specific phrases only. Bare socket codes (ECONNREFUSED,
|
|
37
|
+
* ETIMEDOUT, …) are deliberately NOT matched: they can originate from any
|
|
38
|
+
* outbound socket (HTTP, cache, queue), so keying off them would falsely
|
|
39
|
+
* attribute unrelated failures to the database.
|
|
40
|
+
*/
|
|
41
|
+
const TRANSIENT_DB_MESSAGE_PATTERNS = [
|
|
42
|
+
/too many clients already/i,
|
|
43
|
+
/unable to acquire a connection/i,
|
|
44
|
+
/timeout acquiring a connection/i,
|
|
45
|
+
/connection terminated/i,
|
|
46
|
+
/the database system is (starting up|shutting down|in recovery)/i,
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Detect a transient Postgres connection / availability failure (pool exhaustion,
|
|
51
|
+
* `max_connections` reached, DB restarting) via its SQLSTATE or a DB-specific
|
|
52
|
+
* driver message. Gates retryable 503 responses so callers do not report a
|
|
53
|
+
* temporary infrastructure blip as an auth failure (401) or an unexpected server
|
|
54
|
+
* error (500). Scoped to unambiguous DB signals so generic socket errors from
|
|
55
|
+
* non-DB calls are never misclassified.
|
|
56
|
+
*/
|
|
57
|
+
export function isTransientDbError(err: unknown): boolean {
|
|
58
|
+
if (!err || typeof err !== 'object') {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
const code = (err as { code?: string }).code
|
|
62
|
+
if (typeof code === 'string' && TRANSIENT_CONNECTION_SQLSTATES.has(code)) {
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
const message = (err as { message?: string }).message
|
|
66
|
+
if (typeof message === 'string' && TRANSIENT_DB_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) {
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The app-level DI override module (`@/di`) is OPTIONAL: most apps ship no
|
|
2
|
+
// src/di.ts, and packages contexts cannot resolve the alias at all. Its
|
|
3
|
+
// absence must not produce warnings — only real load/register failures do
|
|
4
|
+
// (covered in container-app-di.test.ts). This file deliberately does NOT
|
|
5
|
+
// mock '@/di', so the import fails with module-not-found, exercising the
|
|
6
|
+
// quiet branch.
|
|
7
|
+
|
|
8
|
+
jest.mock(
|
|
9
|
+
'@open-mercato/shared/lib/db/mikro',
|
|
10
|
+
() => {
|
|
11
|
+
const baseEm: any = {
|
|
12
|
+
fork: () => baseEm,
|
|
13
|
+
getRepository: () => ({ find: async () => [], findOne: async () => null }),
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
__esModule: true,
|
|
17
|
+
getOrm: async () => ({ em: baseEm }),
|
|
18
|
+
getOrmEntities: () => [],
|
|
19
|
+
registerOrmEntities: () => {},
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{ virtual: false },
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
jest.mock(
|
|
26
|
+
'@mikro-orm/core',
|
|
27
|
+
() => ({
|
|
28
|
+
__esModule: true,
|
|
29
|
+
RequestContext: { getEntityManager: () => null },
|
|
30
|
+
}),
|
|
31
|
+
{ virtual: true },
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
jest.mock(
|
|
35
|
+
'@open-mercato/shared/lib/query/engine',
|
|
36
|
+
() => ({ __esModule: true, BasicQueryEngine: class {} }),
|
|
37
|
+
{ virtual: false },
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
jest.mock(
|
|
41
|
+
'@open-mercato/shared/lib/data/engine',
|
|
42
|
+
() => ({ __esModule: true, DefaultDataEngine: class {} }),
|
|
43
|
+
{ virtual: false },
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
jest.mock(
|
|
47
|
+
'@open-mercato/shared/lib/commands',
|
|
48
|
+
() => ({
|
|
49
|
+
__esModule: true,
|
|
50
|
+
commandRegistry: {},
|
|
51
|
+
CommandBus: class { constructor() {} },
|
|
52
|
+
}),
|
|
53
|
+
{ virtual: false },
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
jest.mock(
|
|
57
|
+
'@open-mercato/shared/modules/overrides',
|
|
58
|
+
() => ({
|
|
59
|
+
__esModule: true,
|
|
60
|
+
applyDiOverridesToContainer: () => {},
|
|
61
|
+
}),
|
|
62
|
+
{ virtual: false },
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
// Keep this suite hermetic: without this mock the REAL core bootstrap module
|
|
66
|
+
// loads (and runs its module-level side effects) whenever generated files
|
|
67
|
+
// exist on disk, polluting globalThis state shared with sibling test files.
|
|
68
|
+
jest.mock(
|
|
69
|
+
'@open-mercato/core/bootstrap',
|
|
70
|
+
() => ({
|
|
71
|
+
__esModule: true,
|
|
72
|
+
bootstrap: async () => {},
|
|
73
|
+
}),
|
|
74
|
+
{ virtual: true },
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
jest.mock(
|
|
78
|
+
'@open-mercato/shared/lib/encryption/subscriber',
|
|
79
|
+
() => ({
|
|
80
|
+
__esModule: true,
|
|
81
|
+
registerTenantEncryptionSubscriber: () => {},
|
|
82
|
+
}),
|
|
83
|
+
{ virtual: true },
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const mockWarn = jest.fn()
|
|
87
|
+
const mockDebug = jest.fn()
|
|
88
|
+
jest.mock('../../logger', () => ({
|
|
89
|
+
createLogger: () => ({
|
|
90
|
+
debug: (...args: unknown[]) => mockDebug(...args),
|
|
91
|
+
info: jest.fn(),
|
|
92
|
+
warn: (...args: unknown[]) => mockWarn(...args),
|
|
93
|
+
error: jest.fn(),
|
|
94
|
+
child() { return this },
|
|
95
|
+
}),
|
|
96
|
+
}))
|
|
97
|
+
|
|
98
|
+
const {
|
|
99
|
+
createRequestContainer,
|
|
100
|
+
registerAppDiRegistrar,
|
|
101
|
+
registerDiRegistrars,
|
|
102
|
+
resetBootstrapCache,
|
|
103
|
+
} = require('@open-mercato/shared/lib/di/container')
|
|
104
|
+
|
|
105
|
+
describe('app-level DI override hook when @/di is absent', () => {
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
resetBootstrapCache()
|
|
108
|
+
registerAppDiRegistrar(null)
|
|
109
|
+
registerDiRegistrars([])
|
|
110
|
+
mockWarn.mockClear()
|
|
111
|
+
mockDebug.mockClear()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('creates the container without warning', async () => {
|
|
115
|
+
const firstContainer = await createRequestContainer()
|
|
116
|
+
const secondContainer = await createRequestContainer()
|
|
117
|
+
expect(firstContainer).toBeDefined()
|
|
118
|
+
expect(secondContainer).toBeDefined()
|
|
119
|
+
expect(mockWarn).not.toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Worker and CLI processes create one request container per job against built package
|
|
123
|
+
// output, where the app's `@/` alias does not exist. Retrying the doomed import per
|
|
124
|
+
// container repeats a failed module resolution — and its log line — once per job.
|
|
125
|
+
it('stops retrying the unresolvable @/di import after the first container', async () => {
|
|
126
|
+
await createRequestContainer()
|
|
127
|
+
await createRequestContainer()
|
|
128
|
+
await createRequestContainer()
|
|
129
|
+
|
|
130
|
+
const unresolvableLogs = mockDebug.mock.calls.filter(
|
|
131
|
+
([message]) => message === 'App-level DI override module (@/di) not resolvable; skipping',
|
|
132
|
+
)
|
|
133
|
+
expect(unresolvableLogs).toHaveLength(1)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('warns once when @/di fails because a nested app alias is missing', async () => {
|
|
137
|
+
const nestedAliasError = Object.assign(new Error("Cannot find module '@/di/helpers'"), {
|
|
138
|
+
code: 'MODULE_NOT_FOUND',
|
|
139
|
+
})
|
|
140
|
+
jest.doMock('@/di', () => {
|
|
141
|
+
throw nestedAliasError
|
|
142
|
+
}, { virtual: true })
|
|
143
|
+
|
|
144
|
+
const firstContainer = await createRequestContainer()
|
|
145
|
+
const secondContainer = await createRequestContainer()
|
|
146
|
+
|
|
147
|
+
expect(firstContainer).toBeDefined()
|
|
148
|
+
expect(secondContainer).toBeDefined()
|
|
149
|
+
expect(mockWarn).toHaveBeenCalledTimes(1)
|
|
150
|
+
expect(mockWarn).toHaveBeenCalledWith(
|
|
151
|
+
'App-level DI override module (@/di) failed to load; its registrations are skipped',
|
|
152
|
+
{ err: nestedAliasError },
|
|
153
|
+
)
|
|
154
|
+
})
|
|
155
|
+
})
|