@open-mercato/shared 0.6.7 → 0.6.8-canary.320.1.bf889f49e4
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 +62 -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 +190 -70
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/bootstrap/factory.js +18 -13
- package/dist/lib/bootstrap/factory.js.map +2 -2
- package/dist/lib/commands/command-bus.js +9 -3
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/command-interceptor-runner.js +9 -4
- package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
- package/dist/lib/commands/errors.js +25 -4
- package/dist/lib/commands/errors.js.map +2 -2
- package/dist/lib/commands/index.js +7 -1
- package/dist/lib/commands/index.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 +104 -23
- 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/find.js +22 -0
- package/dist/lib/encryption/find.js.map +2 -2
- package/dist/lib/encryption/likeFilterWarning.js +66 -0
- package/dist/lib/encryption/likeFilterWarning.js.map +7 -0
- 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/dictionary-cache.js +5 -0
- package/dist/lib/i18n/dictionary-cache.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 +36 -6
- 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 +51 -0
- package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
- package/dist/lib/query/engine.js +60 -43
- 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 +20 -10
- package/package.json +19 -9
- package/scripts/versionSource.cjs +65 -0
- package/src/lib/__tests__/versionSource.test.ts +64 -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.test.ts +126 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
- package/src/lib/bootstrap/__tests__/factory.test.ts +78 -0
- package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +346 -96
- package/src/lib/bootstrap/factory.ts +21 -15
- package/src/lib/bootstrap/types.ts +6 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +63 -0
- package/src/lib/commands/__tests__/command-interceptor-error.test.ts +91 -1
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +101 -0
- package/src/lib/commands/command-bus.ts +9 -3
- package/src/lib/commands/command-interceptor-runner.ts +27 -6
- package/src/lib/commands/command-interceptor.ts +8 -0
- package/src/lib/commands/errors.ts +67 -2
- package/src/lib/commands/index.ts +7 -1
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +564 -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 +157 -22
- 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__/likeFilterWarning.test.ts +167 -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/find.ts +22 -0
- package/src/lib/encryption/likeFilterWarning.ts +102 -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__/dictionary-cache.test.ts +23 -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/dictionary-cache.ts +5 -0
- 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__/registry.test.ts +26 -0
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
- package/src/lib/modules/registry.ts +46 -7
- 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.scope-and-or.test.ts +67 -0
- package/src/lib/query/__tests__/engine.test.ts +213 -13
- package/src/lib/query/ciphertext-search-warning.ts +106 -0
- package/src/lib/query/engine.ts +102 -61
- 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/entities.ts +15 -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
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
import { getEnrichersForEntity } from './enricher-registry'
|
|
16
16
|
import { logEnricherTiming } from '../umes/enricher-timing'
|
|
17
17
|
import { createLogger } from '../logger'
|
|
18
|
+
import { authorizeFeatures } from '../../security/featurePolicy'
|
|
18
19
|
|
|
19
20
|
const logger = createLogger('shared').child({ component: 'umes' })
|
|
20
21
|
|
|
@@ -35,17 +36,7 @@ function hasRequiredFeatures(
|
|
|
35
36
|
): boolean {
|
|
36
37
|
if (!enricher.features || enricher.features.length === 0) return true
|
|
37
38
|
if (!userFeatures) return false
|
|
38
|
-
|
|
39
|
-
for (const granted of userFeatures) {
|
|
40
|
-
if (granted === '*' || granted === required) return true
|
|
41
|
-
if (granted.endsWith('.*')) {
|
|
42
|
-
const prefix = granted.slice(0, -1)
|
|
43
|
-
if (required.startsWith(prefix)) return true
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return false
|
|
47
|
-
}
|
|
48
|
-
return enricher.features.every((feature) => hasFeature(feature))
|
|
39
|
+
return authorizeFeatures(enricher.features, { grantedFeatures: userFeatures })
|
|
49
40
|
}
|
|
50
41
|
|
|
51
42
|
function filterByACLAndTenant(
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { SortDir } from '@open-mercato/shared/lib/query/types'
|
|
|
8
8
|
import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
9
9
|
import { resolveOrganizationScopeForRequest, type OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
10
10
|
import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
|
|
11
|
+
import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
|
|
11
12
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
12
13
|
import {
|
|
13
14
|
runMutationGuards,
|
|
@@ -57,6 +58,7 @@ import {
|
|
|
57
58
|
isCrudCacheEnabled,
|
|
58
59
|
normalizeIdentifierValue,
|
|
59
60
|
normalizeTagSegment,
|
|
61
|
+
pickFirstIdentifier,
|
|
60
62
|
resolveCrudCache,
|
|
61
63
|
} from './cache'
|
|
62
64
|
import { deriveCrudSegmentTag } from './cache-stats'
|
|
@@ -72,6 +74,7 @@ import { parseExtensionHeaders } from '../umes/extension-headers'
|
|
|
72
74
|
import { createGenericOptimisticLockReader } from './optimistic-lock'
|
|
73
75
|
import { registerOptimisticLockReaderIfAbsent } from './optimistic-lock-store'
|
|
74
76
|
import { createLogger } from '../logger'
|
|
77
|
+
import { isTransientDbError } from '../db/pg-errors'
|
|
75
78
|
|
|
76
79
|
type RbacServiceLike = {
|
|
77
80
|
getGrantedFeatures: (userId: string, opts: { tenantId: string | null; organizationId: string | null }) => Promise<string[]>
|
|
@@ -79,10 +82,13 @@ type RbacServiceLike = {
|
|
|
79
82
|
|
|
80
83
|
const logger = createLogger('shared').child({ component: 'crud' })
|
|
81
84
|
|
|
82
|
-
function resolveSortParams(queryParams: Record<string, unknown
|
|
83
|
-
const rawSortField = queryParams.sortField ?? queryParams.sort
|
|
84
|
-
const
|
|
85
|
-
|
|
85
|
+
function resolveSortParams(queryParams: Record<string, unknown>, defaultSort?: CrudDefaultSort) {
|
|
86
|
+
const rawSortField = queryParams.sortField ?? queryParams.sort
|
|
87
|
+
const requestedSortField =
|
|
88
|
+
typeof rawSortField === 'string' && rawSortField.trim().length > 0 ? rawSortField.trim() : null
|
|
89
|
+
const sortField = requestedSortField ?? defaultSort?.field ?? 'id'
|
|
90
|
+
const rawSortDir =
|
|
91
|
+
queryParams.sortDir ?? queryParams.order ?? (requestedSortField ? 'asc' : defaultSort?.dir ?? 'asc')
|
|
86
92
|
const normalizedDir = typeof rawSortDir === 'string' ? rawSortDir.trim().toLowerCase() : 'asc'
|
|
87
93
|
const sortDir = normalizedDir === 'desc' ? SortDir.Desc : SortDir.Asc
|
|
88
94
|
return { sortField, sortDir }
|
|
@@ -190,6 +196,8 @@ export type CrudListCustomFieldDecorator = {
|
|
|
190
196
|
stripPrefixedKeys?: boolean
|
|
191
197
|
}
|
|
192
198
|
|
|
199
|
+
export type CrudDefaultSort = { field: string; dir?: 'asc' | 'desc' }
|
|
200
|
+
|
|
193
201
|
export type ListConfig<TList> = {
|
|
194
202
|
schema: z.ZodType<TList>
|
|
195
203
|
// Optional: use the QueryEngine when entityId + fields are provided.
|
|
@@ -200,12 +208,41 @@ export type ListConfig<TList> = {
|
|
|
200
208
|
entityId?: any
|
|
201
209
|
fields?: any[] | ((query: TList, ctx: CrudCtx) => any[])
|
|
202
210
|
sortFieldMap?: Record<string, any>
|
|
211
|
+
/**
|
|
212
|
+
* Sort used when the request carries no `sortField` / `sort` param. The field is
|
|
213
|
+
* resolved through `sortFieldMap`, so a name the map defines is translated to its
|
|
214
|
+
* column and an unmapped name is used as the column directly. `dir` only applies
|
|
215
|
+
* together with the default field — an explicit `sortField` without `sortDir`
|
|
216
|
+
* still defaults to ascending. Defaults to `{ field: 'id', dir: 'asc' }`, which is
|
|
217
|
+
* only a meaningful order for sequential ids, never for random UUIDs.
|
|
218
|
+
*
|
|
219
|
+
* Applies to the Query Engine list path only (the route must set both `entityId`
|
|
220
|
+
* and `fields`). The plain-ORM fallback list issues an unordered `find` and
|
|
221
|
+
* already ignores `sortField` today, so it ignores this too.
|
|
222
|
+
*
|
|
223
|
+
* The list schema must keep `sortField` optional for this to take effect — a zod
|
|
224
|
+
* `.default()` on `sortField` reaches the sort resolver as an explicit request and
|
|
225
|
+
* pins the order itself.
|
|
226
|
+
*/
|
|
227
|
+
defaultSort?: CrudDefaultSort
|
|
228
|
+
/**
|
|
229
|
+
* Appended as a secondary ascending sort whenever it differs from the resolved
|
|
230
|
+
* primary sort, so rows sharing a primary value keep a stable order across pages
|
|
231
|
+
* and re-fetches instead of falling back to the database's arbitrary row order.
|
|
232
|
+
* Applies to explicit sorts too, and is resolved through `sortFieldMap` and gated
|
|
233
|
+
* on the Query Engine path exactly like `defaultSort`.
|
|
234
|
+
*/
|
|
235
|
+
tiebreakSortField?: string
|
|
203
236
|
buildFilters?: (query: TList, ctx: CrudCtx) => Where<any> | Promise<Where<any>>
|
|
204
237
|
transformItem?: (item: any) => any
|
|
205
238
|
allowCsv?: boolean
|
|
239
|
+
// The function forms mirror `fields` above: a route whose export columns depend
|
|
240
|
+
// on per-request state (for example custom-field definitions discovered in
|
|
241
|
+
// `beforeList`) MUST resolve them from `ctx` rather than from module-level
|
|
242
|
+
// mutable state, which would bleed one tenant's columns into another's export.
|
|
206
243
|
csv?: {
|
|
207
|
-
headers: string[]
|
|
208
|
-
row: (item: any) => (string | number | boolean | null | undefined)[]
|
|
244
|
+
headers: string[] | ((query: TList, ctx: CrudCtx) => string[])
|
|
245
|
+
row: (item: any, ctx: CrudCtx) => (string | number | boolean | null | undefined)[]
|
|
209
246
|
filename?: string
|
|
210
247
|
}
|
|
211
248
|
export?: CrudExportOptions
|
|
@@ -250,6 +287,7 @@ const DEFAULT_EXPORT_FORMATS: CrudExportFormat[] = ['csv', 'json', 'xml', 'markd
|
|
|
250
287
|
const DEFAULT_EXPORT_BATCH_SIZE = 1000
|
|
251
288
|
const MIN_EXPORT_BATCH_SIZE = 100
|
|
252
289
|
const MAX_EXPORT_BATCH_SIZE = 10000
|
|
290
|
+
const EXPORT_MAX_PAGES = 1000
|
|
253
291
|
|
|
254
292
|
type ColumnResolver = {
|
|
255
293
|
field: string
|
|
@@ -313,14 +351,20 @@ function buildExportFromColumns(items: any[], columnsConfig: CrudExportColumnCon
|
|
|
313
351
|
}
|
|
314
352
|
}
|
|
315
353
|
|
|
316
|
-
function buildExportFromCsv(
|
|
354
|
+
function buildExportFromCsv(
|
|
355
|
+
items: any[],
|
|
356
|
+
csv: NonNullable<ListConfig<any>['csv']>,
|
|
357
|
+
query: unknown,
|
|
358
|
+
ctx: CrudCtx,
|
|
359
|
+
): PreparedExport {
|
|
317
360
|
const used = new Set<string>()
|
|
318
|
-
const
|
|
361
|
+
const resolvedHeaders = typeof csv.headers === 'function' ? csv.headers(query as any, ctx) : csv.headers
|
|
362
|
+
const columns = resolvedHeaders.map((header, idx) => ({
|
|
319
363
|
field: sanitizeFieldName(header || `column_${idx + 1}`, used, idx),
|
|
320
364
|
header: header || `Column ${idx + 1}`,
|
|
321
365
|
}))
|
|
322
366
|
const rows = items.map((item) => {
|
|
323
|
-
const values = csv.row(item) || []
|
|
367
|
+
const values = csv.row(item, ctx) || []
|
|
324
368
|
const row: Record<string, unknown> = {}
|
|
325
369
|
columns.forEach((column, idx) => {
|
|
326
370
|
row[column.field] = values[idx]
|
|
@@ -343,12 +387,12 @@ function buildDefaultExport(items: any[]): PreparedExport {
|
|
|
343
387
|
}
|
|
344
388
|
}
|
|
345
389
|
|
|
346
|
-
function prepareExportData(items: any[], list: ListConfig<any
|
|
390
|
+
function prepareExportData(items: any[], list: ListConfig<any>, query: unknown, ctx: CrudCtx): PreparedExport {
|
|
347
391
|
if (list.export?.columns && list.export.columns.length > 0) {
|
|
348
392
|
return buildExportFromColumns(items, list.export.columns)
|
|
349
393
|
}
|
|
350
394
|
if (list.csv) {
|
|
351
|
-
return buildExportFromCsv(items, list.csv)
|
|
395
|
+
return buildExportFromCsv(items, list.csv, query, ctx)
|
|
352
396
|
}
|
|
353
397
|
const prepared = buildDefaultExport(items)
|
|
354
398
|
return {
|
|
@@ -553,7 +597,26 @@ function attachOperationHeader(res: Response, logEntry: any) {
|
|
|
553
597
|
function handleError(err: unknown): Response {
|
|
554
598
|
if (err instanceof Response) return err
|
|
555
599
|
if (isCrudHttpError(err)) return json(err.body, { status: err.status })
|
|
600
|
+
// A command interceptor that blocked with an explicit status is a deliberate business
|
|
601
|
+
// rejection, not a server fault — surface its status and message instead of a generic 500.
|
|
602
|
+
// Without a usable status the error falls through to the historical handling below (issue #5045).
|
|
603
|
+
const interceptorRejection = getCommandInterceptorHttpRejection(err)
|
|
604
|
+
if (interceptorRejection) {
|
|
605
|
+
return json(interceptorRejection.body, { status: interceptorRejection.status })
|
|
606
|
+
}
|
|
556
607
|
if (err instanceof z.ZodError) return json({ error: 'Invalid input', details: err.issues }, { status: 400 })
|
|
608
|
+
if (isTransientDbError(err)) {
|
|
609
|
+
// Transient DB unavailability (pool exhausted, `max_connections` reached, DB
|
|
610
|
+
// restarting) is retryable — surface a 503 with a Retry-After hint instead of
|
|
611
|
+
// a generic 500 so clients back off and retry once the DB recovers.
|
|
612
|
+
logger.warn('Transient DB failure during CRUD handler', {
|
|
613
|
+
message: err instanceof Error ? err.message : undefined,
|
|
614
|
+
})
|
|
615
|
+
return json(
|
|
616
|
+
{ error: 'Service temporarily unavailable' },
|
|
617
|
+
{ status: 503, headers: { 'Retry-After': '2' } },
|
|
618
|
+
)
|
|
619
|
+
}
|
|
557
620
|
|
|
558
621
|
const message = err instanceof Error ? err.message : undefined
|
|
559
622
|
const stack = err instanceof Error ? err.stack : undefined
|
|
@@ -915,6 +978,12 @@ function buildCrudCacheKey(
|
|
|
915
978
|
`tenant:${normalizeTagSegment(ctx.auth?.tenantId ?? null)}`,
|
|
916
979
|
`selectedOrg:${normalizeTagSegment(ctx.selectedOrganizationId ?? null)}`,
|
|
917
980
|
`scope:${scopeSegment}`,
|
|
981
|
+
// List payloads can vary per caller identity beyond tenant/org scope:
|
|
982
|
+
// buildFilters may narrow by ctx.auth (e.g. ?mine=true), before-interceptor
|
|
983
|
+
// query rewrites are feature-gated per user, and afterList/after-interceptor
|
|
984
|
+
// output is embedded in the stored payload — so entries MUST be partitioned
|
|
985
|
+
// per actor (API key or user), never shared across identities.
|
|
986
|
+
`user:${normalizeTagSegment((ctx.auth?.keyId ?? ctx.auth?.sub) ?? null)}`,
|
|
918
987
|
`query:${serializeSearchParams(url.searchParams)}`,
|
|
919
988
|
]
|
|
920
989
|
// The cached list payload already embeds enricher output (enrichment runs before
|
|
@@ -1655,10 +1724,21 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1655
1724
|
profiler.mark('query_engine_prepare')
|
|
1656
1725
|
const qe = (ctx.container.resolve('queryEngine') as QueryEngine)
|
|
1657
1726
|
profiler.mark('query_engine_resolved')
|
|
1658
|
-
const
|
|
1659
|
-
const
|
|
1660
|
-
|
|
1727
|
+
const sortFieldMap = opts.list.sortFieldMap
|
|
1728
|
+
const resolveSortSelector = (field: string) => {
|
|
1729
|
+
const mapped = (sortFieldMap && sortFieldMap[field]) || field
|
|
1730
|
+
return typeof mapped === 'string' ? normalizeSortFieldSelector(mapped) : mapped
|
|
1731
|
+
}
|
|
1732
|
+
const { sortField: sortFieldRaw, sortDir: sortDirRaw } = resolveSortParams(
|
|
1733
|
+
queryParams as Record<string, unknown>,
|
|
1734
|
+
opts.list.defaultSort,
|
|
1735
|
+
)
|
|
1736
|
+
const sortField = resolveSortSelector(sortFieldRaw)
|
|
1661
1737
|
const sort: Sort[] = [{ field: sortField as any, dir: sortDirRaw } as any]
|
|
1738
|
+
if (opts.list.tiebreakSortField) {
|
|
1739
|
+
const tiebreakField = resolveSortSelector(opts.list.tiebreakSortField)
|
|
1740
|
+
if (tiebreakField !== sortField) sort.push({ field: tiebreakField as any, dir: SortDir.Asc } as any)
|
|
1741
|
+
}
|
|
1662
1742
|
const page: Page = exportRequested
|
|
1663
1743
|
? { page: 1, pageSize: exportPageSize }
|
|
1664
1744
|
: { page: requestedPage, pageSize: requestedPageSize }
|
|
@@ -1786,13 +1866,17 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1786
1866
|
const initialExportItems = exportFullRequested
|
|
1787
1867
|
? rawItems.map(normalizeFullRecordForExport)
|
|
1788
1868
|
: transformedItems
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1869
|
+
const exportItems = [...initialExportItems]
|
|
1870
|
+
const exportPageSizeNumber = typeof page.pageSize === 'number' ? page.pageSize : exportPageSize
|
|
1871
|
+
// Short-page termination: `total` is a display value, not a loop bound — it can
|
|
1872
|
+
// under-report (capped counts) or drift while rows are inserted/deleted mid-export.
|
|
1873
|
+
// Keep fetching while pages come back full; fail closed at the page ceiling rather
|
|
1874
|
+
// than serializing a partial export.
|
|
1875
|
+
if (rawItems.length >= exportPageSizeNumber) {
|
|
1792
1876
|
const queryBase: any = { ...queryOpts }
|
|
1793
1877
|
delete queryBase.page
|
|
1794
1878
|
let nextPage = 2
|
|
1795
|
-
|
|
1879
|
+
for (;;) {
|
|
1796
1880
|
profiler.mark('export_next_page_request', { page: nextPage })
|
|
1797
1881
|
const nextRes = await qe.query(opts.list.entityId as any, {
|
|
1798
1882
|
...queryBase,
|
|
@@ -1807,13 +1891,16 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1807
1891
|
? nextItemsRaw.map(normalizeFullRecordForExport)
|
|
1808
1892
|
: nextTransformed
|
|
1809
1893
|
exportItems.push(...nextExportItems)
|
|
1810
|
-
if (
|
|
1894
|
+
if (nextItemsRaw.length < exportPageSizeNumber) break
|
|
1895
|
+
if (nextPage >= EXPORT_MAX_PAGES) {
|
|
1896
|
+
throw new Error(`[internal] export exceeded ${EXPORT_MAX_PAGES} pages; refusing to return a partial export`)
|
|
1897
|
+
}
|
|
1811
1898
|
nextPage += 1
|
|
1812
1899
|
}
|
|
1813
1900
|
}
|
|
1814
1901
|
const prepared = exportFullRequested
|
|
1815
1902
|
? { columns: ensureColumns(exportItems), rows: exportItems }
|
|
1816
|
-
: prepareExportData(exportItems, opts.list)
|
|
1903
|
+
: prepareExportData(exportItems, opts.list, validated as any, ctx)
|
|
1817
1904
|
const fallbackBase = `${opts.events?.entity || resourceKind || 'list'}${exportFullRequested ? '_full' : ''}`
|
|
1818
1905
|
const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase)
|
|
1819
1906
|
const serialized = serializeExport(prepared, requestedExport)
|
|
@@ -2015,7 +2102,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2015
2102
|
const exportItems = exportFullRequested ? list.map(normalizeFullRecordForExport) : list
|
|
2016
2103
|
const prepared = exportFullRequested
|
|
2017
2104
|
? { columns: ensureColumns(exportItems), rows: exportItems }
|
|
2018
|
-
: prepareExportData(exportItems, opts.list)
|
|
2105
|
+
: prepareExportData(exportItems, opts.list, validated as any, ctx)
|
|
2019
2106
|
const fallbackBase = `${opts.events?.entity || resourceKind || 'list'}${exportFullRequested ? '_full' : ''}`
|
|
2020
2107
|
const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase)
|
|
2021
2108
|
const serialized = serializeExport(prepared, requestedExport)
|
|
@@ -2140,6 +2227,31 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2140
2227
|
}
|
|
2141
2228
|
}
|
|
2142
2229
|
|
|
2230
|
+
// Mutation guard registry — command path (mirrors the direct create branch)
|
|
2231
|
+
const createCmdUserFeatures = await resolveUserFeatures(ctx)
|
|
2232
|
+
const { allGuards: createCmdAllGuards } = collectAndRunGuards(ctx.container)
|
|
2233
|
+
let createCmdGuardAfterCallbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }> = []
|
|
2234
|
+
if (createCmdAllGuards.length && ctx.auth.tenantId) {
|
|
2235
|
+
const guardResult = await runMutationGuards(createCmdAllGuards, {
|
|
2236
|
+
tenantId: ctx.auth.tenantId,
|
|
2237
|
+
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
2238
|
+
userId: ctx.auth.sub,
|
|
2239
|
+
resourceKind,
|
|
2240
|
+
resourceId: null,
|
|
2241
|
+
operation: 'create',
|
|
2242
|
+
requestMethod: request.method,
|
|
2243
|
+
requestHeaders: request.headers,
|
|
2244
|
+
mutationPayload: input && typeof input === 'object' ? (input as Record<string, unknown>) : null,
|
|
2245
|
+
}, { userFeatures: createCmdUserFeatures ?? [] })
|
|
2246
|
+
if (!guardResult.ok) {
|
|
2247
|
+
return json(guardResult.errorBody ?? { error: 'Operation blocked by guard' }, { status: guardResult.errorStatus ?? 422 })
|
|
2248
|
+
}
|
|
2249
|
+
if (guardResult.modifiedPayload && typeof input === 'object' && input) {
|
|
2250
|
+
input = { ...input as Record<string, unknown>, ...guardResult.modifiedPayload }
|
|
2251
|
+
}
|
|
2252
|
+
createCmdGuardAfterCallbacks = guardResult.afterSuccessCallbacks
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2143
2255
|
const baseMetadata: CommandLogMetadata = {
|
|
2144
2256
|
tenantId: ctx.auth?.tenantId ?? null,
|
|
2145
2257
|
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
@@ -2182,6 +2294,22 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2182
2294
|
const status = action.status ?? 201
|
|
2183
2295
|
const response = json(resolvedPayload, { status })
|
|
2184
2296
|
attachOperationHeader(response, logEntry)
|
|
2297
|
+
const commandResultId = pickFirstIdentifier(
|
|
2298
|
+
(result as Record<string, unknown> | null | undefined)?.id,
|
|
2299
|
+
(resolvedPayload as Record<string, unknown> | null | undefined)?.id,
|
|
2300
|
+
)
|
|
2301
|
+
if (createCmdGuardAfterCallbacks.length && ctx.auth.tenantId && commandResultId) {
|
|
2302
|
+
await runGuardAfterSuccessCallbacks(createCmdGuardAfterCallbacks, {
|
|
2303
|
+
tenantId: ctx.auth.tenantId,
|
|
2304
|
+
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
2305
|
+
userId: ctx.auth.sub,
|
|
2306
|
+
resourceKind,
|
|
2307
|
+
resourceId: commandResultId,
|
|
2308
|
+
operation: 'create',
|
|
2309
|
+
requestMethod: request.method,
|
|
2310
|
+
requestHeaders: request.headers,
|
|
2311
|
+
})
|
|
2312
|
+
}
|
|
2185
2313
|
// Note: side effects (events + indexing) are already flushed by CommandBus.execute()
|
|
2186
2314
|
// via flushCrudSideEffects(). Calling markCommandResultForIndexing here would cause
|
|
2187
2315
|
// duplicate event emissions.
|
|
@@ -2285,6 +2413,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2285
2413
|
organizationId: targetOrgId,
|
|
2286
2414
|
tenantId: writeTenantId,
|
|
2287
2415
|
values,
|
|
2416
|
+
notify: false,
|
|
2288
2417
|
})
|
|
2289
2418
|
}
|
|
2290
2419
|
}
|
|
@@ -2422,6 +2551,10 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2422
2551
|
const updateUserFeatures = await resolveUserFeatures(ctx)
|
|
2423
2552
|
const { allGuards: updateAllGuards } = collectAndRunGuards(ctx.container)
|
|
2424
2553
|
let cmdUpdateGuardAfterCallbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }> = []
|
|
2554
|
+
// Commands whose mapInput wraps the payload (e.g. `{ body }`) intentionally
|
|
2555
|
+
// null candidateId and OPT OUT of row-level guards, leaving the command-level
|
|
2556
|
+
// optimistic-lock check as the sole guard — a documented contract, see
|
|
2557
|
+
// apps/docs/docs/framework/data-integrity/concurrency-locking.mdx.
|
|
2425
2558
|
if (updateAllGuards.length && ctx.auth.tenantId && candidateId) {
|
|
2426
2559
|
const guardResult = await runMutationGuards(updateAllGuards, {
|
|
2427
2560
|
tenantId: ctx.auth.tenantId,
|
|
@@ -2618,6 +2751,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2618
2751
|
organizationId: targetOrgId,
|
|
2619
2752
|
tenantId: writeTenantId,
|
|
2620
2753
|
values,
|
|
2754
|
+
notify: false,
|
|
2621
2755
|
})
|
|
2622
2756
|
}
|
|
2623
2757
|
}
|
|
@@ -2721,6 +2855,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2721
2855
|
request,
|
|
2722
2856
|
method: 'DELETE',
|
|
2723
2857
|
body: interceptorInput,
|
|
2858
|
+
query: raw.query,
|
|
2724
2859
|
})
|
|
2725
2860
|
if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
|
|
2726
2861
|
interceptorRequestPayload = beforeInterceptors.requestPayload
|
|
@@ -2845,7 +2980,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2845
2980
|
request,
|
|
2846
2981
|
method: 'DELETE',
|
|
2847
2982
|
body: idFrom === 'query' ? undefined : ({ id } as Record<string, unknown>),
|
|
2848
|
-
query: idFrom === 'query' ? (
|
|
2983
|
+
query: idFrom === 'query' ? Object.fromEntries(url.searchParams.entries()) : undefined,
|
|
2849
2984
|
})
|
|
2850
2985
|
if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
|
|
2851
2986
|
interceptorRequestPayload = beforeInterceptors.requestPayload
|
|
@@ -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
|
+
}
|