apcore-js 0.1.0

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.
Files changed (142) hide show
  1. package/.claude/settings.local.json +11 -0
  2. package/.gitmessage +60 -0
  3. package/.pre-commit-config.yaml +28 -0
  4. package/CHANGELOG.md +47 -0
  5. package/CLAUDE.md +68 -0
  6. package/README.md +131 -0
  7. package/apcore-logo.svg +79 -0
  8. package/package.json +37 -0
  9. package/planning/acl-system/overview.md +54 -0
  10. package/planning/acl-system/plan.md +92 -0
  11. package/planning/acl-system/state.json +76 -0
  12. package/planning/acl-system/tasks/acl-core.md +226 -0
  13. package/planning/acl-system/tasks/acl-rule.md +92 -0
  14. package/planning/acl-system/tasks/conditional-rules.md +259 -0
  15. package/planning/acl-system/tasks/pattern-matching.md +152 -0
  16. package/planning/acl-system/tasks/yaml-loading.md +271 -0
  17. package/planning/core-executor/overview.md +53 -0
  18. package/planning/core-executor/plan.md +88 -0
  19. package/planning/core-executor/state.json +76 -0
  20. package/planning/core-executor/tasks/async-support.md +106 -0
  21. package/planning/core-executor/tasks/execution-pipeline.md +113 -0
  22. package/planning/core-executor/tasks/redaction.md +85 -0
  23. package/planning/core-executor/tasks/safety-checks.md +65 -0
  24. package/planning/core-executor/tasks/setup.md +75 -0
  25. package/planning/decorator-bindings/overview.md +62 -0
  26. package/planning/decorator-bindings/plan.md +104 -0
  27. package/planning/decorator-bindings/state.json +87 -0
  28. package/planning/decorator-bindings/tasks/binding-directory.md +79 -0
  29. package/planning/decorator-bindings/tasks/binding-loader.md +148 -0
  30. package/planning/decorator-bindings/tasks/explicit-schemas.md +85 -0
  31. package/planning/decorator-bindings/tasks/function-module.md +127 -0
  32. package/planning/decorator-bindings/tasks/module-factory.md +89 -0
  33. package/planning/decorator-bindings/tasks/schema-modes.md +142 -0
  34. package/planning/middleware-system/overview.md +48 -0
  35. package/planning/middleware-system/plan.md +102 -0
  36. package/planning/middleware-system/state.json +65 -0
  37. package/planning/middleware-system/tasks/adapters.md +170 -0
  38. package/planning/middleware-system/tasks/base.md +115 -0
  39. package/planning/middleware-system/tasks/logging-middleware.md +304 -0
  40. package/planning/middleware-system/tasks/manager.md +313 -0
  41. package/planning/observability/overview.md +53 -0
  42. package/planning/observability/plan.md +119 -0
  43. package/planning/observability/state.json +98 -0
  44. package/planning/observability/tasks/context-logger.md +201 -0
  45. package/planning/observability/tasks/exporters.md +121 -0
  46. package/planning/observability/tasks/metrics-collector.md +162 -0
  47. package/planning/observability/tasks/metrics-middleware.md +141 -0
  48. package/planning/observability/tasks/obs-logging-middleware.md +179 -0
  49. package/planning/observability/tasks/span-model.md +120 -0
  50. package/planning/observability/tasks/tracing-middleware.md +179 -0
  51. package/planning/overview.md +81 -0
  52. package/planning/registry-system/overview.md +57 -0
  53. package/planning/registry-system/plan.md +114 -0
  54. package/planning/registry-system/state.json +109 -0
  55. package/planning/registry-system/tasks/dependencies.md +157 -0
  56. package/planning/registry-system/tasks/entry-point.md +148 -0
  57. package/planning/registry-system/tasks/metadata.md +198 -0
  58. package/planning/registry-system/tasks/registry-core.md +323 -0
  59. package/planning/registry-system/tasks/scanner.md +172 -0
  60. package/planning/registry-system/tasks/schema-export.md +261 -0
  61. package/planning/registry-system/tasks/types.md +124 -0
  62. package/planning/registry-system/tasks/validation.md +177 -0
  63. package/planning/schema-system/overview.md +56 -0
  64. package/planning/schema-system/plan.md +121 -0
  65. package/planning/schema-system/state.json +98 -0
  66. package/planning/schema-system/tasks/exporter.md +153 -0
  67. package/planning/schema-system/tasks/loader.md +106 -0
  68. package/planning/schema-system/tasks/ref-resolver.md +133 -0
  69. package/planning/schema-system/tasks/strict-mode.md +140 -0
  70. package/planning/schema-system/tasks/typebox-generation.md +133 -0
  71. package/planning/schema-system/tasks/types-and-annotations.md +160 -0
  72. package/planning/schema-system/tasks/validator.md +149 -0
  73. package/src/acl.ts +188 -0
  74. package/src/bindings.ts +208 -0
  75. package/src/config.ts +24 -0
  76. package/src/context.ts +75 -0
  77. package/src/decorator.ts +110 -0
  78. package/src/errors.ts +369 -0
  79. package/src/executor.ts +348 -0
  80. package/src/index.ts +81 -0
  81. package/src/middleware/adapters.ts +54 -0
  82. package/src/middleware/base.ts +33 -0
  83. package/src/middleware/index.ts +6 -0
  84. package/src/middleware/logging.ts +103 -0
  85. package/src/middleware/manager.ts +105 -0
  86. package/src/module.ts +41 -0
  87. package/src/observability/context-logger.ts +201 -0
  88. package/src/observability/index.ts +4 -0
  89. package/src/observability/metrics.ts +212 -0
  90. package/src/observability/tracing.ts +187 -0
  91. package/src/registry/dependencies.ts +99 -0
  92. package/src/registry/entry-point.ts +64 -0
  93. package/src/registry/index.ts +8 -0
  94. package/src/registry/metadata.ts +111 -0
  95. package/src/registry/registry.ts +314 -0
  96. package/src/registry/scanner.ts +150 -0
  97. package/src/registry/schema-export.ts +177 -0
  98. package/src/registry/types.ts +32 -0
  99. package/src/registry/validation.ts +38 -0
  100. package/src/schema/annotations.ts +67 -0
  101. package/src/schema/exporter.ts +93 -0
  102. package/src/schema/index.ts +14 -0
  103. package/src/schema/loader.ts +270 -0
  104. package/src/schema/ref-resolver.ts +235 -0
  105. package/src/schema/strict.ts +128 -0
  106. package/src/schema/types.ts +73 -0
  107. package/src/schema/validator.ts +82 -0
  108. package/src/utils/index.ts +1 -0
  109. package/src/utils/pattern.ts +30 -0
  110. package/tests/helpers.ts +30 -0
  111. package/tests/integration/test-acl-safety.test.ts +268 -0
  112. package/tests/integration/test-binding-executor.test.ts +194 -0
  113. package/tests/integration/test-e2e-flow.test.ts +117 -0
  114. package/tests/integration/test-error-propagation.test.ts +259 -0
  115. package/tests/integration/test-middleware-chain.test.ts +120 -0
  116. package/tests/integration/test-observability-integration.test.ts +438 -0
  117. package/tests/observability/test-context-logger.test.ts +123 -0
  118. package/tests/observability/test-metrics.test.ts +89 -0
  119. package/tests/observability/test-tracing.test.ts +131 -0
  120. package/tests/registry/test-dependencies.test.ts +70 -0
  121. package/tests/registry/test-entry-point.test.ts +133 -0
  122. package/tests/registry/test-metadata.test.ts +265 -0
  123. package/tests/registry/test-registry.test.ts +140 -0
  124. package/tests/registry/test-scanner.test.ts +257 -0
  125. package/tests/registry/test-schema-export.test.ts +224 -0
  126. package/tests/registry/test-validation.test.ts +75 -0
  127. package/tests/schema/test-loader.test.ts +97 -0
  128. package/tests/schema/test-ref-resolver.test.ts +105 -0
  129. package/tests/schema/test-strict.test.ts +139 -0
  130. package/tests/schema/test-validator.test.ts +64 -0
  131. package/tests/test-acl.test.ts +206 -0
  132. package/tests/test-bindings.test.ts +227 -0
  133. package/tests/test-config.test.ts +76 -0
  134. package/tests/test-context.test.ts +151 -0
  135. package/tests/test-decorator.test.ts +173 -0
  136. package/tests/test-errors.test.ts +204 -0
  137. package/tests/test-executor.test.ts +252 -0
  138. package/tests/test-middleware-manager.test.ts +185 -0
  139. package/tests/test-middleware.test.ts +86 -0
  140. package/tsconfig.build.json +8 -0
  141. package/tsconfig.json +20 -0
  142. package/vitest.config.ts +18 -0
