@feasibleone/blong-gogo 1.36.0 → 1.38.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 (42) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/package.json +4 -3
  3. package/src/ConfigRuntime.test.ts +79 -1
  4. package/src/ConfigRuntime.ts +61 -0
  5. package/src/Gateway.ts +3 -5
  6. package/src/Registry.ts +2 -2
  7. package/src/RpcClient.ts +3 -3
  8. package/src/RpcServer.ts +2 -2
  9. package/src/SemanticLog.test.ts +13 -3
  10. package/src/SemanticLog.ts +1 -1
  11. package/src/Watch.test.ts +150 -1
  12. package/src/Watch.ts +112 -26
  13. package/src/adapter/schema/knex/types.ts +4 -3
  14. package/src/adapter/server/acl.ts +4 -2
  15. package/src/adapter/server/knex.ts +482 -360
  16. package/src/adapter/server/pathRefresh.test.ts +208 -0
  17. package/src/adapter/server/pathRefresh.ts +132 -0
  18. package/src/browserEagerImports.test.ts +62 -0
  19. package/src/chain.allure.test.ts +216 -0
  20. package/src/chain.ts +238 -2
  21. package/src/checkpoint.test.ts +97 -8
  22. package/src/checkpoint.ts +69 -14
  23. package/src/ci.intent.test.ts +94 -0
  24. package/src/codec/adapter/jsonrpc/receive.ts +4 -4
  25. package/src/codec/adapter/mle/ready.ts +39 -11
  26. package/src/kopi.test.ts +95 -0
  27. package/src/kopi.ts +24 -8
  28. package/src/layerProxy.ts +25 -5
  29. package/src/load.ts +133 -20
  30. package/src/loadServer.ts +13 -1
  31. package/src/mle.ts +3 -3
  32. package/src/pino-cacache.ts +59 -3
  33. package/src/pino-pretty.ts +1 -1
  34. package/src/semanticContext.ts +9 -0
  35. package/src/template-files.ts +5 -0
  36. package/template/.storybook/main.ts +14 -0
  37. package/template/.storybook/preview.tsx +19 -0
  38. package/template/browser.ts +22 -4
  39. package/template/meta/db/db.ts +10 -1
  40. package/template/meta/fixture/$subjectFixture.ts +37 -0
  41. package/template/src/stories/$Object.stories.tsx +17 -0
  42. package/template/src/stories/$Subject.stories.tsx +11 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.38.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.37.0...blong-gogo-v1.38.0) (2026-09-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * improve storybook scaffold ([cc66ab4](https://github.com/feasibleone/blong/commit/cc66ab480eb9af2def562873cac12d06591c0de4))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * failed tests ([108caf5](https://github.com/feasibleone/blong/commit/108caf5f3e92151f8b929115e0a8f6b2acdd3d23))
14
+ * gaps ([19beb7d](https://github.com/feasibleone/blong/commit/19beb7d191e853c8182409256c97af7a1e413d57))
15
+
16
+ ## [1.37.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.36.0...blong-gogo-v1.37.0) (2026-09-26)
17
+
18
+
19
+ ### Features
20
+
21
+ * progress points in test reports ([fb73036](https://github.com/feasibleone/blong/commit/fb730360877eb371ab3d1603ae7bf35976c170dc))
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * chain dynamic import ([7db222f](https://github.com/feasibleone/blong/commit/7db222f0a6469b624fa1935027f63b0593585ee6))
27
+ * misc CI failures ([950289d](https://github.com/feasibleone/blong/commit/950289d65e6187d10658dd0fd76361bd4d3d56e8))
28
+
3
29
  ## [1.36.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.35.0...blong-gogo-v1.36.0) (2026-09-24)
4
30
 
5
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feasibleone/blong-gogo",
3
- "version": "1.36.0",
3
+ "version": "1.38.0",
4
4
  "repository": {
5
5
  "url": "git+https://github.com/feasibleone/blong.git"
6
6
  },
@@ -20,7 +20,6 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@apidevtools/swagger-parser": "^12.1.0",
23
- "@modelcontextprotocol/sdk": "^1.0.0",
24
23
  "@aws-sdk/client-s3": "^3.1037.0",
25
24
  "@fastify/basic-auth": "^6.2.0",
26
25
  "@fastify/bearer-auth": "^10.1.2",
@@ -33,6 +32,7 @@
33
32
  "@fastify/swagger-ui": "^5.2.5",
34
33
  "@fastify/type-provider-typebox": "^6.1.0",
35
34
  "@feasibleone/blong": "^1.0.0",
35
+ "@feasibleone/blong-allure": "^1.0.0",
36
36
  "@feasibleone/blong-chain": "^1.0.0",
37
37
  "@feasibleone/blong-config": "^1.0.0",
38
38
  "@feasibleone/blong-lib": "^1.0.0",
@@ -41,6 +41,7 @@
41
41
  "@feasibleone/semantic-log": "^1.0.0",
42
42
  "@keycloak/keycloak-admin-client": "^26.6.2",
43
43
  "@kubernetes/client-node": "^1.4.0",
44
+ "@modelcontextprotocol/sdk": "^1.0.0",
44
45
  "@octokit/rest": "^22.0.1",
45
46
  "@sinclair/typebox-codegen": "^0.11.1",
46
47
  "@slack/webhook": "^7.0.7",
@@ -91,7 +92,7 @@
91
92
  "tap": "^21.6.3",
92
93
  "typescript": "^6.0.3",
93
94
  "vite": "^8.2.1",
94
- "@feasibleone/blong-dev": "1.6.0"
95
+ "@feasibleone/blong-dev": "1.7.1"
95
96
  },
96
97
  "scripts": {
97
98
  "browser-check": "node scripts/browser-compat-check.mjs",
@@ -17,7 +17,7 @@
17
17
 
18
18
  import {test} from 'tap';
19
19
 
20
- import {createConfigProxy, deepDiff} from './ConfigRuntime.ts';
20
+ import ConfigRuntime, {createConfigProxy, deepDiff} from './ConfigRuntime.ts';
21
21
 
22
22
  // ---------------------------------------------------------------------------
23
23
  // deepDiff
@@ -325,3 +325,81 @@ test('factory phase guard — exitConfigFactoryPhase returns empty array in thro
325
325
  const errors = exitConfig();
326
326
  t.equal(errors.length, 0, 'no errors collected in throw mode (they were thrown)');
327
327
  });
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // liveLayerConfig — the config a handler closure receives
331
+ // ---------------------------------------------------------------------------
332
+
333
+ /**
334
+ * The merge input is a getter over a `let`, so a "reload" in these tests replaces
335
+ * the object the next merge sees — the way `ConfigRuntime.#rawSnapshot` is
336
+ * replaced while the version moves with it.
337
+ */
338
+
339
+ test('liveLayerConfig — a handler sees a reloaded value', async t => {
340
+ let source: Record<string, unknown> = {timeout: 5, db: {host: 'a', port: 1}};
341
+ let version = 0;
342
+ const config = ConfigRuntime.liveLayerConfig(
343
+ () => source,
344
+ () => version,
345
+ ) as {
346
+ timeout: number;
347
+ db: {host: string};
348
+ };
349
+
350
+ t.equal(config.timeout, 5, 'the value at assembly time');
351
+
352
+ source = {timeout: 9, db: {host: 'b', port: 2}};
353
+ version += 1;
354
+
355
+ t.equal(config.timeout, 9, 'a leaf read after the reload is fresh');
356
+ t.end();
357
+ });
358
+
359
+ test('liveLayerConfig — an intermediate node destructured at startup stays live', async t => {
360
+ let source: Record<string, unknown> = {db: {host: 'a'}};
361
+ let version = 0;
362
+ const config = ConfigRuntime.liveLayerConfig(
363
+ () => source,
364
+ () => version,
365
+ ) as {
366
+ db: {host: string};
367
+ };
368
+
369
+ // The pattern the config rationale calls safe: the node is taken once, its
370
+ // leaves are read at call time.
371
+ const {db} = config;
372
+ t.equal(db.host, 'a', 'the leaf before the reload');
373
+ t.equal(config.db, db, 'and the node reference is stable');
374
+
375
+ source = {db: {host: 'b'}};
376
+ version += 1;
377
+
378
+ t.equal(db.host, 'b', 'the same node reads the reloaded leaf');
379
+ t.end();
380
+ });
381
+
382
+ test('liveLayerConfig — an unchanged version does not re-merge', async t => {
383
+ let merges = 0;
384
+ let version = 3;
385
+ const config = ConfigRuntime.liveLayerConfig(
386
+ () => {
387
+ merges += 1;
388
+ return {timeout: 5};
389
+ },
390
+ () => version,
391
+ ) as {timeout: number};
392
+
393
+ t.equal(merges, 1, 'assembled once');
394
+ void config.timeout;
395
+ void config.timeout;
396
+ t.equal(merges, 1, 'reads at the same version do not merge again');
397
+
398
+ version += 1;
399
+ void config.timeout;
400
+ t.equal(merges, 2, 'a new version merges once');
401
+
402
+ void config.timeout;
403
+ t.equal(merges, 2, 'and then caches again');
404
+ t.end();
405
+ });
@@ -43,6 +43,17 @@ import merge from 'ut-function.merge';
43
43
  */
44
44
  export function createConfigProxy<T extends object>(
45
45
  store: T,
46
+ options: {
47
+ /**
48
+ * Called before every read, at every level of the proxy.
49
+ *
50
+ * A caller whose backing store has to be re-read from somewhere charges
51
+ * the check here, so a node destructured once at startup (a handler's
52
+ * `config.db`, say) refreshes on its own reads rather than only when the
53
+ * root is touched.
54
+ */
55
+ beforeRead?: () => void;
56
+ } = {},
46
57
  ): {
47
58
  proxy: T;
48
59
  update: (next: T) => void;
@@ -131,6 +142,7 @@ export function createConfigProxy<T extends object>(
131
142
 
132
143
  const pathProxy = new Proxy({} as T, {
133
144
  get(_target, prop) {
145
+ options.beforeRead?.();
134
146
  const container = getNode(path);
135
147
  const val = container == null ? undefined : Reflect.get(container as object, prop);
136
148
  if (
@@ -166,16 +178,19 @@ export function createConfigProxy<T extends object>(
166
178
  return Reflect.set(container as object, prop, value);
167
179
  },
168
180
  has(_target, prop) {
181
+ options.beforeRead?.();
169
182
  const container = getNode(path);
170
183
  if (container == null) return false;
171
184
  return Reflect.has(container as object, prop);
172
185
  },
173
186
  ownKeys() {
187
+ options.beforeRead?.();
174
188
  const container = getNode(path);
175
189
  if (container == null) return [];
176
190
  return Reflect.ownKeys(container as object);
177
191
  },
178
192
  getOwnPropertyDescriptor(_target, prop) {
193
+ options.beforeRead?.();
179
194
  const container = getNode(path);
180
195
  if (container == null) return undefined;
181
196
  return Object.getOwnPropertyDescriptor(container as object, prop);
@@ -265,6 +280,7 @@ export default class ConfigRuntime implements IConfigRuntime {
265
280
  #rawSnapshot: object = {};
266
281
  #proxy: object;
267
282
  #updateProxy: (next: object) => void;
283
+ #version = 0;
268
284
 
269
285
  readonly enterConfig: (mode?: FactoryPhaseMode) => void;
270
286
  readonly exitConfig: () => Error[];
@@ -292,6 +308,11 @@ export default class ConfigRuntime implements IConfigRuntime {
292
308
  return this.#rawSnapshot;
293
309
  }
294
310
 
311
+ /** Bumped whenever the effective config is replaced; see `IConfigRuntime`. */
312
+ public get version(): number {
313
+ return this.#version;
314
+ }
315
+
295
316
  private mergeConfigs(blongConfig: object): object {
296
317
  const loaded = loadBlong(blongConfig);
297
318
  return merge({}, this.#baseConfig, ...this.#configs, loaded);
@@ -305,6 +326,7 @@ export default class ConfigRuntime implements IConfigRuntime {
305
326
  public async load(params: object = {}): Promise<object> {
306
327
  const loaded = this.mergeConfigs({config: {suite: this.#suite}, ...params});
307
328
  this.#rawSnapshot = loaded;
329
+ this.#version += 1;
308
330
  this.#updateProxy(loaded);
309
331
  return this.#proxy;
310
332
  }
@@ -324,6 +346,7 @@ export default class ConfigRuntime implements IConfigRuntime {
324
346
  // Only do work when something actually changed
325
347
  if (diff.size > 0) {
326
348
  this.#rawSnapshot = next;
349
+ this.#version += 1;
327
350
  this.#updateProxy(next);
328
351
  for (const subscriber of this.#subscribers) {
329
352
  try {
@@ -374,6 +397,44 @@ export default class ConfigRuntime implements IConfigRuntime {
374
397
  return merge({}, moduleConfigSlice, portNamespaceConfig) as Record<string, unknown>;
375
398
  }
376
399
 
400
+ /**
401
+ * A live view of one component's config slice, for a handler closure.
402
+ *
403
+ * `mergeLayerConfig` copies, so a handler that reads `config.timeout` inside
404
+ * its function keeps whatever the value was when the layer was assembled,
405
+ * however often the config is reloaded (T-159). This hands the closure a
406
+ * proxy over the merged slice instead: `mergeOnce` is called again when
407
+ * `version()` reports a new runtime version, and the readers see the new
408
+ * values — the handler's own `config` argument, and any intermediate node it
409
+ * destructured at startup, because the nodes are the path proxies of a
410
+ * `createConfigProxy` instance whose backing is replaced rather than a copy
411
+ * of it.
412
+ *
413
+ * The merge runs at most once per runtime version, so a hot path that reads a
414
+ * config leaf pays a property lookup and an integer compare, not a merge.
415
+ */
416
+ static liveLayerConfig(
417
+ mergeOnce: () => Record<string, unknown>,
418
+ version: () => number,
419
+ ): Record<string, unknown> {
420
+ let seen = version();
421
+ const refresh = (): void => {
422
+ const current = version();
423
+ if (current === seen) return;
424
+ seen = current;
425
+ live.update(mergeOnce());
426
+ };
427
+ // The hook is passed to the proxy instance itself, so a node the closure
428
+ // destructured at startup refreshes on its own reads — reading through
429
+ // the root would leave it stale.
430
+ //
431
+ // This instance's factory-phase guard is never armed: layer assembly arms
432
+ // the *runtime's* proxy, and a handler has always been free to destructure
433
+ // a leaf from its `config` argument at factory time.
434
+ const live = createConfigProxy(mergeOnce(), {beforeRead: refresh});
435
+ return live.proxy as Record<string, unknown>;
436
+ }
437
+
377
438
  /**
378
439
  * Merge activation-scoped config for an adapter.
379
440
  *
package/src/Gateway.ts CHANGED
@@ -462,7 +462,7 @@ export default class Gateway extends Internal implements IGateway {
462
462
  Type.Number(),
463
463
  ]),
464
464
  result: value.result,
465
- checkpoints: Type.Optional(
465
+ progress: Type.Optional(
466
466
  Type.Array(Type.Any()),
467
467
  ),
468
468
  }),
@@ -643,10 +643,8 @@ export default class Gateway extends Internal implements IGateway {
643
643
  jsonrpc: '2.0',
644
644
  id,
645
645
  result,
646
- ...((resultMeta as {checkpoints?: unknown[]})?.checkpoints
647
- ?.length && {
648
- checkpoints: (resultMeta as {checkpoints?: unknown[]})
649
- .checkpoints,
646
+ ...((resultMeta as {progress?: unknown[]})?.progress?.length && {
647
+ progress: (resultMeta as {progress?: unknown[]}).progress,
650
648
  }),
651
649
  };
652
650
  }
package/src/Registry.ts CHANGED
@@ -396,8 +396,8 @@ export default class Registry extends Internal implements IRegistry {
396
396
  // carries only config/handler/errors/schema/gateway/apiSchema. And
397
397
  // the lib proxy returns an attached function **raw**, so `this`
398
398
  // inside it is this object — not the port, which is only bound in the
399
- // proxy's not-yet-attached fallback path. Without these keys a
400
- // library function therefore cannot reach the platform or registry at
399
+ // proxy's not-yet-attached fallback path. Without these keys an
400
+ // injected library function therefore cannot reach the platform or registry at
401
401
  // all, which is what a library needs to be reusable outside a
402
402
  // request.
403
403
  platform: this.#platform,
package/src/RpcClient.ts CHANGED
@@ -195,9 +195,9 @@ export default class RpcClientImpl extends RemoteImpl implements IRpcClient {
195
195
  const result = await decode(body.result);
196
196
  if (/\.service\.get$/.test(method!))
197
197
  Object.assign((result as Record<string, unknown>[])[0], requestParams);
198
- if ((body as unknown as {checkpoints?: unknown[]}).checkpoints?.length) {
199
- ((callerMeta as {checkpoints?: unknown[]}).checkpoints ??= []).push(
200
- ...(body as unknown as {checkpoints: unknown[]}).checkpoints,
198
+ if ((body as unknown as {progress?: unknown[]}).progress?.length) {
199
+ ((callerMeta as {progress?: unknown[]}).progress ??= []).push(
200
+ ...(body as unknown as {progress: unknown[]}).progress,
201
201
  );
202
202
  }
203
203
  return result;
package/src/RpcServer.ts CHANGED
@@ -85,8 +85,8 @@ export default class RpcServer extends Internal implements IRpcServer {
85
85
  jsonrpc: '2.0',
86
86
  id,
87
87
  result,
88
- ...((newMeta as {checkpoints?: unknown[]}).checkpoints?.length && {
89
- checkpoints: (newMeta as {checkpoints?: unknown[]}).checkpoints,
88
+ ...((newMeta as {progress?: unknown[]}).progress?.length && {
89
+ progress: (newMeta as {progress?: unknown[]}).progress,
90
90
  }),
91
91
  };
92
92
  }
@@ -324,7 +324,17 @@ t.test('every level is bound, and emits at its own level', async t => {
324
324
  // `fatal` is written synchronously, so it is staged rather than stored: it is
325
325
  // the one level whose record survives an exit that drains no I/O, and it
326
326
  // reaches the store on the next open. The other five take the queued path.
327
- t.same(levels.sort(), ['debug', 'error', 'info', 'trace', 'warn']);
327
+ //
328
+ // An error appears twice, and that is the tenant model rather than a duplicate
329
+ // write: every record is stored once under its **shape** (keyed by the shape
330
+ // reference, standing for every occurrence of it), and a record whose denoise
331
+ // would hide something — an error, or one that withheld a payload — is stored
332
+ // under its own id as well, because that occurrence is what is being diagnosed.
333
+ t.same(
334
+ levels.sort(),
335
+ ['debug', 'error', 'error', 'info', 'trace', 'warn'],
336
+ 'every level is stored, and the error under its shape and its own id',
337
+ );
328
338
  const staged = readFileSync(cachePaths.sidecarFile(dir), 'utf8')
329
339
  .trim()
330
340
  .split('\n')
@@ -332,8 +342,8 @@ t.test('every level is bound, and emits at its own level', async t => {
332
342
  .map(entry => JSON.parse(entry.json) as {levelName: string});
333
343
  t.same(
334
344
  staged.map(entry => entry.levelName),
335
- ['fatal'],
336
- 'the fatal record is staged, not stored',
345
+ ['fatal', 'fatal'],
346
+ 'the fatal record is staged under its shape and its own id, and never stored',
337
347
  );
338
348
  await cache.close();
339
349
  });
@@ -85,7 +85,7 @@ export interface SemanticLogConfig {
85
85
  /**
86
86
  * Retain records on disk so a printed reference resolves later. The directory is
87
87
  * the framework's log cache, which the pino transport also writes — one store,
88
- * shared, so a `semantic-log://record/<id>` reference resolves whichever
88
+ * shared, so a `semlog://r/<id>` reference resolves whichever
89
89
  * implementation emitted it.
90
90
  */
91
91
  cache?: RetentionOptions;
package/src/Watch.test.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import {test} from 'tap';
2
2
 
3
- import {affectedNamespaces} from './Watch.ts';
3
+ import type {ConfigDiff} from '@feasibleone/blong/types';
4
+
5
+ import {
6
+ affectedNamespaces,
7
+ applyConfigReload,
8
+ type IReloadablePort,
9
+ type IReloadRegistry,
10
+ } from './Watch.ts';
4
11
 
5
12
  // ---------------------------------------------------------------------------
6
13
  // affectedNamespaces
@@ -49,3 +56,145 @@ test('affectedNamespaces — partial name prefix does not match', async t => {
49
56
  const affected = affectedNamespaces(diff, ports);
50
57
  t.equal(affected.size, 0, 'partial-name prefix should not match');
51
58
  });
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // applyConfigReload — what a reload does to the ports it affects
62
+ // ---------------------------------------------------------------------------
63
+
64
+ interface FakePort extends IReloadablePort {
65
+ /** Every lifecycle call, in order — the assertion surface. */
66
+ calls: string[];
67
+ /** The arguments `configChanged` was handed, once per call. */
68
+ changed: Array<{diff: ConfigDiff; next: unknown; prev: unknown}>;
69
+ }
70
+
71
+ function fakePort(id: string, options: {configChanged?: boolean; throws?: boolean} = {}): FakePort {
72
+ const calls: string[] = [];
73
+ const changed: FakePort['changed'] = [];
74
+ const port: FakePort = {
75
+ calls,
76
+ changed,
77
+ async stop() {
78
+ calls.push('stop');
79
+ },
80
+ async start() {
81
+ calls.push('start');
82
+ },
83
+ async ready() {
84
+ calls.push('ready');
85
+ },
86
+ };
87
+ if (options.configChanged !== false) {
88
+ port.configChanged = async (diff, next, prev) => {
89
+ calls.push('configChanged');
90
+ changed.push({diff, next, prev});
91
+ if (options.throws) throw new Error(`${id}: reload failed`);
92
+ };
93
+ }
94
+ return port;
95
+ }
96
+
97
+ function fakeRegistry(ports: Record<string, FakePort>): IReloadRegistry {
98
+ return {
99
+ ports: {keys: () => Object.keys(ports)},
100
+ async getPort(id) {
101
+ return ports[id];
102
+ },
103
+ async createPort(id) {
104
+ return ports[id];
105
+ },
106
+ };
107
+ }
108
+
109
+ const reloadDiff: ConfigDiff = new Map([['payment.adapter.db.host', {prev: 'a', next: 'b'}]]);
110
+
111
+ test('applyConfigReload — the affected port is asked to reconfigure itself', async t => {
112
+ const port = fakePort('payment.adapter.db');
113
+ const registry = fakeRegistry({'payment.adapter.db': port});
114
+ const next = {payment: {adapter: {db: {host: 'b'}}}};
115
+
116
+ const reloaded = await applyConfigReload(
117
+ registry,
118
+ affectedNamespaces(reloadDiff, registry.ports.keys()),
119
+ reloadDiff,
120
+ next,
121
+ {payment: {adapter: {db: {host: 'a'}}}},
122
+ {},
123
+ );
124
+
125
+ t.equal(reloaded, 1, 'one port reached');
126
+ t.equal(port.calls.join(','), 'configChanged', 'and reconfigured, not restarted');
127
+ t.equal(port.changed.length, 1, 'configChanged called once');
128
+ t.equal(port.changed[0]?.diff, reloadDiff, 'handed the diff');
129
+ t.equal(port.changed[0]?.next, next, 'and the new effective config');
130
+ t.end();
131
+ });
132
+
133
+ test('applyConfigReload — an unrelated key leaves a port running', async t => {
134
+ const affectedPort = fakePort('payment.adapter.db');
135
+ const untouched = fakePort('audit.adapter.kafka');
136
+ const registry = fakeRegistry({
137
+ 'payment.adapter.db': affectedPort,
138
+ 'audit.adapter.kafka': untouched,
139
+ });
140
+
141
+ const reloaded = await applyConfigReload(
142
+ registry,
143
+ affectedNamespaces(reloadDiff, registry.ports.keys()),
144
+ reloadDiff,
145
+ {payment: {adapter: {db: {host: 'b'}}}},
146
+ {},
147
+ {},
148
+ );
149
+
150
+ t.equal(reloaded, 1, 'only the affected port was reached');
151
+ t.equal(untouched.calls.length, 0, 'the unrelated port was not stopped or started');
152
+ t.end();
153
+ });
154
+
155
+ test('applyConfigReload — a port without configChanged is stopped and started again', async t => {
156
+ const port = fakePort('payment.adapter.db', {configChanged: false});
157
+ const registry = fakeRegistry({'payment.adapter.db': port});
158
+ const override = {marker: true};
159
+
160
+ await applyConfigReload(
161
+ registry,
162
+ affectedNamespaces(reloadDiff, registry.ports.keys()),
163
+ reloadDiff,
164
+ {},
165
+ {},
166
+ override,
167
+ );
168
+
169
+ t.equal(port.calls.join(','), 'stop,start,ready', 'the fallback restarts the port in order');
170
+ t.end();
171
+ });
172
+
173
+ test('applyConfigReload — a failing port does not stop the ones behind it', async t => {
174
+ const failing = fakePort('payment.adapter.db', {throws: true});
175
+ const healthy = fakePort('user.adapter.db');
176
+ const registry = fakeRegistry({
177
+ 'payment.adapter.db': failing,
178
+ 'user.adapter.db': healthy,
179
+ });
180
+ const diff: ConfigDiff = new Map([
181
+ ['payment.adapter.db.host', {prev: 'a', next: 'b'}],
182
+ ['user.adapter.db.host', {prev: 'a', next: 'b'}],
183
+ ]);
184
+ const errors: unknown[] = [];
185
+
186
+ const reloaded = await applyConfigReload(
187
+ registry,
188
+ affectedNamespaces(diff, registry.ports.keys()),
189
+ diff,
190
+ {},
191
+ {},
192
+ {},
193
+ error => errors.push(error),
194
+ );
195
+
196
+ t.equal(reloaded, 2, 'both ports were reached');
197
+ t.equal(healthy.calls.join(','), 'configChanged', 'the second port still reloaded');
198
+ t.equal(errors.length, 1, 'the failure was reported');
199
+ t.end();
200
+ });