@open-mercato/shared 0.6.7-develop.6860.1.f796b22e80 → 0.6.7
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 +6 -57
- package/build.mjs +4 -2
- package/dist/lib/auth/server.js +2 -15
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/dynamicLoader.js +43 -117
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/commands/command-bus.js +1 -5
- 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 +11 -2
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +4 -63
- 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 +0 -4
- package/dist/lib/crud/types.js.map +3 -3
- package/dist/lib/data/engine.js +11 -37
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/mikro.js +12 -3
- package/dist/lib/db/mikro.js.map +2 -2
- package/dist/lib/db/pg-errors.js +0 -42
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/di/container.js +6 -48
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/email/send.js +0 -56
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +10 -2
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +17 -52
- 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/index.js +3 -47
- package/dist/lib/logger/index.js.map +3 -3
- package/dist/lib/modules/registry.js +1 -5
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/query/engine.js +40 -54
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/redis/connection.js +2 -7
- package/dist/lib/redis/connection.js.map +2 -2
- package/dist/lib/search/config.js +2 -61
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenize.js +11 -23
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/index.js +1 -13
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/lib/webhooks/verify.js +7 -12
- package/dist/lib/webhooks/verify.js.map +2 -2
- package/dist/modules/analytics.js.map +2 -2
- package/dist/modules/overrides.js +1 -50
- package/dist/modules/overrides.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +1 -8
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js +0 -1
- package/dist/modules/registry.js.map +2 -2
- package/dist/security/enabledModulesRegistry.js +11 -56
- package/dist/security/enabledModulesRegistry.js.map +2 -2
- package/jest.config.cjs +1 -2
- package/package.json +9 -19
- package/src/lib/ai/llm-provider.ts +0 -33
- package/src/lib/auth/__tests__/server.test.ts +0 -23
- package/src/lib/auth/server.ts +2 -23
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +8 -63
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +1 -21
- package/src/lib/bootstrap/dynamicLoader.ts +65 -213
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +0 -28
- package/src/lib/commands/command-bus.ts +1 -5
- package/src/lib/commands/command-interceptor-runner.ts +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +0 -298
- package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +0 -26
- package/src/lib/crud/enricher-runner.ts +11 -2
- package/src/lib/crud/factory.ts +1 -69
- 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 +0 -3
- package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +3 -18
- package/src/lib/data/engine.ts +16 -50
- package/src/lib/db/__tests__/mikro.test.ts +25 -0
- package/src/lib/db/mikro.ts +38 -13
- package/src/lib/db/pg-errors.ts +0 -58
- package/src/lib/di/container.ts +8 -72
- package/src/lib/email/__tests__/send.test.ts +1 -37
- package/src/lib/email/send.ts +0 -79
- package/src/lib/encryption/__tests__/subscriber.test.ts +2 -54
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +0 -58
- package/src/lib/encryption/subscriber.ts +14 -2
- package/src/lib/encryption/tenantDataEncryptionService.ts +18 -64
- package/src/lib/i18n/config.ts +2 -2
- package/src/lib/i18n/server.ts +2 -6
- package/src/lib/logger/__tests__/logger.test.ts +0 -77
- package/src/lib/logger/index.ts +1 -59
- package/src/lib/modules/registry.ts +0 -10
- package/src/lib/query/__tests__/engine.test.ts +13 -65
- package/src/lib/query/engine.ts +58 -75
- package/src/lib/query/types.ts +5 -4
- package/src/lib/redis/__tests__/connection.test.ts +0 -16
- package/src/lib/redis/connection.ts +7 -10
- package/src/lib/search/__tests__/config.test.ts +0 -177
- package/src/lib/search/config.ts +8 -106
- package/src/lib/search/tokenize.ts +11 -33
- package/src/lib/webhooks/__tests__/verify.test.ts +1 -20
- package/src/lib/webhooks/index.ts +1 -19
- package/src/lib/webhooks/verify.ts +9 -17
- package/src/modules/__tests__/registry.test.ts +2 -10
- package/src/modules/analytics.ts +0 -7
- package/src/modules/customer-auth.ts +0 -1
- package/src/modules/encryption.ts +0 -3
- package/src/modules/navigation/backendChrome.ts +0 -15
- package/src/modules/overrides.ts +0 -103
- package/src/modules/payment_gateways/types.ts +0 -41
- package/src/modules/registry.ts +0 -3
- package/src/security/enabledModulesRegistry.ts +12 -64
- package/dist/lib/ai/safety-identifier.js +0 -32
- package/dist/lib/ai/safety-identifier.js.map +0 -7
- package/dist/lib/auth/organizationScope.js +0 -34
- package/dist/lib/auth/organizationScope.js.map +0 -7
- package/dist/lib/bootstrap/clientOnlyModules.js +0 -55
- package/dist/lib/bootstrap/clientOnlyModules.js.map +0 -7
- package/dist/lib/data/consistency.js +0 -19
- package/dist/lib/data/consistency.js.map +0 -7
- package/dist/lib/db/entityMetadata.js +0 -26
- package/dist/lib/db/entityMetadata.js.map +0 -7
- package/dist/lib/logger/extension.js +0 -29
- package/dist/lib/logger/extension.js.map +0 -7
- package/dist/lib/modules/surfaceFingerprint.js +0 -47
- package/dist/lib/modules/surfaceFingerprint.js.map +0 -7
- package/dist/lib/query/ciphertext-search-warning.js +0 -45
- package/dist/lib/query/ciphertext-search-warning.js.map +0 -7
- package/dist/lib/search/auto-indexing.js +0 -14
- package/dist/lib/search/auto-indexing.js.map +0 -7
- package/dist/lib/search/availability.js +0 -107
- package/dist/lib/search/availability.js.map +0 -7
- package/dist/lib/search/tokenLookup.js +0 -46
- package/dist/lib/search/tokenLookup.js.map +0 -7
- package/dist/lib/telemetry/runtime.js +0 -35
- package/dist/lib/telemetry/runtime.js.map +0 -7
- package/dist/lib/webhooks/body.js +0 -57
- package/dist/lib/webhooks/body.js.map +0 -7
- package/dist/lib/webhooks/inbound-types.js +0 -1
- package/dist/lib/webhooks/inbound-types.js.map +0 -7
- package/dist/modules/widgets/extension-points.js +0 -96
- package/dist/modules/widgets/extension-points.js.map +0 -7
- package/dist/security/featurePolicy.js +0 -62
- package/dist/security/featurePolicy.js.map +0 -7
- package/scripts/versionSource.cjs +0 -65
- package/src/lib/__tests__/versionSource.test.ts +0 -46
- package/src/lib/ai/__tests__/llm-provider-contract.test.ts +0 -59
- package/src/lib/ai/__tests__/safety-identifier.test.ts +0 -71
- package/src/lib/ai/safety-identifier.ts +0 -73
- package/src/lib/auth/__tests__/organizationScope.test.ts +0 -107
- package/src/lib/auth/organizationScope.ts +0 -65
- package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +0 -189
- package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +0 -98
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +0 -211
- package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +0 -192
- package/src/lib/bootstrap/clientOnlyModules.ts +0 -85
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +0 -171
- package/src/lib/data/__tests__/consistency.test.ts +0 -38
- package/src/lib/data/consistency.ts +0 -17
- package/src/lib/db/__tests__/entityMetadata.test.ts +0 -38
- package/src/lib/db/__tests__/pg-errors.test.ts +0 -48
- package/src/lib/db/entityMetadata.ts +0 -43
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +0 -155
- package/src/lib/di/__tests__/container-app-di.test.ts +0 -173
- package/src/lib/di/__tests__/registrar-error-log.test.ts +0 -154
- package/src/lib/i18n/__tests__/config.test.ts +0 -8
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +0 -30
- package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +0 -29
- package/src/lib/logger/extension.ts +0 -59
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +0 -122
- package/src/lib/modules/surfaceFingerprint.ts +0 -87
- package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +0 -178
- package/src/lib/query/ciphertext-search-warning.ts +0 -95
- package/src/lib/search/__tests__/availability.test.ts +0 -211
- package/src/lib/search/__tests__/tokenLookup.test.ts +0 -206
- package/src/lib/search/__tests__/tokenize.test.ts +0 -49
- package/src/lib/search/auto-indexing.ts +0 -22
- package/src/lib/search/availability.ts +0 -232
- package/src/lib/search/tokenLookup.ts +0 -133
- package/src/lib/telemetry/runtime.ts +0 -72
- package/src/lib/webhooks/__tests__/body.test.ts +0 -95
- package/src/lib/webhooks/body.ts +0 -70
- package/src/lib/webhooks/inbound-types.ts +0 -125
- package/src/modules/__tests__/nav-group-order-override.test.ts +0 -183
- package/src/modules/payment_gateways/__tests__/types.test.ts +0 -37
- package/src/modules/widgets/__tests__/extension-points.test.ts +0 -101
- package/src/modules/widgets/extension-points.ts +0 -524
- package/src/security/__tests__/featurePolicy.test.ts +0 -166
- package/src/security/featurePolicy.ts +0 -89
package/src/lib/data/engine.ts
CHANGED
|
@@ -6,12 +6,11 @@ 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
|
-
|
|
14
|
-
type CrudEntityIdentifiers,
|
|
9
|
+
import type {
|
|
10
|
+
CrudEventAction,
|
|
11
|
+
CrudEventsConfig,
|
|
12
|
+
CrudIndexerConfig,
|
|
13
|
+
CrudEntityIdentifiers,
|
|
15
14
|
} from '../crud/types'
|
|
16
15
|
import type { BulkImportSuppression } from '../commands/types'
|
|
17
16
|
import { CrudHttpError } from '../crud/errors'
|
|
@@ -19,7 +18,6 @@ import { resolveRegisteredEntityTableName } from '../query/engine'
|
|
|
19
18
|
import { getEntityIds } from '../encryption/entityIds'
|
|
20
19
|
import { normalizeCustomFieldValues } from '../custom-fields/normalize'
|
|
21
20
|
import { parseBooleanToken } from '../boolean'
|
|
22
|
-
import { isReadProjectionAlwaysConsistent } from './consistency'
|
|
23
21
|
import { isEventDeclared } from '../../modules/events'
|
|
24
22
|
import { createLogger } from '../logger'
|
|
25
23
|
|
|
@@ -600,7 +598,7 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
600
598
|
if (events && !suppress?.skipEvents) {
|
|
601
599
|
const eventName = `${events.module}.${events.entity}.${action}`
|
|
602
600
|
warnIfUndeclaredEvent(eventName, 'emitOrmEntityEvent')
|
|
603
|
-
const
|
|
601
|
+
const payload = events.buildPayload
|
|
604
602
|
? events.buildPayload(ctx)
|
|
605
603
|
: {
|
|
606
604
|
id: ctx.identifiers.id,
|
|
@@ -608,20 +606,6 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
608
606
|
tenantId: ctx.identifiers.tenantId,
|
|
609
607
|
...(ctx.syncOrigin ? { syncOrigin: ctx.syncOrigin } : {}),
|
|
610
608
|
}
|
|
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
|
|
625
609
|
try {
|
|
626
610
|
await bus.emitEvent(eventName, payload, {
|
|
627
611
|
persistent: !!events.persistent,
|
|
@@ -634,7 +618,6 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
634
618
|
}
|
|
635
619
|
|
|
636
620
|
if (indexer && !suppress?.skipReindex) {
|
|
637
|
-
const alwaysConsistent = isReadProjectionAlwaysConsistent()
|
|
638
621
|
const resolveCoverageBaseDelta = (): number | undefined => {
|
|
639
622
|
if (action === 'created') return 1
|
|
640
623
|
if (action === 'deleted') return -1
|
|
@@ -660,14 +643,9 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
660
643
|
// returns. The subscriber removes the projection row + tokens synchronously and
|
|
661
644
|
// defers the coverage recompute + fulltext delete, so this stays bounded.
|
|
662
645
|
// Errors are logged, not thrown — index drift never fails the originating write.
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
}
|
|
646
|
+
await bus.emitEvent('query_index.delete_one', enrichedPayload).catch((err: unknown) => {
|
|
647
|
+
logger.error('query_index.delete_one emit failed', { err })
|
|
648
|
+
})
|
|
671
649
|
} else {
|
|
672
650
|
const payload = indexer.buildUpsertPayload
|
|
673
651
|
? indexer.buildUpsertPayload(ctx)
|
|
@@ -685,27 +663,18 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
685
663
|
// (see delete_one above). The subscriber updates `entity_indexes` synchronously
|
|
686
664
|
// and defers the heavy token-reindex pipeline (build doc + encrypt + decrypt +
|
|
687
665
|
// tokenize + DELETE + chunked INSERT) so write latency stays bounded.
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
}
|
|
691
|
-
await bus.emitEvent('query_index.upsert_one', enrichedPayload).catch((err: unknown) => {
|
|
692
|
-
logger.error('query_index.upsert_one emit failed', { err })
|
|
693
|
-
})
|
|
694
|
-
}
|
|
666
|
+
await bus.emitEvent('query_index.upsert_one', enrichedPayload).catch((err: unknown) => {
|
|
667
|
+
logger.error('query_index.upsert_one emit failed', { err })
|
|
668
|
+
})
|
|
695
669
|
}
|
|
696
670
|
|
|
697
|
-
if (
|
|
698
|
-
|
|
671
|
+
if (shouldTriggerCoverageRefresh(indexer.entityType, ctx.identifiers.tenantId ?? null)) {
|
|
672
|
+
void bus.emitEvent('query_index.coverage.refresh', {
|
|
699
673
|
entityType: indexer.entityType,
|
|
700
674
|
tenantId: ctx.identifiers.tenantId ?? null,
|
|
701
675
|
organizationId: null,
|
|
702
676
|
delayMs: 0,
|
|
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
|
-
}
|
|
677
|
+
}).catch(() => undefined)
|
|
709
678
|
}
|
|
710
679
|
}
|
|
711
680
|
}
|
|
@@ -770,10 +739,7 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
770
739
|
indexer: entry.indexer as CrudIndexerConfig<unknown>,
|
|
771
740
|
suppress,
|
|
772
741
|
})
|
|
773
|
-
} catch
|
|
774
|
-
if (isReadProjectionAlwaysConsistent()) {
|
|
775
|
-
throw error
|
|
776
|
-
}
|
|
742
|
+
} catch {
|
|
777
743
|
// best-effort; continue with remaining side effects
|
|
778
744
|
}
|
|
779
745
|
}
|
|
@@ -24,6 +24,31 @@ describe('ORM entity registry', () => {
|
|
|
24
24
|
})
|
|
25
25
|
})
|
|
26
26
|
|
|
27
|
+
describe('attachPoolErrorHandlers', () => {
|
|
28
|
+
it('swallows errors from idle pooled clients (pool-level emit)', async () => {
|
|
29
|
+
const { EventEmitter } = await import('node:events')
|
|
30
|
+
const { attachPoolErrorHandlers } = await import('../mikro')
|
|
31
|
+
const pool = new EventEmitter()
|
|
32
|
+
|
|
33
|
+
attachPoolErrorHandlers(pool as any)
|
|
34
|
+
|
|
35
|
+
expect(() => pool.emit('error', new Error('terminating connection due to idle-in-transaction timeout'))).not.toThrow()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('swallows errors from checked-out clients (client-level emit)', async () => {
|
|
39
|
+
const { EventEmitter } = await import('node:events')
|
|
40
|
+
const { attachPoolErrorHandlers } = await import('../mikro')
|
|
41
|
+
const pool = new EventEmitter()
|
|
42
|
+
const client = new EventEmitter()
|
|
43
|
+
|
|
44
|
+
attachPoolErrorHandlers(pool as any)
|
|
45
|
+
pool.emit('connect', client)
|
|
46
|
+
|
|
47
|
+
expect(client.listenerCount('error')).toBe(1)
|
|
48
|
+
expect(() => client.emit('error', new Error('terminating connection due to idle-in-transaction timeout'))).not.toThrow()
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
27
52
|
describe('resolvePoolConfig', () => {
|
|
28
53
|
const baseEnv = (extra: Record<string, string | undefined> = {}): NodeJS.ProcessEnv =>
|
|
29
54
|
({ ...extra }) as NodeJS.ProcessEnv
|
package/src/lib/db/mikro.ts
CHANGED
|
@@ -79,6 +79,42 @@ export function resolvePoolConfig(env: NodeJS.ProcessEnv = process.env): Resolve
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
type PoolLike = {
|
|
83
|
+
on(event: 'error', listener: (err: unknown) => void): unknown
|
|
84
|
+
on(event: 'connect', listener: (client: { on(event: 'error', listener: (err: unknown) => void): unknown }) => void): unknown
|
|
85
|
+
options?: Record<string, unknown>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Postgres can terminate a connection at any moment (admin termination, network
|
|
89
|
+
// drop, and — most relevantly for long-running daemons — the
|
|
90
|
+
// `idle_in_transaction_session_timeout` configured above, FATAL 25P03). Where
|
|
91
|
+
// node-postgres surfaces that depends on the client's state:
|
|
92
|
+
// - IDLE (checked into the pool): pg-pool re-emits on the pool's 'error' event.
|
|
93
|
+
// - CHECKED OUT (e.g. a connection pinned by an open transaction while the app
|
|
94
|
+
// awaits non-DB work): pg-pool removes its idle listener, so the FATAL emits
|
|
95
|
+
// on the Client itself.
|
|
96
|
+
// Either way an unlistened 'error' event crashes the whole process ("Scheduler
|
|
97
|
+
// polling engine exited unexpectedly with exit code 1"). Swallow both: the pool
|
|
98
|
+
// discards the dead client, and any in-flight transaction still fails normally
|
|
99
|
+
// on its next query/commit against the dead connection.
|
|
100
|
+
// The per-client listener is deliberately attached once on 'connect' and never
|
|
101
|
+
// removed: it is a last-resort sink whose only job is to guarantee the 'error'
|
|
102
|
+
// event always has a listener, in every client state. It is not error handling
|
|
103
|
+
// and must not be "cleaned up" — removing it reintroduces the process crash.
|
|
104
|
+
// A reaped IDLE client therefore logs twice (once here, once via the pool-level
|
|
105
|
+
// handler that pg-pool's own idle listener re-emits); the pool-level line is the
|
|
106
|
+
// one that identifies the client as idle.
|
|
107
|
+
export function attachPoolErrorHandlers(pool: PoolLike): void {
|
|
108
|
+
pool.on('error', (err: unknown) => {
|
|
109
|
+
logger.warn('Idle pg pool client error (connection reaped/terminated)', { err })
|
|
110
|
+
})
|
|
111
|
+
pool.on('connect', (client) => {
|
|
112
|
+
client.on('error', (err: unknown) => {
|
|
113
|
+
logger.warn('pg client error (connection reaped/terminated)', { err })
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
82
118
|
export async function getOrm() {
|
|
83
119
|
if (ormInstance) {
|
|
84
120
|
return ormInstance
|
|
@@ -151,19 +187,8 @@ export async function getOrm() {
|
|
|
151
187
|
lock_timeout: lockTimeoutMs,
|
|
152
188
|
options: connectionOptions,
|
|
153
189
|
ssl: sslConfig,
|
|
154
|
-
onPoolCreated: (pool:
|
|
155
|
-
|
|
156
|
-
// 'error' event. Postgres terminates idle sessions on its own (admin
|
|
157
|
-
// termination, network drop, and — most relevantly for long-running
|
|
158
|
-
// daemons like the scheduler — `idle_in_transaction_session_timeout`,
|
|
159
|
-
// which defaults to 120s above). Without a listener here, such a
|
|
160
|
-
// termination surfaces as an unhandled 'error' event and crashes the
|
|
161
|
-
// whole process (e.g. "Scheduler polling engine exited unexpectedly
|
|
162
|
-
// with exit code 1"). Swallow it: the pool discards the dead client and
|
|
163
|
-
// opens a fresh connection on the next acquire.
|
|
164
|
-
pool.on('error', (err: unknown) => {
|
|
165
|
-
logger.warn('Idle pg pool client error (connection reaped/terminated)', { err })
|
|
166
|
-
})
|
|
190
|
+
onPoolCreated: (pool: PoolLike) => {
|
|
191
|
+
attachPoolErrorHandlers(pool)
|
|
167
192
|
if (process.env.OM_DB_POOL_DEBUG === '1' || process.env.OM_INTEGRATION_TEST === 'true') {
|
|
168
193
|
logger.info('pg pool created with options', {
|
|
169
194
|
max: pool.options?.max,
|
package/src/lib/db/pg-errors.ts
CHANGED
|
@@ -10,61 +10,3 @@ 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
|
-
}
|
package/src/lib/di/container.ts
CHANGED
|
@@ -24,13 +24,6 @@ export type AppDiRegistrar = (container: AppContainer) => void | Promise<void>
|
|
|
24
24
|
// file can be loaded as multiple module instances when mixing dynamic and static imports
|
|
25
25
|
const GLOBAL_KEY = '__openMercatoDiRegistrars__'
|
|
26
26
|
const APP_DI_REGISTRAR_KEY = '__openMercatoAppDiRegistrar__'
|
|
27
|
-
const APP_DI_LOAD_WARNING_KEY = '__openMercatoAppDiLoadWarningEmitted__'
|
|
28
|
-
const APP_DI_REGISTER_WARNING_KEY = '__openMercatoAppDiRegisterWarningEmitted__'
|
|
29
|
-
// Set once the legacy `@/di` fallback proves the specifier is unresolvable in this process.
|
|
30
|
-
// Worker/CLI processes run against built package output where the app's `@/` alias does not
|
|
31
|
-
// exist, so retrying the import per request container only repeats a failed module resolution
|
|
32
|
-
// (and its log line) once per job.
|
|
33
|
-
const APP_DI_MODULE_UNRESOLVABLE_KEY = '__openMercatoAppDiModuleUnresolvable__'
|
|
34
27
|
// Phase 5 — process-scoped bootstrap cache. The cache/event-bus/encryption
|
|
35
28
|
// services bootstrap() creates are inherently process-scoped (they hold
|
|
36
29
|
// state across requests). Caching them on globalThis after the first
|
|
@@ -125,9 +118,6 @@ export function registerDiRegistrars(registrars: DiRegistrar[]) {
|
|
|
125
118
|
// Force re-bootstrap on HMR — module subscribers may have changed.
|
|
126
119
|
;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = null
|
|
127
120
|
;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined
|
|
128
|
-
// An app that gains a src/di.ts mid-session reloads through here, so the negative
|
|
129
|
-
// resolution result must not outlive the reload.
|
|
130
|
-
;(globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] = undefined
|
|
131
121
|
}
|
|
132
122
|
|
|
133
123
|
export function getDiRegistrars(): DiRegistrar[] {
|
|
@@ -147,44 +137,10 @@ export function registerAppDiRegistrar(registrar: AppDiRegistrar | null): void {
|
|
|
147
137
|
;(globalThis as Record<string, unknown>)[APP_DI_REGISTRAR_KEY] = registrar
|
|
148
138
|
}
|
|
149
139
|
|
|
150
|
-
/** Test-only helper to drop process-scoped
|
|
140
|
+
/** Test-only helper to drop the process-scoped bootstrap cache. */
|
|
151
141
|
export function resetBootstrapCache(): void {
|
|
152
142
|
(globalThis as any)[BOOTSTRAP_CACHE_KEY] = null
|
|
153
143
|
;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined
|
|
154
|
-
;(globalThis as Record<string, unknown>)[APP_DI_LOAD_WARNING_KEY] = undefined
|
|
155
|
-
;(globalThis as Record<string, unknown>)[APP_DI_REGISTER_WARNING_KEY] = undefined
|
|
156
|
-
;(globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] = undefined
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function isAppDiModuleUnresolvable(): boolean {
|
|
160
|
-
return (globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] === true
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function markAppDiModuleUnresolvable(): void {
|
|
164
|
-
;(globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] = true
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function isAppDiModuleNotFound(error: unknown): boolean {
|
|
168
|
-
if (!error || typeof error !== 'object') return false
|
|
169
|
-
const { code, message } = error as { code?: unknown; message?: unknown }
|
|
170
|
-
const text = typeof message === 'string' ? message : ''
|
|
171
|
-
const moduleNotFound =
|
|
172
|
-
code === 'MODULE_NOT_FOUND'
|
|
173
|
-
|| code === 'ERR_MODULE_NOT_FOUND'
|
|
174
|
-
|| text.startsWith('Cannot find module')
|
|
175
|
-
|| text.startsWith('Cannot find package')
|
|
176
|
-
return moduleNotFound && /(?:module|package) ['"]@\/di['"]/.test(text)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function warnAppDiFailureOnce(
|
|
180
|
-
key: typeof APP_DI_LOAD_WARNING_KEY | typeof APP_DI_REGISTER_WARNING_KEY,
|
|
181
|
-
message: string,
|
|
182
|
-
error: unknown,
|
|
183
|
-
): void {
|
|
184
|
-
const globalScope = globalThis as Record<string, unknown>
|
|
185
|
-
if (globalScope[key] === true) return
|
|
186
|
-
globalScope[key] = true
|
|
187
|
-
logger.warn(message, { err: error })
|
|
188
144
|
}
|
|
189
145
|
|
|
190
146
|
function isAwilixResolver(value: unknown): value is Resolver<unknown> {
|
|
@@ -241,14 +197,9 @@ export async function createRequestContainer(): Promise<AppContainer> {
|
|
|
241
197
|
createCommandOptimisticLockGuardService(),
|
|
242
198
|
).scoped(),
|
|
243
199
|
})
|
|
244
|
-
// Allow modules to override/extend
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
// Awilix resolution error surfaces much later.
|
|
248
|
-
for (const [registrarIndex, reg] of diRegistrars.entries()) {
|
|
249
|
-
try { reg?.(container) } catch (error) {
|
|
250
|
-
logger.error('Module DI registrar failed', { registrarIndex, err: error })
|
|
251
|
-
}
|
|
200
|
+
// Allow modules to override/extend
|
|
201
|
+
for (const reg of diRegistrars) {
|
|
202
|
+
try { reg?.(container) } catch {}
|
|
252
203
|
}
|
|
253
204
|
// Core bootstrap (cache, event bus, encryption subscriber/KMS, module subscribers)
|
|
254
205
|
// Phase 5 — process-scoped once-guard. The first request runs the full
|
|
@@ -284,7 +235,7 @@ export async function createRequestContainer(): Promise<AppContainer> {
|
|
|
284
235
|
} catch (error) {
|
|
285
236
|
logger.error('App-level DI registrar failed', { err: error })
|
|
286
237
|
}
|
|
287
|
-
} else
|
|
238
|
+
} else {
|
|
288
239
|
// Backward-compatible fallback for apps that have not adopted explicit wiring.
|
|
289
240
|
try {
|
|
290
241
|
// @ts-ignore - @/di only exists in app context, not in packages
|
|
@@ -293,26 +244,11 @@ export async function createRequestContainer(): Promise<AppContainer> {
|
|
|
293
244
|
try {
|
|
294
245
|
const maybe = appDi.register(container)
|
|
295
246
|
if (maybe && typeof maybe.then === 'function') await maybe
|
|
296
|
-
} catch (
|
|
297
|
-
|
|
298
|
-
APP_DI_REGISTER_WARNING_KEY,
|
|
299
|
-
'App-level DI override (src/di.ts register()) threw; its registrations are skipped',
|
|
300
|
-
err,
|
|
301
|
-
)
|
|
247
|
+
} catch (error) {
|
|
248
|
+
logger.error('App-level DI registrar failed', { err: error })
|
|
302
249
|
}
|
|
303
250
|
}
|
|
304
|
-
} catch
|
|
305
|
-
if (isAppDiModuleNotFound(err)) {
|
|
306
|
-
markAppDiModuleUnresolvable()
|
|
307
|
-
logger.debug('App-level DI override module (@/di) not resolvable; skipping', { err })
|
|
308
|
-
} else {
|
|
309
|
-
warnAppDiFailureOnce(
|
|
310
|
-
APP_DI_LOAD_WARNING_KEY,
|
|
311
|
-
'App-level DI override module (@/di) failed to load; its registrations are skipped',
|
|
312
|
-
err,
|
|
313
|
-
)
|
|
314
|
-
}
|
|
315
|
-
}
|
|
251
|
+
} catch {}
|
|
316
252
|
}
|
|
317
253
|
applyDiOverridesToContainer({
|
|
318
254
|
register: (registrations) => container.register(toAwilixRegistrations(registrations)),
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
3
|
-
import { join } from 'node:path'
|
|
4
|
-
import { tmpdir } from 'node:os'
|
|
5
2
|
import { sendEmail } from '../send'
|
|
6
3
|
|
|
7
4
|
var sendMock: jest.Mock
|
|
@@ -18,7 +15,6 @@ jest.mock('resend', () => {
|
|
|
18
15
|
|
|
19
16
|
describe('sendEmail', () => {
|
|
20
17
|
const originalEnv = process.env
|
|
21
|
-
let tempDir: string | null = null
|
|
22
18
|
|
|
23
19
|
beforeEach(() => {
|
|
24
20
|
process.env = {
|
|
@@ -30,11 +26,7 @@ describe('sendEmail', () => {
|
|
|
30
26
|
ResendMock.mockClear()
|
|
31
27
|
})
|
|
32
28
|
|
|
33
|
-
afterEach(
|
|
34
|
-
if (tempDir) {
|
|
35
|
-
await rm(tempDir, { recursive: true, force: true })
|
|
36
|
-
tempDir = null
|
|
37
|
-
}
|
|
29
|
+
afterEach(() => {
|
|
38
30
|
process.env = originalEnv
|
|
39
31
|
})
|
|
40
32
|
|
|
@@ -166,32 +158,4 @@ describe('sendEmail', () => {
|
|
|
166
158
|
expect(ResendMock).not.toHaveBeenCalled()
|
|
167
159
|
expect(sendMock).not.toHaveBeenCalled()
|
|
168
160
|
})
|
|
169
|
-
|
|
170
|
-
it('captures email links in OM_TEST_MODE without external delivery', async () => {
|
|
171
|
-
tempDir = await mkdtemp(join(tmpdir(), 'om-email-capture-'))
|
|
172
|
-
const capturePath = join(tempDir, 'emails.jsonl')
|
|
173
|
-
process.env.OM_TEST_MODE = '1'
|
|
174
|
-
process.env.OM_TEST_EMAIL_CAPTURE_PATH = capturePath
|
|
175
|
-
delete process.env.RESEND_API_KEY
|
|
176
|
-
|
|
177
|
-
await sendEmail({
|
|
178
|
-
to: 'user@example.com',
|
|
179
|
-
subject: 'Invite',
|
|
180
|
-
react: React.createElement('div', null, [
|
|
181
|
-
React.createElement('p', { key: 'text' }, 'Accept your invite'),
|
|
182
|
-
React.createElement('a', { key: 'link', href: 'https://example.com/portal/invite?token=raw' }, 'Accept'),
|
|
183
|
-
]),
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
const rows = (await readFile(capturePath, 'utf8')).trim().split('\n')
|
|
187
|
-
expect(rows).toHaveLength(1)
|
|
188
|
-
expect(JSON.parse(rows[0])).toEqual(expect.objectContaining({
|
|
189
|
-
to: 'user@example.com',
|
|
190
|
-
subject: 'Invite',
|
|
191
|
-
links: ['https://example.com/portal/invite?token=raw'],
|
|
192
|
-
text: 'Accept your invite Accept',
|
|
193
|
-
}))
|
|
194
|
-
expect(ResendMock).not.toHaveBeenCalled()
|
|
195
|
-
expect(sendMock).not.toHaveBeenCalled()
|
|
196
|
-
})
|
|
197
161
|
})
|
package/src/lib/email/send.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { Resend } from 'resend'
|
|
2
2
|
import React from 'react'
|
|
3
|
-
import { appendFile, mkdir } from 'node:fs/promises'
|
|
4
|
-
import { dirname, join } from 'node:path'
|
|
5
|
-
import { tmpdir } from 'node:os'
|
|
6
3
|
import { parseBooleanWithDefault } from '../boolean'
|
|
7
4
|
import { resolveDefaultEmailFromAddress } from './config'
|
|
8
5
|
|
|
@@ -19,86 +16,10 @@ export type SendEmailOptions = {
|
|
|
19
16
|
}>
|
|
20
17
|
}
|
|
21
18
|
|
|
22
|
-
type CapturedEmail = {
|
|
23
|
-
to: string
|
|
24
|
-
subject: string
|
|
25
|
-
from: string | null
|
|
26
|
-
replyTo: string | null
|
|
27
|
-
links: string[]
|
|
28
|
-
text: string
|
|
29
|
-
capturedAt: string
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
type ReactElementProps = {
|
|
33
|
-
href?: unknown
|
|
34
|
-
children?: unknown
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const DEFAULT_TEST_EMAIL_CAPTURE_PATH = join(tmpdir(), 'open-mercato-email-capture.jsonl')
|
|
38
|
-
|
|
39
|
-
function resolveTestEmailCapturePath(): string {
|
|
40
|
-
return process.env.OM_TEST_EMAIL_CAPTURE_PATH?.trim() || DEFAULT_TEST_EMAIL_CAPTURE_PATH
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function readElementProps(node: React.ReactElement): ReactElementProps {
|
|
44
|
-
return node.props as ReactElementProps
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function collectEmailLinks(node: unknown, links: string[] = []): string[] {
|
|
48
|
-
if (node == null || typeof node === 'boolean') return links
|
|
49
|
-
if (Array.isArray(node)) {
|
|
50
|
-
for (const child of node) collectEmailLinks(child, links)
|
|
51
|
-
return links
|
|
52
|
-
}
|
|
53
|
-
if (React.isValidElement(node)) {
|
|
54
|
-
const props = readElementProps(node)
|
|
55
|
-
if (typeof props.href === 'string' && props.href.length > 0) links.push(props.href)
|
|
56
|
-
collectEmailLinks(props.children, links)
|
|
57
|
-
}
|
|
58
|
-
return links
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function collectEmailText(node: unknown, parts: string[] = []): string[] {
|
|
62
|
-
if (node == null || typeof node === 'boolean') return parts
|
|
63
|
-
if (typeof node === 'string' || typeof node === 'number') {
|
|
64
|
-
parts.push(String(node))
|
|
65
|
-
return parts
|
|
66
|
-
}
|
|
67
|
-
if (Array.isArray(node)) {
|
|
68
|
-
for (const child of node) collectEmailText(child, parts)
|
|
69
|
-
return parts
|
|
70
|
-
}
|
|
71
|
-
if (React.isValidElement(node)) {
|
|
72
|
-
collectEmailText(readElementProps(node).children, parts)
|
|
73
|
-
}
|
|
74
|
-
return parts
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function captureEmailForTests(options: SendEmailOptions): Promise<void> {
|
|
78
|
-
if (!parseBooleanWithDefault(process.env.OM_TEST_MODE, false)) return
|
|
79
|
-
|
|
80
|
-
const capturePath = resolveTestEmailCapturePath()
|
|
81
|
-
const record: CapturedEmail = {
|
|
82
|
-
to: options.to,
|
|
83
|
-
subject: options.subject,
|
|
84
|
-
from: options.from ?? resolveDefaultEmailFromAddress() ?? null,
|
|
85
|
-
replyTo: options.replyTo ?? null,
|
|
86
|
-
links: collectEmailLinks(options.react),
|
|
87
|
-
text: collectEmailText(options.react).join(' ').replace(/\s+/g, ' ').trim(),
|
|
88
|
-
capturedAt: new Date().toISOString(),
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
await mkdir(dirname(capturePath), { recursive: true })
|
|
92
|
-
await appendFile(capturePath, `${JSON.stringify(record)}\n`, 'utf8')
|
|
93
|
-
}
|
|
94
|
-
|
|
95
19
|
export async function sendEmail({ to, subject, react, from, replyTo, attachments }: SendEmailOptions) {
|
|
96
20
|
const emailDisabled =
|
|
97
21
|
parseBooleanWithDefault(process.env.OM_DISABLE_EMAIL_DELIVERY, false) ||
|
|
98
22
|
parseBooleanWithDefault(process.env.OM_TEST_MODE, false)
|
|
99
|
-
|
|
100
|
-
await captureEmailForTests({ to, subject, react, from, replyTo, attachments })
|
|
101
|
-
|
|
102
23
|
if (emailDisabled) return
|
|
103
24
|
|
|
104
25
|
const apiKey = process.env.RESEND_API_KEY
|
|
@@ -4,11 +4,9 @@ import {
|
|
|
4
4
|
} from '../subscriber'
|
|
5
5
|
import type { TenantDataEncryptionService } from '../tenantDataEncryptionService'
|
|
6
6
|
|
|
7
|
-
// Give every entity a resolvable id so the real decrypt() path proceeds to decryptEntityPayload
|
|
8
|
-
// keying off the class name so a test's fixture and its assertion describe the same entity.
|
|
7
|
+
// Give every entity a resolvable id so the real decrypt() path proceeds to decryptEntityPayload.
|
|
9
8
|
jest.mock('../entityIds', () => ({
|
|
10
|
-
resolveEntityIdFromMetadata: jest.fn((
|
|
11
|
-
meta?.className === 'OnboardingRequest' ? 'onboarding:onboarding_request' : 'customers:customer_address'),
|
|
9
|
+
resolveEntityIdFromMetadata: jest.fn(() => 'customers:customer_address'),
|
|
12
10
|
}))
|
|
13
11
|
|
|
14
12
|
describe('decryptEntitiesWithFallbackScope subscriber memoization (issue #2235)', () => {
|
|
@@ -152,53 +150,3 @@ describe('decryptEntitiesWithFallbackScope per-row scope (multi-org safety)', ()
|
|
|
152
150
|
expect(calls).toHaveLength(3)
|
|
153
151
|
})
|
|
154
152
|
})
|
|
155
|
-
|
|
156
|
-
describe('tenant-less entity encryption delegation', () => {
|
|
157
|
-
const originalToggle = process.env.TENANT_DATA_ENCRYPTION
|
|
158
|
-
|
|
159
|
-
beforeEach(() => {
|
|
160
|
-
process.env.TENANT_DATA_ENCRYPTION = 'yes'
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
afterEach(() => {
|
|
164
|
-
if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
|
|
165
|
-
else process.env.TENANT_DATA_ENCRYPTION = originalToggle
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
it('lets the encryption service resolve an explicit system-scoped map', async () => {
|
|
169
|
-
const encryptEntityPayload = jest.fn(async (
|
|
170
|
-
_entityId: string,
|
|
171
|
-
target: Record<string, unknown>,
|
|
172
|
-
) => ({ ...target, secret: 'encrypted' }))
|
|
173
|
-
const service = {
|
|
174
|
-
isEnabled: () => true,
|
|
175
|
-
encryptEntityPayload,
|
|
176
|
-
} as unknown as TenantDataEncryptionService
|
|
177
|
-
const subscriber = new TenantEncryptionSubscriber(service)
|
|
178
|
-
const entity = { secret: 'plaintext' }
|
|
179
|
-
const meta = {
|
|
180
|
-
className: 'OnboardingRequest',
|
|
181
|
-
tableName: 'onboarding_requests',
|
|
182
|
-
properties: {
|
|
183
|
-
secret: { name: 'secret', fieldName: 'secret' },
|
|
184
|
-
},
|
|
185
|
-
}
|
|
186
|
-
const changeSet = { payload: { secret: 'plaintext' } }
|
|
187
|
-
|
|
188
|
-
await subscriber.beforeCreate({
|
|
189
|
-
entity,
|
|
190
|
-
meta,
|
|
191
|
-
em: {},
|
|
192
|
-
changeSet,
|
|
193
|
-
} as never)
|
|
194
|
-
|
|
195
|
-
expect(encryptEntityPayload).toHaveBeenCalledWith(
|
|
196
|
-
'onboarding:onboarding_request',
|
|
197
|
-
expect.any(Object),
|
|
198
|
-
null,
|
|
199
|
-
null,
|
|
200
|
-
)
|
|
201
|
-
expect(entity.secret).toBe('encrypted')
|
|
202
|
-
expect(changeSet.payload.secret).toBe('encrypted')
|
|
203
|
-
})
|
|
204
|
-
})
|