@onlineapps/service-wrapper 3.4.6 → 3.4.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.
@@ -166,5 +166,5 @@ curl -X POST http://localhost:33000/workflow \
166
166
 
167
167
  - [Operations Standard](/docs/OPERATIONS_STANDARD.md)
168
168
  - [Service Wrapper Configuration](../docs/CONFIGURATION_GUIDE.md)
169
- - [Workflow Module](/docs/modules/workflow.md)
169
+ - [Workflow Architecture](/docs/architecture/workflow.md)
170
170
  - [Hello Service Examples](/services/hello-service/docs/WORKFLOW_EXECUTION.md)
package/jest.config.js CHANGED
@@ -9,8 +9,16 @@ module.exports = {
9
9
  testMatch: [
10
10
  '**/tests/**/*.test.js'
11
11
  ],
12
+ // The integration tier owns its runner (jest.integration.config.js) because it
13
+ // needs a globalSetup that probes its live sidecar and env config that fails
14
+ // fast — and because it may not use the moduleNameMapper below, which points
15
+ // the connectors at mocks.
16
+ // Picked up by THIS config it ran without that setup, which is why it used to
17
+ // gate itself with `it.skip(...)` and report green while executing nothing.
18
+ // Run it with `npm run test:integration`.
12
19
  testPathIgnorePatterns: [
13
- '/node_modules/'
20
+ '/node_modules/',
21
+ '/tests/integration/'
14
22
  ],
15
23
  coverageThresholds: {
16
24
  global: {
@@ -28,7 +36,12 @@ module.exports = {
28
36
  '@onlineapps/conn-orch-orchestrator': '<rootDir>/tests/mocks/connectors.js',
29
37
  '@onlineapps/conn-orch-orchestrator/package.json': '<rootDir>/tests/mocks/orchestrator-package-v2.js',
30
38
  '@onlineapps/conn-orch-cookbook': '<rootDir>/tests/mocks/connectors.js',
31
- '@onlineapps/conn-base-state': '<rootDir>/tests/mocks/connectors.js'
39
+ // Anchored on purpose: moduleNameMapper keys are unanchored regexes, so the
40
+ // bare form also matches every absolute path containing the package name —
41
+ // including node_modules/@onlineapps/conn-base-state/src/index.js itself.
42
+ // That made the real connector unreachable from any test in this package.
43
+ // tests/integration/state-in-context.integration.test.js loads it by path.
44
+ '^@onlineapps/conn-base-state$': '<rootDir>/tests/mocks/connectors.js'
32
45
  },
33
46
  setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
34
47
  verbose: true
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Separate Jest runner for the integration tier.
5
+ *
6
+ * It exists for two reasons, both of which the shared `jest.config.js` cannot
7
+ * serve:
8
+ *
9
+ * 1. `globalSetup` probes the live Redis and aborts the run when it is absent,
10
+ * so the tier can never report a result on an environment that cannot serve
11
+ * it (`tests/integration/setup.js`).
12
+ * 2. NO `moduleNameMapper`. The shared config maps the connectors to
13
+ * `tests/mocks/connectors.js`, which is correct for the unit tier and wrong
14
+ * here: an integration test that runs against mocks proves the call was
15
+ * made, never the result (`architecture-principles.md` §10a — mocks are for
16
+ * neighbours, never for the SUT).
17
+ *
18
+ * `tests/setup.js` is deliberately NOT loaded: it silences `console.error` and
19
+ * `console.warn`, which is exactly the output an integration failure needs.
20
+ */
21
+
22
+ module.exports = {
23
+ testEnvironment: 'node',
24
+ testMatch: ['**/tests/integration/**/*.test.js'],
25
+ testTimeout: 30000,
26
+ globalSetup: './tests/integration/setup.js',
27
+ coverageDirectory: 'coverage-integration',
28
+ verbose: true
29
+ };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@onlineapps/service-wrapper",
3
- "version": "3.4.6",
3
+ "version": "3.4.7",
4
4
  "description": "Thin orchestration layer for microservices - delegates all infrastructure concerns to specialized connectors",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
7
7
  "test": "jest",
8
8
  "test:unit": "jest tests/unit",
9
9
  "test:component": "jest tests/component",
10
- "test:integration": "jest tests/integration",
10
+ "test:integration": "jest --config=jest.integration.config.js",
11
11
  "test:coverage": "jest --coverage",
12
12
  "test:mocked": "node test/run-tests.js",
13
13
  "docs": "jsdoc2md --files src/**/*.js > API.md",
@@ -25,19 +25,19 @@
25
25
  "license": "MIT",
26
26
  "dependencies": {
27
27
  "@onlineapps/conn-base-cache": "1.0.9",
28
- "@onlineapps/conn-base-monitoring": "1.0.14",
28
+ "@onlineapps/conn-base-monitoring": "1.0.15",
29
29
  "@onlineapps/conn-base-state": "1.0.1",
30
- "@onlineapps/conn-infra-error-handler": "1.0.13",
30
+ "@onlineapps/conn-infra-error-handler": "1.0.14",
31
31
  "@onlineapps/conn-infra-mq": "1.1.70",
32
32
  "@onlineapps/conn-infra-secrets": "1.0.0",
33
33
  "@onlineapps/conn-orch-cookbook": "2.1.4",
34
- "@onlineapps/conn-orch-orchestrator": "2.1.6",
34
+ "@onlineapps/conn-orch-orchestrator": "2.1.7",
35
35
  "@onlineapps/conn-orch-registry": "1.2.2",
36
36
  "@onlineapps/conn-orch-validator": "3.3.2",
37
- "@onlineapps/infrastructure-tools": "1.2.5",
38
- "@onlineapps/monitoring-core": "1.0.25",
37
+ "@onlineapps/infrastructure-tools": "1.2.6",
38
+ "@onlineapps/monitoring-core": "1.0.26",
39
39
  "@onlineapps/runtime-config": "1.0.2",
40
- "@onlineapps/service-common": "1.2.0",
40
+ "@onlineapps/service-common": "2.0.0",
41
41
  "ajv": "8.17.1",
42
42
  "ajv-formats": "3.0.1"
43
43
  },
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { OperationContext } = require('./OperationContext');
4
+ const { InvalidEnvelopeError } = require('./ErrorMapper');
4
5
 
5
6
  /**
6
7
  * ContextBuilder — assembles an OperationContext for a single invocation.
@@ -8,8 +9,11 @@ const { OperationContext } = require('./OperationContext');
8
9
  * Contract: RFC `api/docs/architecture/biz-service-invocation-model.md` §5.6 + §5.9.
9
10
  * Layer: L3 orchestration (see ARCHITECTURE_PRINCIPLES.md §7).
10
11
  * - Depends on connectors (L1/L2) via constructor injection only.
11
- * - Imports `./OperationContext` only. MUST NOT import HandlerRegistry,
12
- * SchemaValidator, or ErrorMapper.
12
+ * - Imports `./OperationContext` and the error CLASSES from `./ErrorMapper`
13
+ * (L2 types, no behaviour — ErrorMapper itself imports nothing, so there is
14
+ * no cycle; `./OperationContext` already depends on the same module).
15
+ * MUST NOT import HandlerRegistry or SchemaValidator, and MUST NOT call
16
+ * ErrorMapper.map() — mapping stays the wrapper's job.
13
17
  * - Does NOT validate handler input/output schemas (SchemaValidator owns that).
14
18
  * - Does NOT invoke handlers (ServiceWrapper owns that).
15
19
  *
@@ -20,14 +24,14 @@ const { OperationContext } = require('./OperationContext');
20
24
  class ContextBuilder {
21
25
  /**
22
26
  * @param {Object} deps
23
- * @param {Object} deps.connectors - { storage, cache, http, secrets, monitoring, mq }
27
+ * @param {Object} deps.connectors - { storage, cache, http, secrets, state, monitoring, mq }
24
28
  * @param {Object} deps.config - Read-only service config snapshot.
25
29
  * @param {Object} deps.logger - Base logger (must expose `.child()` to be useful).
26
30
  */
27
31
  constructor({ connectors, config, logger } = {}) {
28
32
  if (!connectors) {
29
33
  throw new Error(
30
- '[ContextBuilder] connectors is required - Expected { storage, cache, http, secrets, monitoring, mq }'
34
+ '[ContextBuilder] connectors is required - Expected { storage, cache, http, secrets, state, monitoring, mq }'
31
35
  );
32
36
  }
33
37
  if (!config) {
@@ -54,7 +58,7 @@ class ContextBuilder {
54
58
  * Unset bundle_scope defaults to 'workspace' (strictest) per RFC §5.6 policy.
55
59
  * 3. Scoped logger (via base.child() when available; fall back to base logger
56
60
  * only when the base does not implement .child — bunyan/pino both do).
57
- * 4. Connector acquisition (db, cache, httpClient, secrets facade).
61
+ * 4. Connector acquisition (db, cache, httpClient, state, secrets facade).
58
62
  * 5. stream(chunk) -> mq.publishChunk (TODO P4.x biz-aiclient integration).
59
63
  * 6. abortSignal — external mqMessage.abortSignal wins; otherwise a no-op
60
64
  * controller (plumbing for cancellation is intentionally out of scope here).
@@ -67,6 +71,8 @@ class ContextBuilder {
67
71
  */
68
72
  async build(mqMessage, operationSpec) {
69
73
  this._validateMqMessage(mqMessage);
74
+ // Untyped on purpose: a missing operationSpec means operations.json or the
75
+ // registry lookup is broken — our bug, so it must surface as a 500.
70
76
  if (!operationSpec || typeof operationSpec !== 'object') {
71
77
  throw new Error(
72
78
  '[ContextBuilder] operationSpec is required - Expected operations.json entry with bundle_scope'
@@ -90,6 +96,10 @@ class ContextBuilder {
90
96
  const { db, releaseDb } = await this._acquireDb();
91
97
  const cache = this._connectors.cache;
92
98
  const httpClient = this._connectors.http;
99
+ // Persistent Redis state (conn-base-state). Plain pass-through like `cache`:
100
+ // the connector namespaces its keys by `state:<serviceName>:` on its own, so
101
+ // there is no invocation scope for the builder to inject.
102
+ const state = this._connectors.state;
93
103
  const secretsAdapter = this._connectors.secrets;
94
104
  const secrets = {
95
105
  // Handlers call ctx.secrets.get(ref); the facade injects the invocation
@@ -136,6 +146,7 @@ class ContextBuilder {
136
146
  cache,
137
147
  httpClient,
138
148
  secrets,
149
+ state,
139
150
  stream,
140
151
  abortSignal,
141
152
  config: this._config
@@ -164,32 +175,41 @@ class ContextBuilder {
164
175
  }
165
176
 
166
177
  /**
178
+ * Validate the MQ envelope shape. Every field checked here is supplied by the
179
+ * caller, so every failure is InvalidEnvelopeError (400) naming the key.
167
180
  * @private
168
181
  */
169
182
  _validateMqMessage(mqMessage) {
170
183
  if (!mqMessage || typeof mqMessage !== 'object') {
171
- throw new Error('[ContextBuilder] mqMessage is required - Expected parsed MQ message object');
184
+ throw new InvalidEnvelopeError('[ContextBuilder] mqMessage is required - Expected parsed MQ message object');
172
185
  }
173
186
  if (typeof mqMessage.operation !== 'string' || mqMessage.operation.length === 0) {
174
- throw new Error('[ContextBuilder] mqMessage.operation is required - Expected non-empty string');
187
+ throw new InvalidEnvelopeError('[ContextBuilder] mqMessage.operation is required - Expected non-empty string');
175
188
  }
176
189
  if (!mqMessage.envelope || typeof mqMessage.envelope !== 'object') {
177
- throw new Error(
190
+ throw new InvalidEnvelopeError(
178
191
  '[ContextBuilder] mqMessage.envelope is required - ' +
179
192
  'Expected object with tenant_id, workspace_id, optional person_id'
180
193
  );
181
194
  }
182
195
  if (typeof mqMessage.workflow_id !== 'string' || mqMessage.workflow_id.length === 0) {
183
- throw new Error('[ContextBuilder] mqMessage.workflow_id is required - Expected uuid string');
196
+ throw new InvalidEnvelopeError('[ContextBuilder] mqMessage.workflow_id is required - Expected uuid string');
184
197
  }
185
198
  if (typeof mqMessage.correlation_id !== 'string' || mqMessage.correlation_id.length === 0) {
186
- throw new Error('[ContextBuilder] mqMessage.correlation_id is required - Expected uuid string');
199
+ throw new InvalidEnvelopeError('[ContextBuilder] mqMessage.correlation_id is required - Expected uuid string');
187
200
  }
188
201
  }
189
202
 
190
203
  /**
191
204
  * Enforce bundle_scope tenancy requirements per RFC §5.6 + §6.3 Bug 1 mapping.
192
205
  * Tenancy is derived from the MQ envelope only — never from HTTP headers.
206
+ *
207
+ * Two fault owners, two statuses:
208
+ * - `bundleScope` comes from OUR operations.json. An invalid value is a
209
+ * packaging bug, stays an untyped Error and maps to 500.
210
+ * - `envelope.*` comes from the caller / the cookbook author. A missing id
211
+ * is their bug, is thrown as InvalidEnvelopeError and maps to 400 with
212
+ * the missing key named.
193
213
  * @private
194
214
  */
195
215
  _validateTenancy(bundleScope, envelope) {
@@ -202,7 +222,7 @@ class ContextBuilder {
202
222
 
203
223
  if (bundleScope === 'tenant' || bundleScope === 'workspace') {
204
224
  if (envelope.tenant_id === undefined || envelope.tenant_id === null) {
205
- throw new Error(
225
+ throw new InvalidEnvelopeError(
206
226
  `[ContextBuilder] mqMessage.envelope.tenant_id is required for bundle_scope="${bundleScope}" - ` +
207
227
  'tenancy must come from the MQ envelope (see RFC §6.3 Bug 1)'
208
228
  );
@@ -210,9 +230,10 @@ class ContextBuilder {
210
230
  }
211
231
  if (bundleScope === 'workspace') {
212
232
  if (envelope.workspace_id === undefined || envelope.workspace_id === null) {
213
- throw new Error(
233
+ throw new InvalidEnvelopeError(
214
234
  '[ContextBuilder] mqMessage.envelope.workspace_id is required for bundle_scope="workspace" - ' +
215
- 'workspace-scoped ops must receive a workspace_id in the MQ envelope'
235
+ 'workspace-scoped ops must receive a workspace_id in the MQ envelope. ' +
236
+ 'Fix: set defaults.workspace_id (or the step-level workspace_id) in the cookbook'
216
237
  );
217
238
  }
218
239
  }
@@ -10,33 +10,32 @@
10
10
  *
11
11
  * Exports:
12
12
  * - ErrorMapper — maps exceptions to { status, error: {...} } envelopes.
13
- * - ValidationError — thrown by SchemaValidator (phase: 'input' | 'output').
14
- * - UnknownOperationError — thrown by HandlerRegistry for unregistered ops.
15
13
  * - BusinessError — base class for handler-authored errors (code + status required).
14
+ * - ValidationError — 400 VALIDATION_FAILED. Thrown by SchemaValidator
15
+ * (phase: 'input' | 'output') and by handlers.
16
+ * - NotFoundError — 404 RESOURCE_NOT_FOUND.
17
+ * - ConflictError — 409 DUPLICATE_RESOURCE.
18
+ * - BusinessRuleError — 422 BUSINESS_RULE_VIOLATED.
19
+ * - AuthorizationError — 403 FORBIDDEN.
20
+ * - InvalidEnvelopeError — 400 INVALID_ENVELOPE. Thrown by ContextBuilder when
21
+ * the MQ envelope from the caller is malformed.
22
+ * - UnknownOperationError — thrown by HandlerRegistry for unregistered ops.
16
23
  * - AbortError — thrown when ctx.abortSignal aborts the invocation.
24
+ *
25
+ * Two constructor shapes, because the parameter sets genuinely differ:
26
+ * - `BusinessError` takes `{ code, status, message, details }` — the author
27
+ * must supply code + status, so they are named.
28
+ * - every named subclass takes `(message, { details, operation })` — code and
29
+ * status are the subclass's own identity and MUST NOT be author-supplied.
30
+ * The subclass shape is deliberately identical to the retired
31
+ * `@onlineapps/service-common` hierarchy so a biz service migrates by changing
32
+ * its import line only.
33
+ *
34
+ * ❌ No subclass carries a default status. A default (service-common used
35
+ * `statusCode = 500`) turns an unset field into a fabricated server error; the
36
+ * base class fail-fasts instead.
17
37
  */
18
38
 
19
- class ValidationError extends Error {
20
- constructor({ message, details, phase, operation } = {}) {
21
- super(message);
22
- this.name = 'ValidationError';
23
- this.code = 'VALIDATION_FAILED';
24
- this.status = 400;
25
- this.details = details;
26
- this.phase = phase;
27
- this.operation = operation;
28
- }
29
- }
30
-
31
- class UnknownOperationError extends Error {
32
- constructor(message) {
33
- super(message);
34
- this.name = 'UnknownOperationError';
35
- this.code = 'UNKNOWN_OPERATION';
36
- this.status = 404;
37
- }
38
- }
39
-
40
39
  class BusinessError extends Error {
41
40
  constructor({ code, status, message, details } = {}) {
42
41
  if (!code) {
@@ -56,6 +55,105 @@ class BusinessError extends Error {
56
55
  }
57
56
  }
58
57
 
58
+ /**
59
+ * 400. Input a caller (or a handler's own field check) rejected.
60
+ *
61
+ * `phase` is set only by SchemaValidator: 'input' means the caller's payload
62
+ * failed the operation schema, 'output' means the handler produced a payload
63
+ * that violates its own contract (a server bug — see map()). A handler-thrown
64
+ * ValidationError has no phase and maps to a plain 400.
65
+ *
66
+ * @param {string} message
67
+ * @param {Object} [options]
68
+ * @param {*} [options.details]
69
+ * @param {'input'|'output'} [options.phase]
70
+ * @param {string} [options.operation]
71
+ */
72
+ class ValidationError extends BusinessError {
73
+ constructor(message, { details, phase, operation } = {}) {
74
+ super({ code: 'VALIDATION_FAILED', status: 400, message, details });
75
+ this.name = 'ValidationError';
76
+ if (phase !== undefined) {
77
+ this.phase = phase;
78
+ }
79
+ if (operation !== undefined) {
80
+ this.operation = operation;
81
+ }
82
+ }
83
+ }
84
+
85
+ /** 404. The addressed resource does not exist. */
86
+ class NotFoundError extends BusinessError {
87
+ constructor(message, { details, operation } = {}) {
88
+ super({ code: 'RESOURCE_NOT_FOUND', status: 404, message, details });
89
+ this.name = 'NotFoundError';
90
+ if (operation !== undefined) {
91
+ this.operation = operation;
92
+ }
93
+ }
94
+ }
95
+
96
+ /** 409. The write collides with an existing resource or uniqueness constraint. */
97
+ class ConflictError extends BusinessError {
98
+ constructor(message, { details, operation } = {}) {
99
+ super({ code: 'DUPLICATE_RESOURCE', status: 409, message, details });
100
+ this.name = 'ConflictError';
101
+ if (operation !== undefined) {
102
+ this.operation = operation;
103
+ }
104
+ }
105
+ }
106
+
107
+ /** 422. Shape is valid, a domain rule says no. */
108
+ class BusinessRuleError extends BusinessError {
109
+ constructor(message, { details, operation } = {}) {
110
+ super({ code: 'BUSINESS_RULE_VIOLATED', status: 422, message, details });
111
+ this.name = 'BusinessRuleError';
112
+ if (operation !== undefined) {
113
+ this.operation = operation;
114
+ }
115
+ }
116
+ }
117
+
118
+ /** 403. The caller is authenticated but not entitled to this resource. */
119
+ class AuthorizationError extends BusinessError {
120
+ constructor(message, { details, operation } = {}) {
121
+ super({ code: 'FORBIDDEN', status: 403, message, details });
122
+ this.name = 'AuthorizationError';
123
+ if (operation !== undefined) {
124
+ this.operation = operation;
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * 400. The MQ envelope the caller (or the cookbook author) sent is malformed —
131
+ * a missing tenant_id / workspace_id / workflow_id / correlation_id.
132
+ *
133
+ * Distinct from ValidationError on purpose: VALIDATION_FAILED means the
134
+ * operation's own input schema was violated, INVALID_ENVELOPE means the
135
+ * transport-level context never arrived, so a reader of the log can tell a
136
+ * broken cookbook from a broken payload without opening the message.
137
+ */
138
+ class InvalidEnvelopeError extends BusinessError {
139
+ constructor(message, { details, operation } = {}) {
140
+ super({ code: 'INVALID_ENVELOPE', status: 400, message, details });
141
+ this.name = 'InvalidEnvelopeError';
142
+ if (operation !== undefined) {
143
+ this.operation = operation;
144
+ }
145
+ }
146
+ }
147
+
148
+ class UnknownOperationError extends Error {
149
+ constructor(message) {
150
+ super(message);
151
+ this.name = 'UnknownOperationError';
152
+ this.code = 'UNKNOWN_OPERATION';
153
+ this.status = 404;
154
+ }
155
+ }
156
+
59
157
  class AbortError extends Error {
60
158
  constructor(message) {
61
159
  super(message || 'Invocation aborted');
@@ -115,6 +213,19 @@ class ErrorMapper {
115
213
  }
116
214
 
117
215
  if (err instanceof BusinessError) {
216
+ // A BusinessError with a 5xx status is a server fault wearing a business
217
+ // class. It is logged like one — `error` level WITH the stack — and the
218
+ // caller gets the same neutral text as any other internal error, so the
219
+ // original message (which may name a tenant, a column or a credential)
220
+ // never leaves the process. Details are dropped for the same reason.
221
+ if (err.status >= 500) {
222
+ this._logger.error(
223
+ 'handler raised BusinessError with a server-fault status',
224
+ { err, operation, correlation_id, code: err.code, status: err.status, stack: err && err.stack }
225
+ );
226
+ return this._buildError(err.status, err.code, 'An internal error occurred', undefined, correlation_id);
227
+ }
228
+
118
229
  this._logger.info(
119
230
  'handler raised BusinessError',
120
231
  { err, operation, correlation_id, code: err.code, status: err.status }
@@ -155,8 +266,13 @@ class ErrorMapper {
155
266
 
156
267
  module.exports = {
157
268
  ErrorMapper,
269
+ BusinessError,
158
270
  ValidationError,
271
+ NotFoundError,
272
+ ConflictError,
273
+ BusinessRuleError,
274
+ AuthorizationError,
275
+ InvalidEnvelopeError,
159
276
  UnknownOperationError,
160
- BusinessError,
161
277
  AbortError
162
278
  };
@@ -8,7 +8,7 @@ const { ValidationError } = require('./ErrorMapper');
8
8
  *
9
9
  * Contract: RFC `api/docs/architecture/biz-service-invocation-model.md` §5.6.
10
10
  *
11
- * Exactly 14 named fields, assembled by ContextBuilder.build() once per
11
+ * Exactly 15 named fields, assembled by ContextBuilder.build() once per
12
12
  * invocation and frozen before the handler runs:
13
13
  *
14
14
  * Row-level scoping (from MQ envelope):
@@ -27,6 +27,8 @@ const { ValidationError } = require('./ErrorMapper');
27
27
  * cache — CacheAdapter (tenant-namespaced)
28
28
  * httpClient — axios instance
29
29
  * secrets — { get(name) => Promise<string> }
30
+ * state — StateConnector (persistent Redis state, no TTL,
31
+ * keys namespaced `state:<serviceName>:`) | null
30
32
  * stream — (chunk) => Promise<void>
31
33
  * abortSignal — AbortSignal
32
34
  * config — read-only service config snapshot
@@ -49,14 +51,14 @@ const { ValidationError } = require('./ErrorMapper');
49
51
  */
50
52
  class OperationContext {
51
53
  /**
52
- * @param {Object} fields - The 14 fields listed above, fully populated.
54
+ * @param {Object} fields - The 15 fields listed above, fully populated.
53
55
  */
54
56
  constructor(fields) {
55
57
  if (!fields || typeof fields !== 'object') {
56
58
  throw new Error(
57
- '[OperationContext] fields is required - Expected object with 14 named fields per RFC §5.6 ' +
59
+ '[OperationContext] fields is required - Expected object with 15 named fields per RFC §5.6 ' +
58
60
  '(tenant_id, workspace_id, person_id, operation_name, workflow_id, correlation_id, ' +
59
- 'logger, db, cache, httpClient, secrets, stream, abortSignal, config)'
61
+ 'logger, db, cache, httpClient, secrets, state, stream, abortSignal, config)'
60
62
  );
61
63
  }
62
64
 
@@ -91,11 +93,10 @@ class OperationContext {
91
93
  const to = OperationContext._parseBoundary(dateTo, 'dateTo', 'end');
92
94
 
93
95
  if (to.getTime() < from.getTime()) {
94
- throw new ValidationError({
95
- message:
96
- '[OperationContext.normalizeDateRange] dateTo is earlier than dateFrom - ' +
97
- 'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
98
- });
96
+ throw new ValidationError(
97
+ '[OperationContext.normalizeDateRange] dateTo is earlier than dateFrom - ' +
98
+ 'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
99
+ );
99
100
  }
100
101
 
101
102
  return { from, to };
@@ -110,27 +111,24 @@ class OperationContext {
110
111
  */
111
112
  static _parseBoundary(value, field, mode) {
112
113
  if (value === null || value === undefined) {
113
- throw new ValidationError({
114
- message:
115
- `[OperationContext.normalizeDateRange] ${field} is null or undefined - ` +
116
- 'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
117
- });
114
+ throw new ValidationError(
115
+ `[OperationContext.normalizeDateRange] ${field} is null or undefined - ` +
116
+ 'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
117
+ );
118
118
  }
119
119
  if (typeof value !== 'string') {
120
- throw new ValidationError({
121
- message:
122
- `[OperationContext.normalizeDateRange] ${field} is not a string - ` +
123
- 'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
124
- });
120
+ throw new ValidationError(
121
+ `[OperationContext.normalizeDateRange] ${field} is not a string - ` +
122
+ 'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
123
+ );
125
124
  }
126
125
 
127
126
  const trimmed = value.trim();
128
127
  if (trimmed.length === 0) {
129
- throw new ValidationError({
130
- message:
131
- `[OperationContext.normalizeDateRange] ${field} is empty - ` +
132
- 'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
133
- });
128
+ throw new ValidationError(
129
+ `[OperationContext.normalizeDateRange] ${field} is empty - ` +
130
+ 'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
131
+ );
134
132
  }
135
133
 
136
134
  const isoDate = /^\d{4}-\d{2}-\d{2}$/;
@@ -143,19 +141,17 @@ class OperationContext {
143
141
  } else if (isoDateTime.test(trimmed)) {
144
142
  d = new Date(trimmed);
145
143
  } else {
146
- throw new ValidationError({
147
- message:
148
- `[OperationContext.normalizeDateRange] ${field} is unparseable - ` +
149
- 'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
150
- });
144
+ throw new ValidationError(
145
+ `[OperationContext.normalizeDateRange] ${field} is unparseable - ` +
146
+ 'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
147
+ );
151
148
  }
152
149
 
153
150
  if (isNaN(d.getTime())) {
154
- throw new ValidationError({
155
- message:
156
- `[OperationContext.normalizeDateRange] ${field} is unparseable - ` +
157
- 'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
158
- });
151
+ throw new ValidationError(
152
+ `[OperationContext.normalizeDateRange] ${field} is unparseable - ` +
153
+ 'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
154
+ );
159
155
  }
160
156
 
161
157
  return d;
@@ -145,8 +145,7 @@ class SchemaValidator {
145
145
  }
146
146
 
147
147
  const errors = fn.errors || [];
148
- throw new ValidationError({
149
- message: _summariseAjvError('input', errors),
148
+ throw new ValidationError(_summariseAjvError('input', errors), {
150
149
  phase: 'input',
151
150
  operation,
152
151
  details: errors
@@ -180,8 +179,7 @@ class SchemaValidator {
180
179
  }
181
180
 
182
181
  const errors = fn.errors || [];
183
- throw new ValidationError({
184
- message: _summariseAjvError('output', errors),
182
+ throw new ValidationError(_summariseAjvError('output', errors), {
185
183
  phase: 'output',
186
184
  operation,
187
185
  details: errors
package/src/index.js CHANGED
@@ -17,6 +17,7 @@ const { ConfigLoader } = require('./ConfigLoader');
17
17
  const runtimeCfg = require('./config');
18
18
  const pkg = require('../package.json');
19
19
  const { createTenantContextMiddleware } = require('./createTenantContextMiddleware');
20
+ const { setLogger: setSharedLogger, getLogger: getSharedLogger } = require('./logger');
20
21
 
21
22
  // Note: WorkflowProcessor functionality has been moved to
22
23
  // @onlineapps/conn-orch-orchestrator. The former ApiCaller /
@@ -124,9 +125,14 @@ const { ContextBuilder } = require('./ContextBuilder');
124
125
  const { SchemaValidator } = require('./SchemaValidator');
125
126
  const {
126
127
  ErrorMapper,
128
+ BusinessError,
127
129
  ValidationError,
130
+ NotFoundError,
131
+ ConflictError,
132
+ BusinessRuleError,
133
+ AuthorizationError,
134
+ InvalidEnvelopeError,
128
135
  UnknownOperationError,
129
- BusinessError,
130
136
  AbortError
131
137
  } = require('./ErrorMapper');
132
138
 
@@ -135,6 +141,12 @@ module.exports.ServiceWrapper = ServiceWrapper;
135
141
  module.exports.ConfigLoader = ConfigLoader;
136
142
  module.exports.bootstrap = bootstrap;
137
143
  module.exports.createTenantContextMiddleware = createTenantContextMiddleware;
144
+
145
+ // Shared logger proxy (F15 step 1). Single owner of the late-binding
146
+ // setLogger/getLogger pair that every biz service used to keep as its own
147
+ // `src/lib/logger.js` copy. See src/logger.js for the contract.
148
+ module.exports.setLogger = setSharedLogger;
149
+ module.exports.getLogger = getSharedLogger;
138
150
  module.exports.default = ServiceWrapper;
139
151
  module.exports.VERSION = pkg.version;
140
152
 
@@ -144,7 +156,17 @@ module.exports.OperationContext = OperationContext;
144
156
  module.exports.ContextBuilder = ContextBuilder;
145
157
  module.exports.SchemaValidator = SchemaValidator;
146
158
  module.exports.ErrorMapper = ErrorMapper;
159
+
160
+ // Error hierarchy (DÁVKA 29). Single owner of the business-error classes every
161
+ // biz service throws; the retired @onlineapps/service-common hierarchy used the
162
+ // same `(message, options)` signature, so migration is an import-line change.
163
+ // Contract + per-class status/code: src/ErrorMapper.js header.
164
+ module.exports.BusinessError = BusinessError;
147
165
  module.exports.ValidationError = ValidationError;
166
+ module.exports.NotFoundError = NotFoundError;
167
+ module.exports.ConflictError = ConflictError;
168
+ module.exports.BusinessRuleError = BusinessRuleError;
169
+ module.exports.AuthorizationError = AuthorizationError;
170
+ module.exports.InvalidEnvelopeError = InvalidEnvelopeError;
148
171
  module.exports.UnknownOperationError = UnknownOperationError;
149
- module.exports.BusinessError = BusinessError;
150
172
  module.exports.AbortError = AbortError;
package/src/logger.js ADDED
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module @onlineapps/service-wrapper/logger
5
+ * @description Shared logger proxy for business services.
6
+ *
7
+ * Handlers, services and controllers are `require`d at module load time, long
8
+ * before `wrapper.initialize()` has produced a real logger. This module is the
9
+ * late-binding indirection between the two: bootstrap calls `setLogger()` once,
10
+ * every consumer calls `getLogger()` whenever it actually logs.
11
+ *
12
+ * Fail-fast, no fallback. If `getLogger()` is reached before `setLogger()`, that
13
+ * is a wiring defect and it throws — it does NOT hand back a console-shaped
14
+ * substitute. A silent console fallback makes a service that lost its real
15
+ * logger look healthy: the log lines still appear on stdout, while nothing
16
+ * reaches the monitoring connector, the log files or the correlation context.
17
+ * See `architecture-principles.md` §3 (No Fallbacks) and §4 (Fail-Fast).
18
+ *
19
+ * Consumers call `getLogger()` at call time, never at module scope — binding
20
+ * the result into a module-level constant would capture the state before
21
+ * bootstrap and defeat the indirection.
22
+ *
23
+ * @example
24
+ * // bootstrap, once, after wrapper.initialize()
25
+ * const { setLogger } = require('@onlineapps/service-wrapper');
26
+ * setLogger(wrapper.logger);
27
+ *
28
+ * @example
29
+ * // any consumer
30
+ * const { getLogger } = require('@onlineapps/service-wrapper');
31
+ * function handle() {
32
+ * getLogger().info('Message', { data: 'value' });
33
+ * }
34
+ */
35
+
36
+ /**
37
+ * Methods a logger instance must expose. This is the contract every consumer
38
+ * of `getLogger()` relies on, so it is validated at injection time rather than
39
+ * discovered at the first `logger.debug(...)` in an error path.
40
+ * @type {string[]}
41
+ */
42
+ const REQUIRED_METHODS = ['info', 'warn', 'error', 'debug'];
43
+
44
+ const FIX_HINT =
45
+ 'Fix: pass wrapper.logger to setLogger() once during bootstrap, after wrapper.initialize().';
46
+
47
+ /** @type {Object|null} */
48
+ let _logger = null;
49
+
50
+ /**
51
+ * Inject the logger instance. Called once during bootstrap; a later call
52
+ * replaces the instance (tests wire an in-memory logger this way).
53
+ *
54
+ * @param {Object} logger - Logger exposing info/warn/error/debug as functions.
55
+ * @throws {Error} If `logger` is not an object exposing all four methods. The
56
+ * previously stored instance is left untouched when validation fails.
57
+ * @returns {void}
58
+ */
59
+ function setLogger(logger) {
60
+ if (logger === null || typeof logger !== 'object' || Array.isArray(logger)) {
61
+ throw new Error(
62
+ `[Logger] setLogger() received ${describe(logger)} - expected an object exposing ` +
63
+ `${REQUIRED_METHODS.join('/')}. ${FIX_HINT}`
64
+ );
65
+ }
66
+
67
+ const missing = REQUIRED_METHODS.filter((method) => typeof logger[method] !== 'function');
68
+ if (missing.length > 0) {
69
+ throw new Error(
70
+ `[Logger] setLogger() received an incomplete logger - missing callable method(s): ` +
71
+ `${missing.join(', ')}. Expected an object exposing ${REQUIRED_METHODS.join('/')}. ${FIX_HINT}`
72
+ );
73
+ }
74
+
75
+ _logger = logger;
76
+ }
77
+
78
+ /**
79
+ * Return the injected logger.
80
+ *
81
+ * @throws {Error} If `setLogger()` has not run yet. There is no console
82
+ * fallback — an uninitialised logger is a bootstrap defect, not a degraded
83
+ * mode to be papered over.
84
+ * @returns {Object} The logger passed to `setLogger()`.
85
+ */
86
+ function getLogger() {
87
+ if (_logger === null) {
88
+ throw new Error(
89
+ '[Logger] Logger not initialized - getLogger() was called before setLogger(wrapper.logger). ' +
90
+ 'Fix: call setLogger(wrapper.logger) once during bootstrap, after wrapper.initialize(), ' +
91
+ 'before any consumer runs.'
92
+ );
93
+ }
94
+
95
+ return _logger;
96
+ }
97
+
98
+ /**
99
+ * Name the rejected value in the error message, so the caller sees what it
100
+ * actually passed rather than a bare type name.
101
+ *
102
+ * @param {*} value - The rejected argument.
103
+ * @returns {string} Human-readable description.
104
+ * @private
105
+ */
106
+ function describe(value) {
107
+ if (value === null) {
108
+ return 'null';
109
+ }
110
+ if (Array.isArray(value)) {
111
+ return 'an array';
112
+ }
113
+ return `a value of type "${typeof value}"`;
114
+ }
115
+
116
+ module.exports = { setLogger, getLogger };