@open-mercato/shared 0.6.7-develop.6775.1.c2313bb8a3 → 0.6.7-develop.6784.1.f80b9afce5
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 +18 -3
- 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 +4 -2
- 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/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- 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/package.json +6 -2
- 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.test.ts +18 -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 +2 -0
- 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/modules/customer-auth.ts +1 -0
- package/src/modules/navigation/backendChrome.ts +1 -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,21 +6,57 @@ function buildRegistry(modules) {
|
|
|
6
6
|
const enabledModuleSet = new Set(enabledModuleIds);
|
|
7
7
|
const featureToModule = /* @__PURE__ */ new Map();
|
|
8
8
|
const prefixToModule = /* @__PURE__ */ new Map();
|
|
9
|
+
const concreteFeatureIds = [];
|
|
10
|
+
const concreteFeatureSet = /* @__PURE__ */ new Set();
|
|
11
|
+
const addConcreteFeature = (featureId, owningModule, authoritative) => {
|
|
12
|
+
if (!featureId || featureId === "*" || featureId.endsWith(".*")) return;
|
|
13
|
+
if (!concreteFeatureSet.has(featureId)) {
|
|
14
|
+
concreteFeatureSet.add(featureId);
|
|
15
|
+
concreteFeatureIds.push(featureId);
|
|
16
|
+
}
|
|
17
|
+
if (authoritative || !featureToModule.has(featureId)) {
|
|
18
|
+
featureToModule.set(featureId, owningModule);
|
|
19
|
+
}
|
|
20
|
+
const dot = featureId.indexOf(".");
|
|
21
|
+
if (dot > 0) {
|
|
22
|
+
const prefix = featureId.slice(0, dot);
|
|
23
|
+
if (!prefixToModule.has(prefix)) prefixToModule.set(prefix, owningModule);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
9
26
|
for (const mod of modules) {
|
|
10
27
|
const features = mod.features;
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
if (Array.isArray(features)) {
|
|
29
|
+
for (const feature of features) {
|
|
30
|
+
if (!feature || typeof feature.id !== "string" || !feature.id) continue;
|
|
31
|
+
const declared = typeof feature.module === "string" && feature.module.length > 0 ? feature.module : mod.id;
|
|
32
|
+
addConcreteFeature(feature.id, declared, true);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const customerDefaults = mod.setup?.defaultCustomerRoleFeatures;
|
|
36
|
+
if (customerDefaults) {
|
|
37
|
+
for (const roleFeatures of Object.values(customerDefaults)) {
|
|
38
|
+
if (!Array.isArray(roleFeatures)) continue;
|
|
39
|
+
for (const featureId of roleFeatures) {
|
|
40
|
+
if (typeof featureId === "string") addConcreteFeature(featureId, mod.id, false);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(mod.frontendRoutes)) {
|
|
45
|
+
for (const route of mod.frontendRoutes) {
|
|
46
|
+
if (!Array.isArray(route.requireCustomerFeatures)) continue;
|
|
47
|
+
for (const featureId of route.requireCustomerFeatures) {
|
|
48
|
+
if (typeof featureId === "string") addConcreteFeature(featureId, mod.id, false);
|
|
49
|
+
}
|
|
20
50
|
}
|
|
21
51
|
}
|
|
22
52
|
}
|
|
23
|
-
return {
|
|
53
|
+
return {
|
|
54
|
+
enabledModuleIds,
|
|
55
|
+
enabledModuleSet,
|
|
56
|
+
featureToModule,
|
|
57
|
+
prefixToModule,
|
|
58
|
+
concreteFeatureIds
|
|
59
|
+
};
|
|
24
60
|
}
|
|
25
61
|
function getRegistry() {
|
|
26
62
|
try {
|
|
@@ -52,6 +88,13 @@ function getEnabledModuleIds() {
|
|
|
52
88
|
const registry = getRegistry();
|
|
53
89
|
return registry ? [...registry.enabledModuleIds] : [];
|
|
54
90
|
}
|
|
91
|
+
function getConcreteFeatureIds() {
|
|
92
|
+
const registry = getRegistry();
|
|
93
|
+
return registry ? [...registry.concreteFeatureIds] : [];
|
|
94
|
+
}
|
|
95
|
+
function hasEnabledModulesRegistry() {
|
|
96
|
+
return getRegistry() !== null;
|
|
97
|
+
}
|
|
55
98
|
function filterGrantsByEnabledModules(granted) {
|
|
56
99
|
const registry = getRegistry();
|
|
57
100
|
if (!registry) return [...granted];
|
|
@@ -73,7 +116,9 @@ function filterGrantsByEnabledModules(granted) {
|
|
|
73
116
|
}
|
|
74
117
|
export {
|
|
75
118
|
filterGrantsByEnabledModules,
|
|
119
|
+
getConcreteFeatureIds,
|
|
76
120
|
getEnabledModuleIds,
|
|
77
|
-
getOwningModuleId
|
|
121
|
+
getOwningModuleId,
|
|
122
|
+
hasEnabledModulesRegistry
|
|
78
123
|
};
|
|
79
124
|
//# sourceMappingURL=enabledModulesRegistry.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/security/enabledModulesRegistry.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Module-aware grant filtering.\n *\n * Features live under `<module>.<action>` (see AGENTS.md naming convention).\n * When a module is disabled in `modules.ts`, its routes/UI are absent but\n * roles may still carry the feature string. Anywhere we turn raw ACL\n * grants into \"what the user can currently act on\", we must drop grants\n * whose owning module is not enabled \u2014 otherwise stale grants re-open the\n * 404-class bug PR #1567 only partially fixed.\n *\n * Owning-module resolution:\n * - Most features follow the convention so `id.split('.')[0]` matches the\n * module id. For those, the prefix is correct.\n * - A few features (e.g. `analytics.view`) deliberately use a different\n * namespace from their owning module. For those, the registry's declared\n * `module` field on the `Module.features` entry is authoritative. We\n * consult it first and fall back to the prefix only when the feature is\n * unknown to the registry.\n *\n * This helper is server-only: it reads the enabled module set from the\n * bootstrapped module registry. The browser never imports it; instead,\n * server code pre-filters `BackendChromePayload.grantedFeatures` so\n * client-side `hasFeature` can stay a pure grant check.\n */\n\nimport { getModules } from '../lib/modules/registry'\nimport type { Module } from '../modules/registry'\n\ntype FeatureRegistry = {\n enabledModuleIds: string[]\n enabledModuleSet: Set<string>\n featureToModule: Map<string, string>\n prefixToModule: Map<string, string>\n}\n\nlet cachedRegistry: FeatureRegistry | null = null\nlet cachedModulesRef: readonly Module[] | null = null\n\nfunction buildRegistry(modules: readonly Module[]): FeatureRegistry {\n const enabledModuleIds = modules.map((mod) => mod.id)\n const enabledModuleSet = new Set(enabledModuleIds)\n const featureToModule = new Map<string, string>()\n const prefixToModule = new Map<string, string>()\n for (const mod of modules) {\n const features = mod.features\n if (
|
|
5
|
-
"mappings": "AAyBA,SAAS,kBAAkB;
|
|
4
|
+
"sourcesContent": ["/**\n * Module-aware grant filtering.\n *\n * Features live under `<module>.<action>` (see AGENTS.md naming convention).\n * When a module is disabled in `modules.ts`, its routes/UI are absent but\n * roles may still carry the feature string. Anywhere we turn raw ACL\n * grants into \"what the user can currently act on\", we must drop grants\n * whose owning module is not enabled \u2014 otherwise stale grants re-open the\n * 404-class bug PR #1567 only partially fixed.\n *\n * Owning-module resolution:\n * - Most features follow the convention so `id.split('.')[0]` matches the\n * module id. For those, the prefix is correct.\n * - A few features (e.g. `analytics.view`) deliberately use a different\n * namespace from their owning module. For those, the registry's declared\n * `module` field on the `Module.features` entry is authoritative. We\n * consult it first and fall back to the prefix only when the feature is\n * unknown to the registry.\n *\n * This helper is server-only: it reads the enabled module set from the\n * bootstrapped module registry. The browser never imports it; instead,\n * server code pre-filters `BackendChromePayload.grantedFeatures` so\n * client-side `hasFeature` can stay a pure grant check.\n */\n\nimport { getModules } from '../lib/modules/registry'\nimport type { Module } from '../modules/registry'\n\ntype FeatureRegistry = {\n enabledModuleIds: string[]\n enabledModuleSet: Set<string>\n featureToModule: Map<string, string>\n prefixToModule: Map<string, string>\n concreteFeatureIds: string[]\n}\n\nlet cachedRegistry: FeatureRegistry | null = null\nlet cachedModulesRef: readonly Module[] | null = null\n\nfunction buildRegistry(modules: readonly Module[]): FeatureRegistry {\n const enabledModuleIds = modules.map((mod) => mod.id)\n const enabledModuleSet = new Set(enabledModuleIds)\n const featureToModule = new Map<string, string>()\n const prefixToModule = new Map<string, string>()\n const concreteFeatureIds: string[] = []\n const concreteFeatureSet = new Set<string>()\n\n const addConcreteFeature = (featureId: string, owningModule: string, authoritative: boolean) => {\n if (!featureId || featureId === '*' || featureId.endsWith('.*')) return\n if (!concreteFeatureSet.has(featureId)) {\n concreteFeatureSet.add(featureId)\n concreteFeatureIds.push(featureId)\n }\n if (authoritative || !featureToModule.has(featureId)) {\n featureToModule.set(featureId, owningModule)\n }\n const dot = featureId.indexOf('.')\n if (dot > 0) {\n const prefix = featureId.slice(0, dot)\n if (!prefixToModule.has(prefix)) prefixToModule.set(prefix, owningModule)\n }\n }\n\n for (const mod of modules) {\n const features = mod.features\n if (Array.isArray(features)) {\n for (const feature of features) {\n if (!feature || typeof feature.id !== 'string' || !feature.id) continue\n const declared = typeof feature.module === 'string' && feature.module.length > 0\n ? feature.module\n : mod.id\n addConcreteFeature(feature.id, declared, true)\n }\n }\n\n const customerDefaults = mod.setup?.defaultCustomerRoleFeatures\n if (customerDefaults) {\n for (const roleFeatures of Object.values(customerDefaults)) {\n if (!Array.isArray(roleFeatures)) continue\n for (const featureId of roleFeatures) {\n if (typeof featureId === 'string') addConcreteFeature(featureId, mod.id, false)\n }\n }\n }\n\n if (Array.isArray(mod.frontendRoutes)) {\n for (const route of mod.frontendRoutes) {\n if (!Array.isArray(route.requireCustomerFeatures)) continue\n for (const featureId of route.requireCustomerFeatures) {\n if (typeof featureId === 'string') addConcreteFeature(featureId, mod.id, false)\n }\n }\n }\n }\n return {\n enabledModuleIds,\n enabledModuleSet,\n featureToModule,\n prefixToModule,\n concreteFeatureIds,\n }\n}\n\nfunction getRegistry(): FeatureRegistry | null {\n try {\n const modules = getModules() as readonly Module[]\n if (cachedRegistry && cachedModulesRef === modules) return cachedRegistry\n cachedModulesRef = modules\n cachedRegistry = buildRegistry(modules)\n return cachedRegistry\n } catch {\n return null\n }\n}\n\nexport function getOwningModuleId(featureId: string): string {\n const registry = getRegistry()\n if (registry) {\n const direct = registry.featureToModule.get(featureId)\n if (direct) return direct\n if (featureId.endsWith('.*')) {\n const prefix = featureId.slice(0, -2)\n const fromPrefix = registry.prefixToModule.get(prefix)\n if (fromPrefix) return fromPrefix\n return prefix\n }\n }\n const dot = featureId.indexOf('.')\n return dot === -1 ? featureId : featureId.slice(0, dot)\n}\n\nexport function getEnabledModuleIds(): string[] {\n const registry = getRegistry()\n return registry ? [...registry.enabledModuleIds] : []\n}\n\n/** @internal Infrastructure input for concrete feature-policy projection. */\nexport function getConcreteFeatureIds(): string[] {\n const registry = getRegistry()\n return registry ? [...registry.concreteFeatureIds] : []\n}\n\n/** @internal Distinguishes an empty registry from an unavailable bootstrap registry. */\nexport function hasEnabledModulesRegistry(): boolean {\n return getRegistry() !== null\n}\n\n/**\n * Filters a raw granted-features list down to the grants whose owning\n * module is currently enabled. Expands `*` (superadmin) into one wildcard\n * per enabled module so the result is still safe to feed into a pure\n * `matchFeature` check, plus one wildcard per off-convention feature\n * prefix (e.g. `analytics.*`) whose declared owning module is enabled.\n * If the module registry is not populated (tests, CLI), returns the input\n * unchanged \u2014 preserves legacy behavior.\n */\nexport function filterGrantsByEnabledModules(granted: readonly string[]): string[] {\n const registry = getRegistry()\n if (!registry) return [...granted]\n const { enabledModuleIds, enabledModuleSet, prefixToModule } = registry\n const result: string[] = []\n for (const grant of granted) {\n if (grant === '*') {\n for (const id of enabledModuleIds) result.push(`${id}.*`)\n for (const [prefix, owningModule] of prefixToModule) {\n if (!enabledModuleSet.has(prefix) && enabledModuleSet.has(owningModule)) {\n result.push(`${prefix}.*`)\n }\n }\n continue\n }\n if (enabledModuleSet.has(getOwningModuleId(grant))) result.push(grant)\n }\n return result\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { hasAllFeatures as matchesAllFeatures } from "./features.js";
|
|
2
|
+
import {
|
|
3
|
+
filterGrantsByEnabledModules,
|
|
4
|
+
getConcreteFeatureIds,
|
|
5
|
+
getEnabledModuleIds,
|
|
6
|
+
getOwningModuleId,
|
|
7
|
+
hasEnabledModulesRegistry
|
|
8
|
+
} from "./enabledModulesRegistry.js";
|
|
9
|
+
import { composeAclFeatureOverrides } from "../modules/overrides.js";
|
|
10
|
+
function getRemovedAclFeatureIds() {
|
|
11
|
+
return Object.entries(composeAclFeatureOverrides()).filter(([, override]) => override === null).map(([featureId]) => featureId);
|
|
12
|
+
}
|
|
13
|
+
function isAclFeatureRemoved(featureId) {
|
|
14
|
+
return composeAclFeatureOverrides()[featureId] === null;
|
|
15
|
+
}
|
|
16
|
+
function isFeatureEnabled(featureId) {
|
|
17
|
+
if (!hasEnabledModulesRegistry()) return true;
|
|
18
|
+
const enabledModuleIds = getEnabledModuleIds();
|
|
19
|
+
return enabledModuleIds.includes(getOwningModuleId(featureId));
|
|
20
|
+
}
|
|
21
|
+
function authorizeFeatures(required, subject) {
|
|
22
|
+
if (required.length === 0) return true;
|
|
23
|
+
if (subject.scopeAllowed === false) return false;
|
|
24
|
+
if (required.some((featureId) => isAclFeatureRemoved(featureId) || !isFeatureEnabled(featureId))) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (subject.unrestricted === true) return true;
|
|
28
|
+
return matchesAllFeatures(
|
|
29
|
+
filterGrantsByEnabledModules(subject.grantedFeatures),
|
|
30
|
+
required
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
function resolveEffectiveFeatures(grantedFeatures) {
|
|
34
|
+
const filteredGrants = filterGrantsByEnabledModules(grantedFeatures).filter((featureId) => !isAclFeatureRemoved(featureId));
|
|
35
|
+
if (!hasEnabledModulesRegistry()) {
|
|
36
|
+
return filteredGrants.filter((featureId, index, features) => featureId !== "*" && !featureId.endsWith(".*") && features.indexOf(featureId) === index);
|
|
37
|
+
}
|
|
38
|
+
const result = [];
|
|
39
|
+
const seen = /* @__PURE__ */ new Set();
|
|
40
|
+
const addFeature = (featureId) => {
|
|
41
|
+
if (seen.has(featureId) || isAclFeatureRemoved(featureId) || !isFeatureEnabled(featureId)) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
seen.add(featureId);
|
|
45
|
+
result.push(featureId);
|
|
46
|
+
};
|
|
47
|
+
for (const featureId of getConcreteFeatureIds()) {
|
|
48
|
+
if (matchesAllFeatures(filteredGrants, [featureId])) addFeature(featureId);
|
|
49
|
+
}
|
|
50
|
+
for (const featureId of filteredGrants) {
|
|
51
|
+
if (featureId === "*" || featureId.endsWith(".*")) continue;
|
|
52
|
+
addFeature(featureId);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
export {
|
|
57
|
+
authorizeFeatures,
|
|
58
|
+
getRemovedAclFeatureIds,
|
|
59
|
+
isAclFeatureRemoved,
|
|
60
|
+
resolveEffectiveFeatures
|
|
61
|
+
};
|
|
62
|
+
//# sourceMappingURL=featurePolicy.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/security/featurePolicy.ts"],
|
|
4
|
+
"sourcesContent": ["import { hasAllFeatures as matchesAllFeatures } from './features'\nimport {\n filterGrantsByEnabledModules,\n getConcreteFeatureIds,\n getEnabledModuleIds,\n getOwningModuleId,\n hasEnabledModulesRegistry,\n} from './enabledModulesRegistry'\nimport { composeAclFeatureOverrides } from '../modules/overrides'\n\nexport type FeaturePolicySubject = {\n grantedFeatures: readonly string[]\n unrestricted?: boolean\n scopeAllowed?: boolean\n}\n\nexport function getRemovedAclFeatureIds(): string[] {\n return Object.entries(composeAclFeatureOverrides())\n .filter(([, override]) => override === null)\n .map(([featureId]) => featureId)\n}\n\nexport function isAclFeatureRemoved(featureId: string): boolean {\n return composeAclFeatureOverrides()[featureId] === null\n}\n\nfunction isFeatureEnabled(featureId: string): boolean {\n if (!hasEnabledModulesRegistry()) return true\n const enabledModuleIds = getEnabledModuleIds()\n return enabledModuleIds.includes(getOwningModuleId(featureId))\n}\n\nexport function authorizeFeatures(\n required: readonly string[],\n subject: FeaturePolicySubject,\n): boolean {\n if (required.length === 0) return true\n if (subject.scopeAllowed === false) return false\n if (required.some((featureId) => (\n isAclFeatureRemoved(featureId) || !isFeatureEnabled(featureId)\n ))) {\n return false\n }\n if (subject.unrestricted === true) return true\n return matchesAllFeatures(\n filterGrantsByEnabledModules(subject.grantedFeatures),\n required,\n )\n}\n\nexport function resolveEffectiveFeatures(\n grantedFeatures: readonly string[],\n): string[] {\n const filteredGrants = filterGrantsByEnabledModules(grantedFeatures)\n .filter((featureId) => !isAclFeatureRemoved(featureId))\n\n if (!hasEnabledModulesRegistry()) {\n return filteredGrants.filter((featureId, index, features) => (\n featureId !== '*'\n && !featureId.endsWith('.*')\n && features.indexOf(featureId) === index\n ))\n }\n\n const result: string[] = []\n const seen = new Set<string>()\n const addFeature = (featureId: string) => {\n if (\n seen.has(featureId)\n || isAclFeatureRemoved(featureId)\n || !isFeatureEnabled(featureId)\n ) {\n return\n }\n seen.add(featureId)\n result.push(featureId)\n }\n\n for (const featureId of getConcreteFeatureIds()) {\n if (matchesAllFeatures(filteredGrants, [featureId])) addFeature(featureId)\n }\n\n for (const featureId of filteredGrants) {\n if (featureId === '*' || featureId.endsWith('.*')) continue\n addFeature(featureId)\n }\n\n return result\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,kBAAkB,0BAA0B;AACrD;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,kCAAkC;AAQpC,SAAS,0BAAoC;AAClD,SAAO,OAAO,QAAQ,2BAA2B,CAAC,EAC/C,OAAO,CAAC,CAAC,EAAE,QAAQ,MAAM,aAAa,IAAI,EAC1C,IAAI,CAAC,CAAC,SAAS,MAAM,SAAS;AACnC;AAEO,SAAS,oBAAoB,WAA4B;AAC9D,SAAO,2BAA2B,EAAE,SAAS,MAAM;AACrD;AAEA,SAAS,iBAAiB,WAA4B;AACpD,MAAI,CAAC,0BAA0B,EAAG,QAAO;AACzC,QAAM,mBAAmB,oBAAoB;AAC7C,SAAO,iBAAiB,SAAS,kBAAkB,SAAS,CAAC;AAC/D;AAEO,SAAS,kBACd,UACA,SACS;AACT,MAAI,SAAS,WAAW,EAAG,QAAO;AAClC,MAAI,QAAQ,iBAAiB,MAAO,QAAO;AAC3C,MAAI,SAAS,KAAK,CAAC,cACjB,oBAAoB,SAAS,KAAK,CAAC,iBAAiB,SAAS,CAC9D,GAAG;AACF,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,iBAAiB,KAAM,QAAO;AAC1C,SAAO;AAAA,IACL,6BAA6B,QAAQ,eAAe;AAAA,IACpD;AAAA,EACF;AACF;AAEO,SAAS,yBACd,iBACU;AACV,QAAM,iBAAiB,6BAA6B,eAAe,EAChE,OAAO,CAAC,cAAc,CAAC,oBAAoB,SAAS,CAAC;AAExD,MAAI,CAAC,0BAA0B,GAAG;AAChC,WAAO,eAAe,OAAO,CAAC,WAAW,OAAO,aAC9C,cAAc,OACX,CAAC,UAAU,SAAS,IAAI,KACxB,SAAS,QAAQ,SAAS,MAAM,KACpC;AAAA,EACH;AAEA,QAAM,SAAmB,CAAC;AAC1B,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,aAAa,CAAC,cAAsB;AACxC,QACE,KAAK,IAAI,SAAS,KACf,oBAAoB,SAAS,KAC7B,CAAC,iBAAiB,SAAS,GAC9B;AACA;AAAA,IACF;AACA,SAAK,IAAI,SAAS;AAClB,WAAO,KAAK,SAAS;AAAA,EACvB;AAEA,aAAW,aAAa,sBAAsB,GAAG;AAC/C,QAAI,mBAAmB,gBAAgB,CAAC,SAAS,CAAC,EAAG,YAAW,SAAS;AAAA,EAC3E;AAEA,aAAW,aAAa,gBAAgB;AACtC,QAAI,cAAc,OAAO,UAAU,SAAS,IAAI,EAAG;AACnD,eAAW,SAAS;AAAA,EACtB;AAEA,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6784.1.f80b9afce5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -48,6 +48,10 @@
|
|
|
48
48
|
"types": "./src/lib/events/patterns.ts",
|
|
49
49
|
"default": "./dist/lib/events/patterns.js"
|
|
50
50
|
},
|
|
51
|
+
"./lib/data/consistency": {
|
|
52
|
+
"types": "./src/lib/data/consistency.ts",
|
|
53
|
+
"default": "./dist/lib/data/consistency.js"
|
|
54
|
+
},
|
|
51
55
|
"./lib/logger": {
|
|
52
56
|
"types": "./src/lib/logger/index.ts",
|
|
53
57
|
"default": "./dist/lib/logger/index.js"
|
|
@@ -101,7 +105,7 @@
|
|
|
101
105
|
"@mikro-orm/core": "^7.1.5",
|
|
102
106
|
"@mikro-orm/decorators": "^7.1.5",
|
|
103
107
|
"@mikro-orm/postgresql": "^7.1.5",
|
|
104
|
-
"@open-mercato/cache": "0.6.7-develop.
|
|
108
|
+
"@open-mercato/cache": "0.6.7-develop.6784.1.f80b9afce5",
|
|
105
109
|
"@types/sanitize-html": "^2.16.1",
|
|
106
110
|
"dotenv": "^17.4.2",
|
|
107
111
|
"pino": "^10.3.1",
|
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
} from '../command-interceptor-runner'
|
|
8
8
|
import type { CommandInterceptor, CommandInterceptorContext, CommandInterceptorUndoContext } from '../command-interceptor'
|
|
9
9
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
10
|
+
import {
|
|
11
|
+
applyAclFeatureOverrides,
|
|
12
|
+
resetModuleContractOverridesForTests,
|
|
13
|
+
} from '@open-mercato/shared/modules/overrides'
|
|
10
14
|
|
|
11
15
|
jest.mock('@open-mercato/shared/lib/logger', () => {
|
|
12
16
|
const mocked = {
|
|
@@ -59,6 +63,10 @@ describe('matchesCommandPattern', () => {
|
|
|
59
63
|
})
|
|
60
64
|
|
|
61
65
|
describe('runCommandInterceptorsBefore', () => {
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
resetModuleContractOverridesForTests()
|
|
68
|
+
})
|
|
69
|
+
|
|
62
70
|
it('returns ok when no interceptors match', async () => {
|
|
63
71
|
const interceptor = makeInterceptor({
|
|
64
72
|
id: 'i1',
|
|
@@ -158,6 +166,26 @@ describe('runCommandInterceptorsBefore', () => {
|
|
|
158
166
|
expect(withWildcard.ok).toBe(false)
|
|
159
167
|
expect(withWildcard.error?.message).toBe('Blocked by wildcard')
|
|
160
168
|
})
|
|
169
|
+
|
|
170
|
+
it('does not run an interceptor gated by a nulled ACL feature', async () => {
|
|
171
|
+
applyAclFeatureOverrides({ 'premium.audit': null })
|
|
172
|
+
const interceptor = makeInterceptor({
|
|
173
|
+
id: 'i1',
|
|
174
|
+
features: ['premium.audit'],
|
|
175
|
+
beforeExecute: jest.fn().mockResolvedValue({ ok: false }),
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const result = await runCommandInterceptorsBefore(
|
|
179
|
+
[interceptor],
|
|
180
|
+
'customers.create-person',
|
|
181
|
+
{},
|
|
182
|
+
baseContext,
|
|
183
|
+
['*', 'premium.audit'],
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
expect(result.ok).toBe(true)
|
|
187
|
+
expect(interceptor.beforeExecute).not.toHaveBeenCalled()
|
|
188
|
+
})
|
|
161
189
|
})
|
|
162
190
|
|
|
163
191
|
describe('runCommandInterceptorsAfter', () => {
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from './command-interceptor-runner'
|
|
32
32
|
import type { CommandInterceptorContext } from './command-interceptor'
|
|
33
33
|
import { CommandInterceptorError } from './errors'
|
|
34
|
+
import { isReadProjectionAlwaysConsistent } from '@open-mercato/shared/lib/data/consistency'
|
|
34
35
|
import { createLogger } from '../logger'
|
|
35
36
|
|
|
36
37
|
const logger = createLogger('shared').child({ component: 'commands' })
|
|
@@ -697,7 +698,10 @@ export class CommandBus {
|
|
|
697
698
|
try {
|
|
698
699
|
const dataEngine = (container.resolve('dataEngine') as DataEngine)
|
|
699
700
|
await dataEngine.flushOrmEntityChanges(suppress)
|
|
700
|
-
} catch {
|
|
701
|
+
} catch (error) {
|
|
702
|
+
if (isReadProjectionAlwaysConsistent()) {
|
|
703
|
+
throw error
|
|
704
|
+
}
|
|
701
705
|
// best-effort: failures should not block command execution
|
|
702
706
|
}
|
|
703
707
|
}
|
|
@@ -3,7 +3,7 @@ import type {
|
|
|
3
3
|
CommandInterceptorContext,
|
|
4
4
|
CommandInterceptorUndoContext,
|
|
5
5
|
} from './command-interceptor'
|
|
6
|
-
import {
|
|
6
|
+
import { authorizeFeatures } from '../../security/featurePolicy'
|
|
7
7
|
import { createLogger } from '../logger'
|
|
8
8
|
|
|
9
9
|
const logger = createLogger('shared').child({ component: 'commands' })
|
|
@@ -33,7 +33,7 @@ function collectMatching(
|
|
|
33
33
|
): CommandInterceptor[] {
|
|
34
34
|
return interceptors
|
|
35
35
|
.filter((i) => matchesCommandPattern(i.targetCommand, commandId))
|
|
36
|
-
.filter((i) =>
|
|
36
|
+
.filter((i) => authorizeFeatures(i.features ?? [], { grantedFeatures: userFeatures }))
|
|
37
37
|
.sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50))
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -545,6 +545,7 @@ describe('CRUD Factory', () => {
|
|
|
545
545
|
const data = await res.json()
|
|
546
546
|
expect(data.id).toBeDefined()
|
|
547
547
|
// CF saved
|
|
548
|
+
expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
|
|
548
549
|
expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ entityId: 'example.todo', values: { priority: 3 } }))
|
|
549
550
|
// Event + indexer delegated to data engine
|
|
550
551
|
expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
|
|
@@ -571,6 +572,7 @@ describe('CRUD Factory', () => {
|
|
|
571
572
|
await em.persist(created).flush()
|
|
572
573
|
const res = await route.PUT(new Request('http://x/api/example/todos', { method: 'PUT', body: JSON.stringify({ id: created.id, title: 'X2', cf_priority: 5 }), headers: { 'content-type': 'application/json' } }))
|
|
573
574
|
expect(res.status).toBe(200)
|
|
575
|
+
expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
|
|
574
576
|
expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ values: { priority: 5 } }))
|
|
575
577
|
expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
|
|
576
578
|
const updatedCall = mockDataEngine.emitOrmEntityEvent.mock.calls.at(0)
|
|
@@ -592,6 +594,22 @@ describe('CRUD Factory', () => {
|
|
|
592
594
|
expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
|
|
593
595
|
})
|
|
594
596
|
|
|
597
|
+
it('POST surfaces CRUD side-effect failures after custom field writes', async () => {
|
|
598
|
+
mockDataEngine.emitOrmEntityEvent.mockImplementationOnce(async () => {
|
|
599
|
+
throw new Error('index write failed')
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
const res = await route.POST(new Request('http://x/api/example/todos', {
|
|
603
|
+
method: 'POST',
|
|
604
|
+
body: JSON.stringify({ title: 'Indexed', is_done: true, cf_priority: 3 }),
|
|
605
|
+
headers: { 'content-type': 'application/json' },
|
|
606
|
+
}))
|
|
607
|
+
|
|
608
|
+
expect(res.status).toBe(500)
|
|
609
|
+
expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
|
|
610
|
+
expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
|
|
611
|
+
})
|
|
612
|
+
|
|
595
613
|
it('PUT rolls back the entity update when the custom field write fails', async () => {
|
|
596
614
|
const created = em.create(Todo, { title: 'Before', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
|
|
597
615
|
created.id = '123e4567-e89b-12d3-a456-426614174003'
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { matchesEntity, runMutationGuards } from '../mutation-guard-registry'
|
|
2
2
|
import type { MutationGuard, MutationGuardInput } from '../mutation-guard-registry'
|
|
3
|
+
import {
|
|
4
|
+
applyAclFeatureOverrides,
|
|
5
|
+
resetModuleContractOverridesForTests,
|
|
6
|
+
} from '../../../modules/overrides'
|
|
3
7
|
|
|
4
8
|
describe('matchesEntity', () => {
|
|
5
9
|
it('matches wildcard "*" against any entity', () => {
|
|
@@ -24,6 +28,10 @@ describe('matchesEntity', () => {
|
|
|
24
28
|
})
|
|
25
29
|
|
|
26
30
|
describe('runMutationGuards', () => {
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
resetModuleContractOverridesForTests()
|
|
33
|
+
})
|
|
34
|
+
|
|
27
35
|
const baseInput: MutationGuardInput = {
|
|
28
36
|
tenantId: 'tenant-1',
|
|
29
37
|
organizationId: 'org-1',
|
|
@@ -159,6 +167,24 @@ describe('runMutationGuards', () => {
|
|
|
159
167
|
expect(resultWithWildcard.errorBody).toEqual({ error: 'Blocked by wildcard', guardId: 'g1' })
|
|
160
168
|
})
|
|
161
169
|
|
|
170
|
+
it('does not run a guard gated by a nulled ACL feature', async () => {
|
|
171
|
+
applyAclFeatureOverrides({ 'premium.locks': null })
|
|
172
|
+
const guard = makeGuard({
|
|
173
|
+
id: 'g1',
|
|
174
|
+
features: ['premium.locks'],
|
|
175
|
+
validate: jest.fn().mockResolvedValue({ ok: false }),
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const result = await runMutationGuards(
|
|
179
|
+
[guard],
|
|
180
|
+
baseInput,
|
|
181
|
+
{ userFeatures: ['*', 'premium.locks'] },
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
expect(result.ok).toBe(true)
|
|
185
|
+
expect(guard.validate).not.toHaveBeenCalled()
|
|
186
|
+
})
|
|
187
|
+
|
|
162
188
|
it('uses custom error body when provided', async () => {
|
|
163
189
|
const guard = makeGuard({
|
|
164
190
|
id: 'g1',
|
|
@@ -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
|
@@ -2333,6 +2333,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2333
2333
|
organizationId: targetOrgId,
|
|
2334
2334
|
tenantId: writeTenantId,
|
|
2335
2335
|
values,
|
|
2336
|
+
notify: false,
|
|
2336
2337
|
})
|
|
2337
2338
|
}
|
|
2338
2339
|
}
|
|
@@ -2670,6 +2671,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2670
2671
|
organizationId: targetOrgId,
|
|
2671
2672
|
tenantId: writeTenantId,
|
|
2672
2673
|
values,
|
|
2674
|
+
notify: false,
|
|
2673
2675
|
})
|
|
2674
2676
|
}
|
|
2675
2677
|
}
|
|
@@ -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
|
+
}
|