@@ -0,0 +1,204 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ ModuleError,
4
+ ModuleNotFoundError,
5
+ ModuleTimeoutError,
6
+ SchemaValidationError,
7
+ ACLDeniedError,
8
+ CallDepthExceededError,
9
+ CircularCallError,
10
+ CallFrequencyExceededError,
11
+ ConfigNotFoundError,
12
+ ConfigError,
13
+ InvalidInputError,
14
+ BindingInvalidTargetError,
15
+ BindingModuleNotFoundError,
16
+ BindingCallableNotFoundError,
17
+ BindingNotCallableError,
18
+ BindingSchemaMissingError,
19
+ BindingFileInvalidError,
20
+ CircularDependencyError,
21
+ ModuleLoadError,
22
+ SchemaNotFoundError,
23
+ SchemaParseError,
24
+ SchemaCircularRefError,
25
+ ACLRuleError,
26
+ } from '../src/errors.js';
27
+
28
+ describe('ModuleError', () => {
29
+ it('creates with code and message', () => {
30
+ const err = new ModuleError('TEST_CODE', 'test message');
31
+ expect(err.code).toBe('TEST_CODE');
32
+ expect(err.message).toBe('test message');
33
+ expect(err.name).toBe('ModuleError');
34
+ expect(err.details).toEqual({});
35
+ expect(err.timestamp).toBeDefined();
36
+ });
37
+
38
+ it('toString includes code and message', () => {
39
+ const err = new ModuleError('ERR', 'something failed');
40
+ expect(err.toString()).toBe('[ERR] something failed');
41
+ });
42
+
43
+ it('accepts details, cause, and traceId', () => {
44
+ const cause = new Error('root cause');
45
+ const err = new ModuleError('X', 'msg', { key: 'val' }, cause, 'trace-123');
46
+ expect(err.details).toEqual({ key: 'val' });
47
+ expect(err.cause).toBe(cause);
48
+ expect(err.traceId).toBe('trace-123');
49
+ });
50
+
51
+ it('is an instance of Error', () => {
52
+ const err = new ModuleError('X', 'msg');
53
+ expect(err).toBeInstanceOf(Error);
54
+ expect(err).toBeInstanceOf(ModuleError);
55
+ });
56
+ });
57
+
58
+ describe('Error subclasses', () => {
59
+ it('ModuleNotFoundError', () => {
60
+ const err = new ModuleNotFoundError('mod.x');
61
+ expect(err.name).toBe('ModuleNotFoundError');
62
+ expect(err.code).toBe('MODULE_NOT_FOUND');
63
+ expect(err.message).toContain('mod.x');
64
+ expect(err.details['moduleId']).toBe('mod.x');
65
+ });
66
+
67
+ it('ModuleTimeoutError', () => {
68
+ const err = new ModuleTimeoutError('mod.x', 5000);
69
+ expect(err.name).toBe('ModuleTimeoutError');
70
+ expect(err.code).toBe('MODULE_TIMEOUT');
71
+ expect(err.moduleId).toBe('mod.x');
72
+ expect(err.timeoutMs).toBe(5000);
73
+ });
74
+
75
+ it('SchemaValidationError', () => {
76
+ const err = new SchemaValidationError('bad data', [{ path: '/x' }]);
77
+ expect(err.name).toBe('SchemaValidationError');
78
+ expect(err.code).toBe('SCHEMA_VALIDATION_ERROR');
79
+ expect(err.details['errors']).toHaveLength(1);
80
+ });
81
+
82
+ it('ACLDeniedError', () => {
83
+ const err = new ACLDeniedError('caller.a', 'target.b');
84
+ expect(err.name).toBe('ACLDeniedError');
85
+ expect(err.code).toBe('ACL_DENIED');
86
+ expect(err.callerId).toBe('caller.a');
87
+ expect(err.targetId).toBe('target.b');
88
+ });
89
+
90
+ it('CallDepthExceededError', () => {
91
+ const err = new CallDepthExceededError(33, 32, ['a', 'b']);
92
+ expect(err.name).toBe('CallDepthExceededError');
93
+ expect(err.code).toBe('CALL_DEPTH_EXCEEDED');
94
+ expect(err.currentDepth).toBe(33);
95
+ expect(err.maxDepth).toBe(32);
96
+ });
97
+
98
+ it('CircularCallError', () => {
99
+ const err = new CircularCallError('mod.a', ['mod.a', 'mod.b', 'mod.a']);
100
+ expect(err.name).toBe('CircularCallError');
101
+ expect(err.code).toBe('CIRCULAR_CALL');
102
+ expect(err.moduleId).toBe('mod.a');
103
+ });
104
+
105
+ it('CallFrequencyExceededError', () => {
106
+ const err = new CallFrequencyExceededError('mod.a', 4, 3, ['mod.a', 'mod.a', 'mod.a', 'mod.a']);
107
+ expect(err.name).toBe('CallFrequencyExceededError');
108
+ expect(err.code).toBe('CALL_FREQUENCY_EXCEEDED');
109
+ expect(err.moduleId).toBe('mod.a');
110
+ expect(err.count).toBe(4);
111
+ expect(err.maxRepeat).toBe(3);
112
+ });
113
+
114
+ it('ConfigNotFoundError', () => {
115
+ const err = new ConfigNotFoundError('/path/to/config');
116
+ expect(err.name).toBe('ConfigNotFoundError');
117
+ expect(err.code).toBe('CONFIG_NOT_FOUND');
118
+ });
119
+
120
+ it('ConfigError', () => {
121
+ const err = new ConfigError('bad config');
122
+ expect(err.name).toBe('ConfigError');
123
+ expect(err.code).toBe('CONFIG_INVALID');
124
+ });
125
+
126
+ it('InvalidInputError', () => {
127
+ const err = new InvalidInputError('bad input');
128
+ expect(err.name).toBe('InvalidInputError');
129
+ expect(err.code).toBe('GENERAL_INVALID_INPUT');
130
+ });
131
+
132
+ it('BindingInvalidTargetError', () => {
133
+ const err = new BindingInvalidTargetError('bad:target:format');
134
+ expect(err.name).toBe('BindingInvalidTargetError');
135
+ expect(err.code).toBe('BINDING_INVALID_TARGET');
136
+ });
137
+
138
+ it('BindingModuleNotFoundError', () => {
139
+ const err = new BindingModuleNotFoundError('some.module');
140
+ expect(err.name).toBe('BindingModuleNotFoundError');
141
+ expect(err.code).toBe('BINDING_MODULE_NOT_FOUND');
142
+ });
143
+
144
+ it('BindingCallableNotFoundError', () => {
145
+ const err = new BindingCallableNotFoundError('fn', 'some.module');
146
+ expect(err.name).toBe('BindingCallableNotFoundError');
147
+ expect(err.code).toBe('BINDING_CALLABLE_NOT_FOUND');
148
+ });
149
+
150
+ it('BindingNotCallableError', () => {
151
+ const err = new BindingNotCallableError('some:target');
152
+ expect(err.name).toBe('BindingNotCallableError');
153
+ expect(err.code).toBe('BINDING_NOT_CALLABLE');
154
+ });
155
+
156
+ it('BindingSchemaMissingError', () => {
157
+ const err = new BindingSchemaMissingError('some:target');
158
+ expect(err.name).toBe('BindingSchemaMissingError');
159
+ expect(err.code).toBe('BINDING_SCHEMA_MISSING');
160
+ });
161
+
162
+ it('BindingFileInvalidError', () => {
163
+ const err = new BindingFileInvalidError('/path/file.yaml', 'parse error');
164
+ expect(err.name).toBe('BindingFileInvalidError');
165
+ expect(err.code).toBe('BINDING_FILE_INVALID');
166
+ });
167
+
168
+ it('CircularDependencyError', () => {
169
+ const err = new CircularDependencyError(['a', 'b', 'a']);
170
+ expect(err.name).toBe('CircularDependencyError');
171
+ expect(err.code).toBe('CIRCULAR_DEPENDENCY');
172
+ expect(err.message).toContain('a -> b -> a');
173
+ });
174
+
175
+ it('ModuleLoadError', () => {
176
+ const err = new ModuleLoadError('mod.a', 'file not found');
177
+ expect(err.name).toBe('ModuleLoadError');
178
+ expect(err.code).toBe('MODULE_LOAD_ERROR');
179
+ });
180
+
181
+ it('SchemaNotFoundError', () => {
182
+ const err = new SchemaNotFoundError('schema.x');
183
+ expect(err.name).toBe('SchemaNotFoundError');
184
+ expect(err.code).toBe('SCHEMA_NOT_FOUND');
185
+ });
186
+
187
+ it('SchemaParseError', () => {
188
+ const err = new SchemaParseError('invalid yaml');
189
+ expect(err.name).toBe('SchemaParseError');
190
+ expect(err.code).toBe('SCHEMA_PARSE_ERROR');
191
+ });
192
+
193
+ it('SchemaCircularRefError', () => {
194
+ const err = new SchemaCircularRefError('#/definitions/A');
195
+ expect(err.name).toBe('SchemaCircularRefError');
196
+ expect(err.code).toBe('SCHEMA_CIRCULAR_REF');
197
+ });
198
+
199
+ it('ACLRuleError', () => {
200
+ const err = new ACLRuleError('bad rule');
201
+ expect(err.name).toBe('ACLRuleError');
202
+ expect(err.code).toBe('ACL_RULE_ERROR');
203
+ });
204
+ });
@@ -0,0 +1,252 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Type } from '@sinclair/typebox';
3
+ import { Context, createIdentity } from '../src/context.js';
4
+ import { Executor, redactSensitive, REDACTED_VALUE } from '../src/executor.js';
5
+ import { FunctionModule } from '../src/decorator.js';
6
+ import { Registry } from '../src/registry/registry.js';
7
+ import { ACL } from '../src/acl.js';
8
+ import { Middleware } from '../src/middleware/base.js';
9
+ import {
10
+ ModuleNotFoundError,
11
+ ACLDeniedError,
12
+ CallDepthExceededError,
13
+ CircularCallError,
14
+ CallFrequencyExceededError,
15
+ SchemaValidationError,
16
+ } from '../src/errors.js';
17
+
18
+ function createSimpleModule(id: string): FunctionModule {
19
+ return new FunctionModule({
20
+ execute: (inputs) => ({ greeting: `Hello, ${inputs['name'] ?? 'world'}!` }),
21
+ moduleId: id,
22
+ inputSchema: Type.Object({ name: Type.Optional(Type.String()) }),
23
+ outputSchema: Type.Object({ greeting: Type.String() }),
24
+ description: 'Greet module',
25
+ });
26
+ }
27
+
28
+ describe('redactSensitive', () => {
29
+ it('redacts fields marked x-sensitive', () => {
30
+ const data = { name: 'Alice', password: 'secret123' };
31
+ const schema = {
32
+ properties: {
33
+ name: { type: 'string' },
34
+ password: { type: 'string', 'x-sensitive': true },
35
+ },
36
+ };
37
+ const result = redactSensitive(data, schema);
38
+ expect(result['name']).toBe('Alice');
39
+ expect(result['password']).toBe(REDACTED_VALUE);
40
+ });
41
+
42
+ it('redacts _secret_ prefix keys', () => {
43
+ const data = { _secret_token: 'abc123', name: 'Bob' };
44
+ const schema = { properties: { name: { type: 'string' } } };
45
+ const result = redactSensitive(data, schema);
46
+ expect(result['_secret_token']).toBe(REDACTED_VALUE);
47
+ expect(result['name']).toBe('Bob');
48
+ });
49
+
50
+ it('does not modify original data', () => {
51
+ const data = { password: 'secret' };
52
+ const schema = { properties: { password: { type: 'string', 'x-sensitive': true } } };
53
+ redactSensitive(data, schema);
54
+ expect(data['password']).toBe('secret');
55
+ });
56
+
57
+ it('handles nested objects', () => {
58
+ const data = { user: { name: 'Alice', token: 'abc' } };
59
+ const schema = {
60
+ properties: {
61
+ user: {
62
+ type: 'object',
63
+ properties: {
64
+ name: { type: 'string' },
65
+ token: { type: 'string', 'x-sensitive': true },
66
+ },
67
+ },
68
+ },
69
+ };
70
+ const result = redactSensitive(data, schema);
71
+ expect((result['user'] as Record<string, unknown>)['name']).toBe('Alice');
72
+ expect((result['user'] as Record<string, unknown>)['token']).toBe(REDACTED_VALUE);
73
+ });
74
+ });
75
+
76
+ describe('Executor', () => {
77
+ it('executes a simple module', async () => {
78
+ const registry = new Registry();
79
+ const mod = createSimpleModule('greet');
80
+ registry.register('greet', mod);
81
+
82
+ const executor = new Executor({ registry });
83
+ const result = await executor.call('greet', { name: 'Alice' });
84
+ expect(result['greeting']).toBe('Hello, Alice!');
85
+ });
86
+
87
+ it('throws ModuleNotFoundError for unknown module', async () => {
88
+ const registry = new Registry();
89
+ const executor = new Executor({ registry });
90
+
91
+ await expect(executor.call('nonexistent')).rejects.toThrow(ModuleNotFoundError);
92
+ });
93
+
94
+ it('validates input against schema', async () => {
95
+ const registry = new Registry();
96
+ const mod = new FunctionModule({
97
+ execute: (inputs) => ({ result: inputs['count'] }),
98
+ moduleId: 'strict',
99
+ inputSchema: Type.Object({ count: Type.Number() }),
100
+ outputSchema: Type.Object({ result: Type.Number() }),
101
+ description: 'Strict module',
102
+ });
103
+ registry.register('strict', mod);
104
+
105
+ const executor = new Executor({ registry });
106
+ await expect(executor.call('strict', { count: 'not-a-number' })).rejects.toThrow(SchemaValidationError);
107
+ });
108
+
109
+ it('enforces ACL deny', async () => {
110
+ const registry = new Registry();
111
+ registry.register('secret', createSimpleModule('secret'));
112
+
113
+ const acl = new ACL([
114
+ { callers: ['@external'], targets: ['secret'], effect: 'deny', description: 'deny all' },
115
+ ], 'deny');
116
+
117
+ const executor = new Executor({ registry, acl });
118
+ await expect(executor.call('secret')).rejects.toThrow(ACLDeniedError);
119
+ });
120
+
121
+ it('enforces ACL allow', async () => {
122
+ const registry = new Registry();
123
+ registry.register('public', createSimpleModule('public'));
124
+
125
+ const acl = new ACL([
126
+ { callers: ['*'], targets: ['*'], effect: 'allow', description: 'allow all' },
127
+ ], 'deny');
128
+
129
+ const executor = new Executor({ registry, acl });
130
+ const result = await executor.call('public', { name: 'World' });
131
+ expect(result['greeting']).toBe('Hello, World!');
132
+ });
133
+
134
+ it('calls middleware before and after', async () => {
135
+ const registry = new Registry();
136
+ registry.register('echo', createSimpleModule('echo'));
137
+
138
+ const calls: string[] = [];
139
+ class TrackingMiddleware extends Middleware {
140
+ override before() { calls.push('before'); return null; }
141
+ override after() { calls.push('after'); return null; }
142
+ }
143
+
144
+ const executor = new Executor({ registry, middlewares: [new TrackingMiddleware()] });
145
+ await executor.call('echo');
146
+ expect(calls).toEqual(['before', 'after']);
147
+ });
148
+
149
+ it('runs middleware onError on execution failure', async () => {
150
+ const registry = new Registry();
151
+ const failMod = new FunctionModule({
152
+ execute: () => { throw new Error('boom'); },
153
+ moduleId: 'fail',
154
+ inputSchema: Type.Object({}),
155
+ outputSchema: Type.Object({}),
156
+ description: 'Failing module',
157
+ });
158
+ registry.register('fail', failMod);
159
+
160
+ let errorSeen = false;
161
+ class ErrorTracker extends Middleware {
162
+ override onError() { errorSeen = true; return null; }
163
+ }
164
+
165
+ const executor = new Executor({ registry, middlewares: [new ErrorTracker()] });
166
+ await expect(executor.call('fail')).rejects.toThrow('boom');
167
+ expect(errorSeen).toBe(true);
168
+ });
169
+
170
+ it('middleware onError can recover', async () => {
171
+ const registry = new Registry();
172
+ const failMod = new FunctionModule({
173
+ execute: () => { throw new Error('boom'); },
174
+ moduleId: 'fail',
175
+ inputSchema: Type.Object({}),
176
+ outputSchema: Type.Object({}),
177
+ description: 'Failing module',
178
+ });
179
+ registry.register('fail', failMod);
180
+
181
+ class RecoveryMiddleware extends Middleware {
182
+ override onError() { return { recovered: true }; }
183
+ }
184
+
185
+ const executor = new Executor({ registry, middlewares: [new RecoveryMiddleware()] });
186
+ const result = await executor.call('fail');
187
+ expect(result['recovered']).toBe(true);
188
+ });
189
+
190
+ it('validate() checks inputs without executing', () => {
191
+ const registry = new Registry();
192
+ const mod = new FunctionModule({
193
+ execute: () => ({ ok: true }),
194
+ moduleId: 'v',
195
+ inputSchema: Type.Object({ x: Type.Number() }),
196
+ outputSchema: Type.Object({ ok: Type.Boolean() }),
197
+ description: 'Validate test',
198
+ });
199
+ registry.register('v', mod);
200
+
201
+ const executor = new Executor({ registry });
202
+ const valid = executor.validate('v', { x: 42 });
203
+ expect(valid.valid).toBe(true);
204
+
205
+ const invalid = executor.validate('v', { x: 'not-a-number' });
206
+ expect(invalid.valid).toBe(false);
207
+ expect(invalid.errors.length).toBeGreaterThan(0);
208
+ });
209
+
210
+ it('auto-creates context when none provided', async () => {
211
+ const registry = new Registry();
212
+ registry.register('ctx', createSimpleModule('ctx'));
213
+
214
+ const executor = new Executor({ registry });
215
+ const result = await executor.call('ctx');
216
+ expect(result['greeting']).toBeDefined();
217
+ });
218
+
219
+ it('uses provided context', async () => {
220
+ const registry = new Registry();
221
+ let capturedCtx: Context | null = null;
222
+ const mod = new FunctionModule({
223
+ execute: (_inputs, ctx) => { capturedCtx = ctx; return { ok: true }; },
224
+ moduleId: 'ctx-test',
225
+ inputSchema: Type.Object({}),
226
+ outputSchema: Type.Object({ ok: Type.Boolean() }),
227
+ description: 'Context capture',
228
+ });
229
+ registry.register('ctx-test', mod);
230
+
231
+ const executor = new Executor({ registry });
232
+ const ctx = Context.create(executor, createIdentity('user1'));
233
+ await executor.call('ctx-test', {}, ctx);
234
+
235
+ expect(capturedCtx).not.toBeNull();
236
+ expect(capturedCtx!.traceId).toBe(ctx.traceId);
237
+ expect(capturedCtx!.identity?.id).toBe('user1');
238
+ });
239
+
240
+ it('use/remove middleware chaining', () => {
241
+ const registry = new Registry();
242
+ const executor = new Executor({ registry });
243
+ const mw = new Middleware();
244
+
245
+ const result = executor.use(mw);
246
+ expect(result).toBe(executor);
247
+ expect(executor.middlewares).toHaveLength(1);
248
+
249
+ executor.remove(mw);
250
+ expect(executor.middlewares).toHaveLength(0);
251
+ });
252
+ });
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Middleware } from '../src/middleware/base.js';
3
+ import { MiddlewareManager, MiddlewareChainError } from '../src/middleware/manager.js';
4
+ import { Context, createIdentity } from '../src/context.js';
5
+
6
+ function makeContext(): Context {
7
+ return Context.create(null, createIdentity('test-user'));
8
+ }
9
+
10
+ class TaggingMiddleware extends Middleware {
11
+ readonly tag: string;
12
+
13
+ constructor(tag: string) {
14
+ super();
15
+ this.tag = tag;
16
+ }
17
+
18
+ override before(
19
+ _moduleId: string,
20
+ inputs: Record<string, unknown>,
21
+ ): Record<string, unknown> | null {
22
+ const trail = ((inputs['trail'] as string) ?? '') + this.tag;
23
+ return { ...inputs, trail };
24
+ }
25
+
26
+ override after(
27
+ _moduleId: string,
28
+ _inputs: Record<string, unknown>,
29
+ output: Record<string, unknown>,
30
+ ): Record<string, unknown> | null {
31
+ const trail = ((output['trail'] as string) ?? '') + this.tag;
32
+ return { ...output, trail };
33
+ }
34
+ }
35
+
36
+ class RecoveringMiddleware extends Middleware {
37
+ readonly recovery: Record<string, unknown>;
38
+
39
+ constructor(recovery: Record<string, unknown>) {
40
+ super();
41
+ this.recovery = recovery;
42
+ }
43
+
44
+ override onError(): Record<string, unknown> | null {
45
+ return this.recovery;
46
+ }
47
+ }
48
+
49
+ describe('MiddlewareManager', () => {
50
+ it('starts empty', () => {
51
+ const mgr = new MiddlewareManager();
52
+ expect(mgr.snapshot()).toEqual([]);
53
+ });
54
+
55
+ it('add and snapshot', () => {
56
+ const mgr = new MiddlewareManager();
57
+ const mw1 = new Middleware();
58
+ const mw2 = new Middleware();
59
+ mgr.add(mw1);
60
+ mgr.add(mw2);
61
+ expect(mgr.snapshot()).toHaveLength(2);
62
+ });
63
+
64
+ it('snapshot returns a copy', () => {
65
+ const mgr = new MiddlewareManager();
66
+ mgr.add(new Middleware());
67
+ const snap = mgr.snapshot();
68
+ snap.pop();
69
+ expect(mgr.snapshot()).toHaveLength(1);
70
+ });
71
+
72
+ it('remove by identity', () => {
73
+ const mgr = new MiddlewareManager();
74
+ const mw1 = new Middleware();
75
+ const mw2 = new Middleware();
76
+ mgr.add(mw1);
77
+ mgr.add(mw2);
78
+ expect(mgr.remove(mw1)).toBe(true);
79
+ expect(mgr.snapshot()).toEqual([mw2]);
80
+ });
81
+
82
+ it('remove returns false when not found', () => {
83
+ const mgr = new MiddlewareManager();
84
+ expect(mgr.remove(new Middleware())).toBe(false);
85
+ });
86
+
87
+ it('executeBefore runs in forward order', () => {
88
+ const mgr = new MiddlewareManager();
89
+ mgr.add(new TaggingMiddleware('A'));
90
+ mgr.add(new TaggingMiddleware('B'));
91
+ mgr.add(new TaggingMiddleware('C'));
92
+ const ctx = makeContext();
93
+ const [result, executed] = mgr.executeBefore('mod.test', { trail: '' }, ctx);
94
+ expect(result['trail']).toBe('ABC');
95
+ expect(executed).toHaveLength(3);
96
+ });
97
+
98
+ it('executeBefore passes original inputs when all return null', () => {
99
+ const mgr = new MiddlewareManager();
100
+ mgr.add(new Middleware());
101
+ mgr.add(new Middleware());
102
+ const ctx = makeContext();
103
+ const [result] = mgr.executeBefore('mod.test', { x: 42 }, ctx);
104
+ expect(result).toEqual({ x: 42 });
105
+ });
106
+
107
+ it('executeAfter runs in reverse order', () => {
108
+ const mgr = new MiddlewareManager();
109
+ mgr.add(new TaggingMiddleware('A'));
110
+ mgr.add(new TaggingMiddleware('B'));
111
+ mgr.add(new TaggingMiddleware('C'));
112
+ const ctx = makeContext();
113
+ const result = mgr.executeAfter('mod.test', {}, { trail: '' }, ctx);
114
+ expect(result['trail']).toBe('CBA');
115
+ });
116
+
117
+ it('executeAfter passes original output when all return null', () => {
118
+ const mgr = new MiddlewareManager();
119
+ mgr.add(new Middleware());
120
+ const ctx = makeContext();
121
+ const result = mgr.executeAfter('mod.test', {}, { y: 99 }, ctx);
122
+ expect(result).toEqual({ y: 99 });
123
+ });
124
+
125
+ it('executeOnError returns first non-null recovery (reverse order)', () => {
126
+ const mgr = new MiddlewareManager();
127
+ const mwA = new RecoveringMiddleware({ recovered: 'A' });
128
+ const mwB = new RecoveringMiddleware({ recovered: 'B' });
129
+ mgr.add(mwA);
130
+ mgr.add(mwB);
131
+ const ctx = makeContext();
132
+ const result = mgr.executeOnError('mod.test', {}, new Error('oops'), ctx, [mwA, mwB]);
133
+ expect(result).toEqual({ recovered: 'B' });
134
+ });
135
+
136
+ it('executeOnError returns null when no recovery', () => {
137
+ const mgr = new MiddlewareManager();
138
+ const mw = new Middleware();
139
+ mgr.add(mw);
140
+ const ctx = makeContext();
141
+ const result = mgr.executeOnError('mod.test', {}, new Error('oops'), ctx, [mw]);
142
+ expect(result).toBeNull();
143
+ });
144
+
145
+ it('executeOnError swallows errors in onError handlers', () => {
146
+ class ThrowingOnError extends Middleware {
147
+ override onError(): Record<string, unknown> | null {
148
+ throw new Error('onError also failed');
149
+ }
150
+ }
151
+ const mgr = new MiddlewareManager();
152
+ const mwRecover = new RecoveringMiddleware({ safe: true });
153
+ const mwThrow = new ThrowingOnError();
154
+ mgr.add(mwRecover);
155
+ mgr.add(mwThrow);
156
+ const ctx = makeContext();
157
+ const result = mgr.executeOnError('mod.test', {}, new Error('original'), ctx, [mwRecover, mwThrow]);
158
+ expect(result).toEqual({ safe: true });
159
+ });
160
+
161
+ it('MiddlewareChainError wraps before() failure', () => {
162
+ class FailingBefore extends Middleware {
163
+ override before(): Record<string, unknown> | null {
164
+ throw new Error('before exploded');
165
+ }
166
+ }
167
+ const mgr = new MiddlewareManager();
168
+ const ok = new TaggingMiddleware('A');
169
+ const fail = new FailingBefore();
170
+ mgr.add(ok);
171
+ mgr.add(fail);
172
+ const ctx = makeContext();
173
+
174
+ let caught: MiddlewareChainError | undefined;
175
+ try {
176
+ mgr.executeBefore('mod.test', { trail: '' }, ctx);
177
+ } catch (e) {
178
+ caught = e as MiddlewareChainError;
179
+ }
180
+
181
+ expect(caught).toBeInstanceOf(MiddlewareChainError);
182
+ expect(caught!.original.message).toBe('before exploded');
183
+ expect(caught!.executedMiddlewares).toHaveLength(2);
184
+ });
185
+ });