@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.
- package/CHANGELOG.md +26 -0
- package/package.json +4 -3
- package/src/ConfigRuntime.test.ts +79 -1
- package/src/ConfigRuntime.ts +61 -0
- package/src/Gateway.ts +3 -5
- package/src/Registry.ts +2 -2
- package/src/RpcClient.ts +3 -3
- package/src/RpcServer.ts +2 -2
- package/src/SemanticLog.test.ts +13 -3
- package/src/SemanticLog.ts +1 -1
- package/src/Watch.test.ts +150 -1
- package/src/Watch.ts +112 -26
- package/src/adapter/schema/knex/types.ts +4 -3
- package/src/adapter/server/acl.ts +4 -2
- package/src/adapter/server/knex.ts +482 -360
- package/src/adapter/server/pathRefresh.test.ts +208 -0
- package/src/adapter/server/pathRefresh.ts +132 -0
- package/src/browserEagerImports.test.ts +62 -0
- package/src/chain.allure.test.ts +216 -0
- package/src/chain.ts +238 -2
- package/src/checkpoint.test.ts +97 -8
- package/src/checkpoint.ts +69 -14
- package/src/ci.intent.test.ts +94 -0
- package/src/codec/adapter/jsonrpc/receive.ts +4 -4
- package/src/codec/adapter/mle/ready.ts +39 -11
- package/src/kopi.test.ts +95 -0
- package/src/kopi.ts +24 -8
- package/src/layerProxy.ts +25 -5
- package/src/load.ts +133 -20
- package/src/loadServer.ts +13 -1
- package/src/mle.ts +3 -3
- package/src/pino-cacache.ts +59 -3
- package/src/pino-pretty.ts +1 -1
- package/src/semanticContext.ts +9 -0
- package/src/template-files.ts +5 -0
- package/template/.storybook/main.ts +14 -0
- package/template/.storybook/preview.tsx +19 -0
- package/template/browser.ts +22 -4
- package/template/meta/db/db.ts +10 -1
- package/template/meta/fixture/$subjectFixture.ts +37 -0
- package/template/src/stories/$Object.stories.tsx +17 -0
- 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.
|
|
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.
|
|
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
|
+
});
|
package/src/ConfigRuntime.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
647
|
-
|
|
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
|
|
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 {
|
|
199
|
-
((callerMeta as {
|
|
200
|
-
...(body as unknown as {
|
|
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 {
|
|
89
|
-
|
|
88
|
+
...((newMeta as {progress?: unknown[]}).progress?.length && {
|
|
89
|
+
progress: (newMeta as {progress?: unknown[]}).progress,
|
|
90
90
|
}),
|
|
91
91
|
};
|
|
92
92
|
}
|
package/src/SemanticLog.test.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
});
|
package/src/SemanticLog.ts
CHANGED
|
@@ -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 `
|
|
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 {
|
|
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
|
+
});
|