@feasibleone/blong-gogo 1.35.0 → 1.36.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 +14 -0
- package/package.json +2 -2
- package/src/AdapterBase.ts +33 -5
- package/src/Gateway.ts +11 -11
- package/src/Registry.ts +15 -2
- package/src/SemanticLog.test.ts +54 -5
- package/src/adapterProgress.test.ts +190 -0
- package/src/checkpoint.test.ts +176 -0
- package/src/checkpoint.ts +57 -11
- package/src/orchestrator/common/dispatch.ts +6 -5
- package/src/registry.lib.test.ts +97 -0
- package/src/semanticContext.test.ts +125 -35
- package/src/semanticContext.ts +42 -22
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.36.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.35.0...blong-gogo-v1.36.0) (2026-09-24)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add from property to leg ([4925e76](https://github.com/feasibleone/blong/commit/4925e76a7d69000e83804cd9a5fa1ea58ab26d81))
|
|
9
|
+
* docs screenshots and diagrams ([65e37d9](https://github.com/feasibleone/blong/commit/65e37d93ef3e71d65b340541e14b50a25f14e3c8))
|
|
10
|
+
* sequence diagram progress points ([dc5b153](https://github.com/feasibleone/blong/commit/dc5b15327f31698a26a75ec73bdf9a0956a43dd9))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* build errors ([145a424](https://github.com/feasibleone/blong/commit/145a424ffc2dc4dfc14801f1002c7fa1e7a543ce))
|
|
16
|
+
|
|
3
17
|
## [1.35.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.34.0...blong-gogo-v1.35.0) (2026-09-21)
|
|
4
18
|
|
|
5
19
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feasibleone/blong-gogo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.36.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"url": "git+https://github.com/feasibleone/blong.git"
|
|
6
6
|
},
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"tap": "^21.6.3",
|
|
92
92
|
"typescript": "^6.0.3",
|
|
93
93
|
"vite": "^8.2.1",
|
|
94
|
-
"@feasibleone/blong-dev": "1.
|
|
94
|
+
"@feasibleone/blong-dev": "1.6.0"
|
|
95
95
|
},
|
|
96
96
|
"scripts": {
|
|
97
97
|
"browser-check": "node scripts/browser-compat-check.mjs",
|
package/src/AdapterBase.ts
CHANGED
|
@@ -15,7 +15,14 @@ import PQueue from 'p-queue';
|
|
|
15
15
|
import ConfigRuntime from './ConfigRuntime.ts';
|
|
16
16
|
import {isExpectedError, portLogCalls} from './lib.ts';
|
|
17
17
|
import loop from './loop.ts';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
adoptInbound,
|
|
20
|
+
attachProgress,
|
|
21
|
+
beginProgress,
|
|
22
|
+
inboundIdentities,
|
|
23
|
+
isFlowId,
|
|
24
|
+
takeProgress,
|
|
25
|
+
} from './semanticContext.ts';
|
|
19
26
|
|
|
20
27
|
const errorMap: IErrorMap = {
|
|
21
28
|
'adapter.configValidation': 'Adapter config validation:\r\n{message}',
|
|
@@ -376,15 +383,36 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
|
|
|
376
383
|
// leg that arrived: that is what turns the caller's declaration into a receipt
|
|
377
384
|
// the ledger can match, and it happens only when a flow was traced at all, so an
|
|
378
385
|
// untraced request still pays nothing.
|
|
379
|
-
const answered = (): unknown => {
|
|
386
|
+
const answered = async (): Promise<unknown> => {
|
|
380
387
|
const inbound = inboundIdentities($meta as IMeta);
|
|
381
|
-
|
|
388
|
+
const traced = isFlowId(inbound.flow) && inbound.leg !== undefined;
|
|
389
|
+
if (traced) {
|
|
382
390
|
// The receipt is the receiver's record for the leg it was handed. It carries
|
|
383
391
|
// no declared target - the caller named the ends - so the ledger can tell a
|
|
384
392
|
// receipt from a declaration by the leg alone, in one process or many.
|
|
385
|
-
portLogCalls(this)?.received(inbound.leg);
|
|
393
|
+
portLogCalls(this)?.received(inbound.leg as string);
|
|
394
|
+
// And the collection starts *before* the handler runs, because the boxes it will
|
|
395
|
+
// read are installed with `enterWith`: one created after the handler's first wait
|
|
396
|
+
// never reaches the frame that answers for the call (T-136).
|
|
397
|
+
beginProgress();
|
|
386
398
|
}
|
|
387
|
-
|
|
399
|
+
const result = await run();
|
|
400
|
+
// What the handler announced while it ran (`$meta.checkpoint`, `$meta.decide`) is
|
|
401
|
+
// staged in the scope around it and written on no record of its own: the records
|
|
402
|
+
// of a call are the framework's, written before the handler starts and after it
|
|
403
|
+
// returns. Reading the progress here — inside that scope, once the handler is
|
|
404
|
+
// done — is what lets the call answer with its own work: a point becomes a note
|
|
405
|
+
// beside this hop, and a branch becomes the alternative that was taken
|
|
406
|
+
// (PRD R26/R27). Nothing is written when a handler announced nothing, so an
|
|
407
|
+
// uninstrumented call still costs one scope read.
|
|
408
|
+
if (traced) {
|
|
409
|
+
const progress = takeProgress();
|
|
410
|
+
if (progress !== undefined) {
|
|
411
|
+
attachProgress(progress);
|
|
412
|
+
portLogCalls(this)?.answered(inbound.leg as string);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return result;
|
|
388
416
|
};
|
|
389
417
|
// The callee adopts the identity its caller declared, so the records its
|
|
390
418
|
// handler writes belong to the caller's execution and its own calls are
|
package/src/Gateway.ts
CHANGED
|
@@ -592,17 +592,17 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
592
592
|
// Declare the hop this route is making. The caller is
|
|
593
593
|
// **`public`** - the surface that answered the request,
|
|
594
594
|
// named apart from the namespace it calls. The gateway realm
|
|
595
|
-
// is a namespace called `gateway`, so a
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
595
|
+
// is a namespace called `gateway`, so a caller that was also
|
|
596
|
+
// called `gateway` drew the public surface and that namespace
|
|
597
|
+
// as one participant, and a realm answering a call to its own
|
|
598
|
+
// namespace read as a self-call. The API surface is a unit of
|
|
599
|
+
// its own - not the process either: one process hosts a whole
|
|
600
|
+
// suite, so naming the process here would draw every flow of a
|
|
601
|
+
// monolith as the same participant.
|
|
602
|
+
// `methodName` is the wire name the request asked for, and it
|
|
603
|
+
// is the leg: the caller is declared beside it, so a diagram
|
|
604
|
+
// labels the arrow with the method and draws its source from
|
|
605
|
+
// the unit. The callee's receipt then carries the same leg,
|
|
606
606
|
// which is what makes the drawing a call rather than a
|
|
607
607
|
// participation.
|
|
608
608
|
declareCall(
|
package/src/Registry.ts
CHANGED
|
@@ -25,6 +25,7 @@ import merge from 'ut-function.merge';
|
|
|
25
25
|
|
|
26
26
|
import * as blongLib from '@feasibleone/blong-lib';
|
|
27
27
|
import {renderAll} from '@feasibleone/blong-template';
|
|
28
|
+
import {vocabulary} from '@feasibleone/semantic-log/attachable';
|
|
28
29
|
import {createAttachCheckpoint} from './checkpoint.ts';
|
|
29
30
|
import {methodId, methodParts} from './lib.ts';
|
|
30
31
|
import type {IResolution} from './Resolution.ts';
|
|
@@ -369,13 +370,25 @@ export default class Registry extends Internal implements IRegistry {
|
|
|
369
370
|
port: object | undefined,
|
|
370
371
|
): Promise<{local: object; literals: object[]}> {
|
|
371
372
|
const attachCheckpoint = this.#attachCheckpoint;
|
|
373
|
+
const recording = (this.#config.checkpointMode ?? 'production') !== 'production';
|
|
372
374
|
const lib = {
|
|
375
|
+
// The library's own surface first, so an injected handle can never be silently
|
|
376
|
+
// overwritten by a name it happens to share: `blongLib` exports `checkpoint`
|
|
377
|
+
// for the snapshot marker, and the spread used to sit *after* these members.
|
|
378
|
+
...blongLib,
|
|
373
379
|
error: this.#error.register.bind(this.#error),
|
|
374
|
-
assert:
|
|
380
|
+
assert: recording ? nodeAssert : undefined,
|
|
381
|
+
// The progress-point pair (PRD R26). `checkpoint` reports a milestone from code that
|
|
382
|
+
// has no `$meta` — it is the emitter's own `point` reached through the attachable
|
|
383
|
+
// facade, and it is `undefined` in production for the same reason `$meta.checkpoint`
|
|
384
|
+
// is: a note nobody records must cost nothing. `decide` selects, so it is always
|
|
385
|
+
// there, and it is the same function in every mode — what the mode decides is whether
|
|
386
|
+
// a record carries the rationale, not whether the branch is taken.
|
|
387
|
+
checkpoint: recording ? vocabulary.point : undefined,
|
|
388
|
+
decide: vocabulary.decide,
|
|
375
389
|
render: (what: object[] | object) =>
|
|
376
390
|
renderAll(Array.isArray(what) ? merge(...what) : what, this.#platform.context),
|
|
377
391
|
timing: this.#platform.timing,
|
|
378
|
-
...blongLib,
|
|
379
392
|
// The runtime services a `library()` function needs, mirroring the
|
|
380
393
|
// port API (`registry: this`, `platform: …`).
|
|
381
394
|
//
|
package/src/SemanticLog.test.ts
CHANGED
|
@@ -52,6 +52,54 @@ t.test('a cache configured without a bound takes the default', async t => {
|
|
|
52
52
|
t.equal(log.store.stats().size, 1, 'the default bound retains a record comfortably');
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
t.test('a record carries the branch and the points of the scope that wrote it', async t => {
|
|
56
|
+
// PRD R26/R27: the framework's logger must not have to know about progress points for them to
|
|
57
|
+
// reach a record — they ride the emitter's ambient scope, and what this pins is that the
|
|
58
|
+
// framework's own path (`semanticRecord.ts`, `LogBase`, the call channel that goes through the
|
|
59
|
+
// same logger) reads that scope rather than writing a record of its own.
|
|
60
|
+
const dir = await mkdtemp(join(tmpdir(), 'blong-semantic-log-'));
|
|
61
|
+
t.teardown(() => rm(dir, {recursive: true, force: true}));
|
|
62
|
+
const log = new SemanticLog({level: 'info', cache: {dir}});
|
|
63
|
+
await log.init();
|
|
64
|
+
const at = log.logger('info', {name: 'progress'});
|
|
65
|
+
|
|
66
|
+
const chosen = vocabulary.decide('route-selection', {corridor: 'EUR-USD'}, [
|
|
67
|
+
{name: 'hold', when: () => false, run: () => 'hold'},
|
|
68
|
+
{
|
|
69
|
+
name: 'hubB',
|
|
70
|
+
when: () => true,
|
|
71
|
+
run: () => {
|
|
72
|
+
vocabulary.point('corridor-resolved', {hub: 'B'});
|
|
73
|
+
at.info?.('routing to the far ecosystem');
|
|
74
|
+
return 'hubB';
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
t.equal(chosen, 'hubB', 'the branch ran');
|
|
79
|
+
await log.stop();
|
|
80
|
+
|
|
81
|
+
let record: {msg?: string; progress?: {regions?: unknown[]; points?: unknown[]}} | undefined;
|
|
82
|
+
for (const id of await cacheRecordIds(dir)) {
|
|
83
|
+
const candidate = await log.store.get(id);
|
|
84
|
+
if (candidate?.msg === 'routing to the far ecosystem') {
|
|
85
|
+
record = candidate;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
t.ok(record, 'the record the branch wrote was retained');
|
|
89
|
+
t.same(
|
|
90
|
+
(record?.progress?.regions as Array<{discriminator: string; chosen: string}>).map(
|
|
91
|
+
region => [region.discriminator, region.chosen],
|
|
92
|
+
),
|
|
93
|
+
[['route-selection', 'hubB']],
|
|
94
|
+
'it was emitted inside the branch, which is what a diagram draws as an alt block',
|
|
95
|
+
);
|
|
96
|
+
t.same(
|
|
97
|
+
(record?.progress?.points as Array<{name: string}>).map(point => point.name),
|
|
98
|
+
['corridor-resolved'],
|
|
99
|
+
'and the point announced in that scope rides the same record',
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
55
103
|
t.test('the home-relative cache path the framework uses is expanded', t => {
|
|
56
104
|
// The framework's own configuration names the shared log cache as
|
|
57
105
|
// `~/.blong/log-cache`, and the pino transport expands that itself — so a
|
|
@@ -329,13 +377,14 @@ t.test('a record made inside a flow names the flow, the call and the position',
|
|
|
329
377
|
);
|
|
330
378
|
const [id] = await cacheRecordIds(dir);
|
|
331
379
|
const record = (await log.store.get(id ?? '')) as unknown as {
|
|
332
|
-
flow?: {id?: string; leg?: string; legTo?: string; legSeq?: string};
|
|
380
|
+
flow?: {id?: string; leg?: string; legFrom?: string; legTo?: string; legSeq?: string};
|
|
333
381
|
};
|
|
334
382
|
t.equal(
|
|
335
383
|
record.flow?.leg,
|
|
336
|
-
'
|
|
337
|
-
'the record names the call it was made in',
|
|
384
|
+
'party.subject.find',
|
|
385
|
+
'the record names the call it was made in, by the method it was made by',
|
|
338
386
|
);
|
|
387
|
+
t.equal(record.flow?.legFrom, 'access.db', 'and the unit that made it, beside the method');
|
|
339
388
|
t.equal(record.flow?.legTo, 'party', 'and the participant the caller aimed at');
|
|
340
389
|
t.match(String(record.flow?.legSeq), /^[0-9]+$/, 'and its position in the execution');
|
|
341
390
|
t.equal(isFlowId(record.flow?.id), true, 'and the execution it is part of');
|
|
@@ -382,8 +431,8 @@ t.test('the records of a flow reach the cluster service and come back as a diagr
|
|
|
382
431
|
t.equal(diagram?.statusCode, 200, 'the execution can be drawn from the records alone');
|
|
383
432
|
t.match(
|
|
384
433
|
diagram?.body ?? '',
|
|
385
|
-
/
|
|
386
|
-
'and the drawing names the call the caller declared',
|
|
434
|
+
/party\.subject\.find/,
|
|
435
|
+
'and the drawing names the call the caller declared, by its method',
|
|
387
436
|
);
|
|
388
437
|
});
|
|
389
438
|
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a handler announced, on the answer of the call it handled (PRD R26/R27).
|
|
3
|
+
*
|
|
4
|
+
* The gap this closes, measured before it was closed: a realm handler announced five points and
|
|
5
|
+
* one branch through `$meta.checkpoint` / `$meta.decide`, and **no record carried any of them** —
|
|
6
|
+
* 0 of 5056 retained records. The handles work; the records do not. A call's records are written
|
|
7
|
+
* by the framework *around* the handler (the receipt before it runs, the answer from the caller's
|
|
8
|
+
* end), so the ambient scope the handler staged into never emits one, and everything it announced
|
|
9
|
+
* is dropped with that scope.
|
|
10
|
+
*
|
|
11
|
+
* What is pinned here is the receiver's half: the adapter reads what its handler announced once
|
|
12
|
+
* the handler returns and reports it on the leg it answered, which is a record the ledger already
|
|
13
|
+
* matches. Two properties matter and both are asserted below — the progress is *re-announced* (so
|
|
14
|
+
* the logger's own facet machinery puts it on the record, rather than a second way of writing
|
|
15
|
+
* records being invented), and nothing is written when the handler announced nothing.
|
|
16
|
+
*/
|
|
17
|
+
import type {IMeta} from '@feasibleone/blong/types';
|
|
18
|
+
import {attachSemanticVocabulary} from '@feasibleone/semantic-log/attachable';
|
|
19
|
+
import * as vocabulary from '@feasibleone/semantic-log/emitter';
|
|
20
|
+
import t from 'tap';
|
|
21
|
+
import {AdapterBase} from './AdapterBase.ts';
|
|
22
|
+
import {createAttachCheckpoint} from './checkpoint.ts';
|
|
23
|
+
|
|
24
|
+
// The framework reaches the vocabulary by attachment (the emitter's scope is Node-only, and the
|
|
25
|
+
// browser bootstrap loads this module too), so a tap process attaches it exactly as `loadServer.ts`
|
|
26
|
+
// does. Without it the handles degrade and there is nothing to report.
|
|
27
|
+
attachSemanticVocabulary(vocabulary);
|
|
28
|
+
|
|
29
|
+
/** A canonical flow ULID. */
|
|
30
|
+
const FLOW = '01ARZ3NDEKTSV4RRFFQ69G5FAV';
|
|
31
|
+
|
|
32
|
+
/** The leg the caller declared for the call this adapter answers. */
|
|
33
|
+
const LEG = 'gateway.bundle.merge';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The `$meta` a receiver is handed, built the way the wire builds it: the identities travel in
|
|
37
|
+
* the header bag, and the leg is in it because the caller declared one.
|
|
38
|
+
*/
|
|
39
|
+
function inboundMeta(): IMeta {
|
|
40
|
+
const forward = vocabulary.withFlow({id: FLOW, kind: 'transfer.single'}, () =>
|
|
41
|
+
vocabulary.bindLeg({id: LEG, from: 'public', to: 'gateway'}, () =>
|
|
42
|
+
vocabulary.identityHeaders(),
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
return {mtid: 'event', method: 'exec', forward};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the fake call channel saw, with the progress staged at the moment it was asked. */
|
|
49
|
+
interface ISeen {
|
|
50
|
+
phase: string;
|
|
51
|
+
leg: string;
|
|
52
|
+
points?: unknown;
|
|
53
|
+
regions?: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The adapter under test: the real `handle`, on an instance built without a constructor.
|
|
58
|
+
*
|
|
59
|
+
* `handle` reads exactly three things — the imported handler table, the checkpoint attach hook and
|
|
60
|
+
* the call channel — so those are the three that are set. The alternative, going through
|
|
61
|
+
* `adapterFactory`, would drag in the registry, the schema and the config runtime to test a
|
|
62
|
+
* twenty-line method.
|
|
63
|
+
*/
|
|
64
|
+
function receiver(handler: (params: unknown[]) => unknown, seen: ISeen[]) {
|
|
65
|
+
const adapter = Object.create(AdapterBase.prototype) as AdapterBase<unknown, never>;
|
|
66
|
+
adapter.imported = {exec: handler as never};
|
|
67
|
+
adapter._attachCheckpoint = createAttachCheckpoint('debug');
|
|
68
|
+
// The method-id hook is the constructor's, and it is asked before the handler is found. Identity
|
|
69
|
+
// is the honest stub for a test: the real one names a method the way its realm does.
|
|
70
|
+
adapter._methodId = (name: string) => name;
|
|
71
|
+
(adapter as unknown as {log: unknown}).log = {
|
|
72
|
+
calls: {
|
|
73
|
+
enabled: () => true,
|
|
74
|
+
start: () => undefined,
|
|
75
|
+
end: () => undefined,
|
|
76
|
+
error: () => undefined,
|
|
77
|
+
received: (leg: string) => void seen.push({phase: 'received', leg}),
|
|
78
|
+
// Read at the moment the answer is written, which is the whole point: what the
|
|
79
|
+
// framework staged onto the scope is what the record will carry.
|
|
80
|
+
answered: (leg: string) => {
|
|
81
|
+
const points = vocabulary.takePoints();
|
|
82
|
+
const regions = vocabulary.currentRegions();
|
|
83
|
+
seen.push({
|
|
84
|
+
phase: 'answered',
|
|
85
|
+
leg,
|
|
86
|
+
...(points ? {points} : {}),
|
|
87
|
+
...(regions ? {regions} : {}),
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
return adapter;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
t.test('the answer of a call carries what its handler announced', async t => {
|
|
96
|
+
const seen: ISeen[] = [];
|
|
97
|
+
const meta = inboundMeta();
|
|
98
|
+
const adapter = receiver(() => {
|
|
99
|
+
meta.checkpoint?.('merge-started', {bundles: 1});
|
|
100
|
+
return meta.decide?.('role-bit', {declared: null}, [
|
|
101
|
+
{name: 'allocated', when: () => true, run: () => 'allocated'},
|
|
102
|
+
{name: 'declared', when: () => false, run: () => 'declared'},
|
|
103
|
+
]);
|
|
104
|
+
}, seen);
|
|
105
|
+
|
|
106
|
+
const result = await adapter.handle({}, meta);
|
|
107
|
+
t.equal(result, 'allocated', 'the handler answered');
|
|
108
|
+
|
|
109
|
+
t.same(
|
|
110
|
+
seen.map(event => event.phase),
|
|
111
|
+
['received', 'answered'],
|
|
112
|
+
'the receiver writes a receipt when the call arrives and an answer when its work is done',
|
|
113
|
+
);
|
|
114
|
+
t.equal(seen[1]?.leg, LEG, 'and the answer names the leg it was handed');
|
|
115
|
+
t.same(
|
|
116
|
+
seen[1]?.points,
|
|
117
|
+
[{name: 'merge-started', data: {bundles: 1}}],
|
|
118
|
+
'the point the handler announced is staged for the answer',
|
|
119
|
+
);
|
|
120
|
+
t.same(
|
|
121
|
+
(
|
|
122
|
+
seen[1]?.regions as Array<{
|
|
123
|
+
discriminator: string;
|
|
124
|
+
chosen: string;
|
|
125
|
+
candidates: string[];
|
|
126
|
+
}>
|
|
127
|
+
).map(region => [region.discriminator, region.chosen, region.candidates]),
|
|
128
|
+
[['role-bit', 'allocated', ['allocated', 'declared']]],
|
|
129
|
+
'and so is the branch it took, so the block can be drawn around this hop',
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
t.test('a handler that waits before it announces is still answered for', async t => {
|
|
134
|
+
// The regression this pins: the collection's boxes are installed with `enterWith`, which
|
|
135
|
+
// reaches the current context and what follows it — never a frame that is already suspended.
|
|
136
|
+
// A handler that awaits before it announces anything resumes in a context whose boxes were
|
|
137
|
+
// created after the receiver's continuation was bound, so without installing them first the
|
|
138
|
+
// branch mark (and every point staged after the wait) reached no record: measured live, the
|
|
139
|
+
// points survived and the branch did not.
|
|
140
|
+
const seen: ISeen[] = [];
|
|
141
|
+
const meta = inboundMeta();
|
|
142
|
+
const adapter = receiver(async () => {
|
|
143
|
+
await new Promise(resolve => setTimeout(resolve, 1));
|
|
144
|
+
meta.checkpoint?.('after-the-wait');
|
|
145
|
+
return meta.decide?.('role-bit', {declared: null}, [
|
|
146
|
+
{name: 'allocated', when: () => true, run: () => 'allocated'},
|
|
147
|
+
{name: 'declared', when: () => false, run: () => 'declared'},
|
|
148
|
+
]);
|
|
149
|
+
}, seen);
|
|
150
|
+
|
|
151
|
+
await adapter.handle({}, meta);
|
|
152
|
+
t.same(
|
|
153
|
+
seen.map(event => event.phase),
|
|
154
|
+
['received', 'answered'],
|
|
155
|
+
'the answer is still written after a wait',
|
|
156
|
+
);
|
|
157
|
+
t.same(seen[1]?.points, [{name: 'after-the-wait'}], 'with the point announced after it');
|
|
158
|
+
t.same(
|
|
159
|
+
(
|
|
160
|
+
seen[1]?.regions as Array<{
|
|
161
|
+
discriminator: string;
|
|
162
|
+
chosen: string;
|
|
163
|
+
candidates: string[];
|
|
164
|
+
}>
|
|
165
|
+
).map(region => [region.discriminator, region.chosen, region.candidates]),
|
|
166
|
+
[['role-bit', 'allocated', ['allocated', 'declared']]],
|
|
167
|
+
'and the branch it took, which is what the alt block is drawn from',
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
t.test('a handler that announced nothing writes no answer', async t => {
|
|
172
|
+
const seen: ISeen[] = [];
|
|
173
|
+
const meta = inboundMeta();
|
|
174
|
+
const adapter = receiver(() => ({success: true}), seen);
|
|
175
|
+
await adapter.handle({}, meta);
|
|
176
|
+
t.same(
|
|
177
|
+
seen.map(event => event.phase),
|
|
178
|
+
['received'],
|
|
179
|
+
'an uninstrumented call still pays nothing beyond its receipt',
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
t.test('an untraced call records nothing at all', async t => {
|
|
184
|
+
const seen: ISeen[] = [];
|
|
185
|
+
const adapter = receiver(() => {
|
|
186
|
+
return {success: true};
|
|
187
|
+
}, seen);
|
|
188
|
+
await adapter.handle({}, {mtid: 'event', method: 'exec'});
|
|
189
|
+
t.same(seen, [], 'no flow on the call, so no records — the gate is asked first');
|
|
190
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework's two progress-point handles (PRD R11/R26).
|
|
3
|
+
*
|
|
4
|
+
* A checkpoint reports a moment; a decision selects a branch. That difference is the whole
|
|
5
|
+
* reason one is optional and the other is not, so it is pinned here: a checkpoint exists
|
|
6
|
+
* only where points are recorded, a decision exists in every mode, and both reach the log
|
|
7
|
+
* through the attachable vocabulary — degrading to nothing and to a plain selection where
|
|
8
|
+
* no emitter is attached, because a dispatch path must never reach the emitter's ambient
|
|
9
|
+
* scope (F-197).
|
|
10
|
+
*/
|
|
11
|
+
import type {IMeta} from '@feasibleone/blong/types';
|
|
12
|
+
import {
|
|
13
|
+
attachSemanticVocabulary,
|
|
14
|
+
detachSemanticVocabulary,
|
|
15
|
+
} from '@feasibleone/semantic-log/attachable';
|
|
16
|
+
import * as semantic from '@feasibleone/semantic-log/emitter';
|
|
17
|
+
import t from 'tap';
|
|
18
|
+
import {createAttachCheckpoint} from './checkpoint.ts';
|
|
19
|
+
|
|
20
|
+
const MODES = ['production', 'debug', 'test'] as const;
|
|
21
|
+
|
|
22
|
+
t.test('a branch is taken and its rationale kept, in every mode', t => {
|
|
23
|
+
for (const mode of MODES) {
|
|
24
|
+
const meta: IMeta = {};
|
|
25
|
+
createAttachCheckpoint(mode)(meta);
|
|
26
|
+
const result = meta.decide?.('rate-within-limit', {rate: 2}, [
|
|
27
|
+
{name: 'decline', when: values => (values.rate as number) > 1, run: () => 'declined'},
|
|
28
|
+
{name: 'accept', when: () => true, run: () => 'accepted'},
|
|
29
|
+
]);
|
|
30
|
+
t.equal(result, 'declined', `${mode}: the branch is taken`);
|
|
31
|
+
t.same(
|
|
32
|
+
meta.decisions,
|
|
33
|
+
[
|
|
34
|
+
{
|
|
35
|
+
discriminator: 'rate-within-limit',
|
|
36
|
+
candidates: ['decline', 'accept'],
|
|
37
|
+
chosen: 'decline',
|
|
38
|
+
values: {rate: 2},
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
`${mode}: and the invocation keeps which one it was`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
t.end();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
t.test('a checkpoint exists only where points are recorded', t => {
|
|
48
|
+
const production: IMeta = {};
|
|
49
|
+
createAttachCheckpoint('production')(production);
|
|
50
|
+
t.notOk(
|
|
51
|
+
production.checkpoint,
|
|
52
|
+
'absent in production, which is what makes the `?.` call cost nothing',
|
|
53
|
+
);
|
|
54
|
+
t.ok(production.decide, 'while the branch helper is there, because a branch cannot be skipped');
|
|
55
|
+
|
|
56
|
+
const debug: IMeta = {};
|
|
57
|
+
createAttachCheckpoint('debug')(debug);
|
|
58
|
+
debug.checkpoint?.('total-calculated', {total: 200});
|
|
59
|
+
t.equal(debug.checkpoints?.length, 1, 'recorded where points are');
|
|
60
|
+
t.equal(debug.checkpoints?.[0]?.name, 'total-calculated');
|
|
61
|
+
t.same(debug.checkpoints?.[0]?.data, {total: 200});
|
|
62
|
+
t.type(debug.checkpoints?.[0]?.timestamp, 'number', 'and stamped when it happened');
|
|
63
|
+
t.end();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
t.test('a checkpoint is announced to the log, and announces nothing without one', t => {
|
|
67
|
+
detachSemanticVocabulary();
|
|
68
|
+
const detached: IMeta = {};
|
|
69
|
+
createAttachCheckpoint('test')(detached);
|
|
70
|
+
detached.checkpoint?.('no-log');
|
|
71
|
+
t.equal(
|
|
72
|
+
semantic.takePoints(),
|
|
73
|
+
undefined,
|
|
74
|
+
'nothing is announced where no emitter is attached: the degradation is the whole point',
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
attachSemanticVocabulary(semantic);
|
|
78
|
+
t.teardown(() => detachSemanticVocabulary());
|
|
79
|
+
const attached: IMeta = {};
|
|
80
|
+
createAttachCheckpoint('test')(attached);
|
|
81
|
+
attached.checkpoint?.('total-calculated', {total: 200});
|
|
82
|
+
t.same(
|
|
83
|
+
semantic.takePoints(),
|
|
84
|
+
[{name: 'total-calculated', data: {total: 200}}],
|
|
85
|
+
'PRD R26: the milestone reaches the log, so a diagram can draw it as a note',
|
|
86
|
+
);
|
|
87
|
+
t.equal(semantic.takePoints(), undefined, 'and is taken once, by the record that carries it');
|
|
88
|
+
t.end();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
t.test('a decision is announced to the log too, and still selects without one', t => {
|
|
92
|
+
detachSemanticVocabulary();
|
|
93
|
+
const detached: IMeta = {};
|
|
94
|
+
createAttachCheckpoint('test')(detached);
|
|
95
|
+
t.equal(
|
|
96
|
+
detached.decide?.('d', {}, [{name: 'only', when: () => true, run: () => 7}]),
|
|
97
|
+
7,
|
|
98
|
+
'the branch is taken with nothing attached',
|
|
99
|
+
);
|
|
100
|
+
t.equal(semantic.takeDecision(), undefined, 'and no rationale is staged to record');
|
|
101
|
+
|
|
102
|
+
attachSemanticVocabulary(semantic);
|
|
103
|
+
t.teardown(() => detachSemanticVocabulary());
|
|
104
|
+
const attached: IMeta = {};
|
|
105
|
+
createAttachCheckpoint('test')(attached);
|
|
106
|
+
attached.decide?.('route-selection', {}, [
|
|
107
|
+
{name: 'hubB', when: () => true, run: () => 'hubB'},
|
|
108
|
+
{name: 'hold', when: () => true, run: () => undefined},
|
|
109
|
+
]);
|
|
110
|
+
t.same(
|
|
111
|
+
semantic.takeDecision()?.candidates,
|
|
112
|
+
['hubB', 'hold'],
|
|
113
|
+
'the rationale is staged for the next record, in evaluation order (PRD R11)',
|
|
114
|
+
);
|
|
115
|
+
t.end();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
t.test('no branch matching is a recorded non-choice', t => {
|
|
119
|
+
const meta: IMeta = {};
|
|
120
|
+
createAttachCheckpoint('test')(meta);
|
|
121
|
+
t.equal(meta.decide?.('never', {}, [{name: 'a', when: () => false, run: () => 1}]), undefined);
|
|
122
|
+
t.equal(meta.decisions?.[0]?.chosen, 'none', 'and it is recorded as `none` rather than lost');
|
|
123
|
+
t.end();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
t.test('each predicate is evaluated once', t => {
|
|
127
|
+
const meta: IMeta = {};
|
|
128
|
+
createAttachCheckpoint('test')(meta);
|
|
129
|
+
let evaluations = 0;
|
|
130
|
+
meta.decide?.('counting', {}, [
|
|
131
|
+
{
|
|
132
|
+
name: 'only',
|
|
133
|
+
when: () => {
|
|
134
|
+
evaluations++;
|
|
135
|
+
return true;
|
|
136
|
+
},
|
|
137
|
+
run: () => 'taken',
|
|
138
|
+
},
|
|
139
|
+
]);
|
|
140
|
+
t.equal(evaluations, 1, 'the `run` is wrapped, not the predicate re-evaluated');
|
|
141
|
+
t.end();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
t.test('a branch that throws records nothing', t => {
|
|
145
|
+
const meta: IMeta = {};
|
|
146
|
+
createAttachCheckpoint('test')(meta);
|
|
147
|
+
t.throws(
|
|
148
|
+
() =>
|
|
149
|
+
meta.decide?.('boom', {}, [
|
|
150
|
+
{
|
|
151
|
+
name: 'a',
|
|
152
|
+
when: () => true,
|
|
153
|
+
run: () => {
|
|
154
|
+
throw new Error('handler failed');
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
]),
|
|
158
|
+
/handler failed/,
|
|
159
|
+
'the failure is not swallowed',
|
|
160
|
+
);
|
|
161
|
+
t.notOk(
|
|
162
|
+
meta.decisions,
|
|
163
|
+
'a decision that never completed is not a decision, so nothing is recorded',
|
|
164
|
+
);
|
|
165
|
+
t.end();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
t.test('attaching twice does not replace a handle already there', t => {
|
|
169
|
+
const meta: IMeta = {};
|
|
170
|
+
const attach = createAttachCheckpoint('test');
|
|
171
|
+
attach(meta);
|
|
172
|
+
const first = meta.decide;
|
|
173
|
+
attach(meta);
|
|
174
|
+
t.equal(meta.decide, first, 'a dispatch that was already prepared is left alone');
|
|
175
|
+
t.end();
|
|
176
|
+
});
|
package/src/checkpoint.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import type {CheckpointFn, IMeta} from '@feasibleone/blong/types';
|
|
1
|
+
import type {CheckpointFn, DecideFn, IMeta} from '@feasibleone/blong/types';
|
|
2
|
+
import {vocabulary} from '@feasibleone/semantic-log/attachable';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Records a checkpoint in the $meta.checkpoints array
|
|
5
|
+
* Records a checkpoint in the $meta.checkpoints array, and announces it as a progress
|
|
6
|
+
* point (PRD R26).
|
|
7
|
+
*
|
|
5
8
|
* Uses `this` binding — works correctly when called as
|
|
6
9
|
* $meta.checkpoint?.('name', data) with optional chaining.
|
|
7
10
|
*/
|
|
@@ -11,22 +14,65 @@ const checkpoint: CheckpointFn = function (this: IMeta, name: string, data?: unk
|
|
|
11
14
|
data,
|
|
12
15
|
timestamp: Date.now(),
|
|
13
16
|
});
|
|
17
|
+
// The same moment is announced to the log, so a checkpoint reaches the record the
|
|
18
|
+
// sequence diagram is drawn from rather than only the list a test asserts on. The
|
|
19
|
+
// vocabulary degrades to nothing where no emitter is attached, so this is one call
|
|
20
|
+
// and never a way for a dispatch path to import the emitter's ambient scope (F-197).
|
|
21
|
+
vocabulary.point(name, data);
|
|
14
22
|
};
|
|
15
23
|
|
|
16
24
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
25
|
+
* Takes a branch, and keeps the rationale on the invocation that took it (PRD R11/R26).
|
|
26
|
+
*
|
|
27
|
+
* `this`-bound like {@link checkpoint}. The branch's `run` is *wrapped* rather than the
|
|
28
|
+
* predicates re-evaluated: a predicate with a side effect must run exactly once, and the
|
|
29
|
+
* decision recorded has to be the one the code actually took. A branch whose `run` throws
|
|
30
|
+
* records nothing — the throw propagates before the push, because a decision that never
|
|
31
|
+
* completed is not a decision.
|
|
19
32
|
*/
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
33
|
+
const decide: DecideFn = function <T>(
|
|
34
|
+
this: IMeta,
|
|
35
|
+
discriminator: string,
|
|
36
|
+
values: Record<string, unknown>,
|
|
37
|
+
branches: ReadonlyArray<{
|
|
38
|
+
name: string;
|
|
39
|
+
when: (values: Record<string, unknown>) => boolean;
|
|
40
|
+
run: () => T;
|
|
41
|
+
}>,
|
|
42
|
+
): T | undefined {
|
|
43
|
+
let chosen: string | undefined;
|
|
44
|
+
const wrapped = branches.map(branch => ({
|
|
45
|
+
...branch,
|
|
46
|
+
run: () => {
|
|
47
|
+
chosen = branch.name;
|
|
48
|
+
return branch.run();
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
const result = vocabulary.decide<T>(discriminator, values, wrapped);
|
|
52
|
+
(this.decisions ??= []).push({
|
|
53
|
+
discriminator,
|
|
54
|
+
candidates: branches.map(branch => branch.name),
|
|
55
|
+
chosen: chosen ?? 'none',
|
|
56
|
+
values,
|
|
57
|
+
});
|
|
58
|
+
return result;
|
|
59
|
+
};
|
|
23
60
|
|
|
24
61
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
62
|
+
* Attach the framework's progress-point handles to a dispatch's `$meta`.
|
|
63
|
+
*
|
|
64
|
+
* `decide` is attached in **every** mode: it selects a branch, so a missing one would skip
|
|
65
|
+
* the work rather than cost a note. `checkpoint` is attached only where points are
|
|
66
|
+
* recorded, which is what keeps its `?.` call free in production.
|
|
27
67
|
*/
|
|
28
68
|
export function createAttachCheckpoint(
|
|
29
69
|
mode: 'test' | 'debug' | 'production',
|
|
30
|
-
): (
|
|
31
|
-
|
|
70
|
+
): (meta: IMeta) => void {
|
|
71
|
+
const recording = mode !== 'production';
|
|
72
|
+
return (meta: IMeta): void => {
|
|
73
|
+
meta.decide ??= decide;
|
|
74
|
+
if (recording) {
|
|
75
|
+
meta.checkpoint ??= checkpoint;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
32
78
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {orchestrator, type IMeta} from '@feasibleone/blong/types';
|
|
2
|
-
import {
|
|
2
|
+
import {recordedCall} from '../../lib.ts';
|
|
3
3
|
import {declareCall, namespaceOf} from '../../semanticContext.ts';
|
|
4
4
|
|
|
5
5
|
export default orchestrator<{destination?: string; appendNamespace?: string}>(({remote}) => ({
|
|
@@ -35,10 +35,11 @@ export default orchestrator<{destination?: string; appendNamespace?: string}>(({
|
|
|
35
35
|
// name would credit the hop to whichever namespace happened to be served
|
|
36
36
|
// alongside it.
|
|
37
37
|
//
|
|
38
|
-
// The leg is
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
38
|
+
// The leg is the method the forwarded call reaches its destination with — the
|
|
39
|
+
// wire name, slash and all (`db/gateway.bundle.find`): that is the method the
|
|
40
|
+
// callee strips back to, and a label that dotted it would no longer be the name
|
|
41
|
+
// the call was made by. Who made it is stated beside the method, not inside it.
|
|
42
|
+
const declared = forwarded;
|
|
42
43
|
const forward = async (): Promise<unknown[] | undefined> =>
|
|
43
44
|
(await recordedCall(this, declared, () =>
|
|
44
45
|
remote.dispatch(...params, {...$meta, method: forwarded}),
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `lib` a handler is built with (PRD R26).
|
|
3
|
+
*
|
|
4
|
+
* `blongLib` is spread into `lib` first, so a name the library exports cannot silently
|
|
5
|
+
* replace a handle the framework injects: `blongLib` exports `snapshot` (a chain step) and
|
|
6
|
+
* used to export `checkpoint` for it, an unrelated thing that shares a word with the
|
|
7
|
+
* runtime's milestone handle, so the spread used to sit after the injections. The order
|
|
8
|
+
* is the invariant, so it is pinned here rather than left to a comment — together with
|
|
9
|
+
* the mode-gated `assert`, the other member whose presence the mode decides.
|
|
10
|
+
*/
|
|
11
|
+
import * as blongLib from '@feasibleone/blong-lib';
|
|
12
|
+
import type {ILib} from '@feasibleone/blong/types';
|
|
13
|
+
import {vocabulary} from '@feasibleone/semantic-log/attachable';
|
|
14
|
+
import t from 'tap';
|
|
15
|
+
import Registry from './Registry.ts';
|
|
16
|
+
|
|
17
|
+
// Minimal stand-ins: `_createHandlers` only reads the error factory, the platform's
|
|
18
|
+
// timing and the api schema, and closes over the rest.
|
|
19
|
+
const components = {
|
|
20
|
+
log: {logger: () => ({info() {}, error() {}, warn() {}, debug() {}})},
|
|
21
|
+
error: {register: () => () => undefined},
|
|
22
|
+
rpcServer: {},
|
|
23
|
+
remote: {remote: () => ({})},
|
|
24
|
+
gateway: {},
|
|
25
|
+
local: {},
|
|
26
|
+
resolution: {},
|
|
27
|
+
watch: {},
|
|
28
|
+
apiSchema: {},
|
|
29
|
+
platform: {context: {}, timing: () => undefined},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Run one handler through the factory and hand back the `lib` it was given. */
|
|
33
|
+
async function captureLib(mode: 'test' | 'debug' | 'production'): Promise<ILib> {
|
|
34
|
+
const registry = new Registry({checkpointMode: mode}, components as never);
|
|
35
|
+
let lib: ILib | undefined;
|
|
36
|
+
await (
|
|
37
|
+
registry as unknown as {
|
|
38
|
+
_createHandlers(handlers: unknown, port: object | undefined): Promise<unknown>;
|
|
39
|
+
}
|
|
40
|
+
)._createHandlers(
|
|
41
|
+
[
|
|
42
|
+
async (params: {lib: ILib}) => {
|
|
43
|
+
lib = params.lib;
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
undefined,
|
|
47
|
+
);
|
|
48
|
+
if (lib === undefined) {
|
|
49
|
+
throw new Error('the handler factory was not called');
|
|
50
|
+
}
|
|
51
|
+
return lib;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
t.test('a handler is handed the framework branch helper, not a name the library owns', async t => {
|
|
55
|
+
const lib = await captureLib('test');
|
|
56
|
+
t.type(lib.decide, 'function', 'the branch helper is always injected');
|
|
57
|
+
t.equal(
|
|
58
|
+
lib.decide,
|
|
59
|
+
vocabulary.decide,
|
|
60
|
+
'and it is the attachable one, so a realm needs no dependency on the log library',
|
|
61
|
+
);
|
|
62
|
+
t.equal(lib.checkpoint, vocabulary.point, 'the milestone handle is the ambient one');
|
|
63
|
+
t.notOk(
|
|
64
|
+
'checkpoint' in blongLib,
|
|
65
|
+
'the library does not define that name, so the spread cannot replace it',
|
|
66
|
+
);
|
|
67
|
+
t.notOk('decide' in blongLib, 'nor the branch helper');
|
|
68
|
+
t.notOk('assert' in blongLib, 'nor the assertion handle');
|
|
69
|
+
t.end();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
t.test('checkpoint follows the mode, while decide is always there', async t => {
|
|
73
|
+
t.type((await captureLib('test')).checkpoint, 'function', 'test mode reports points');
|
|
74
|
+
t.type((await captureLib('debug')).checkpoint, 'function', 'debug mode reports them too');
|
|
75
|
+
t.equal(
|
|
76
|
+
(await captureLib('production')).checkpoint,
|
|
77
|
+
undefined,
|
|
78
|
+
'and production leaves the slot empty, so `lib.checkpoint?.()` is one call',
|
|
79
|
+
);
|
|
80
|
+
t.type(
|
|
81
|
+
(await captureLib('production')).decide,
|
|
82
|
+
'function',
|
|
83
|
+
'a branch is selected in production as anywhere else',
|
|
84
|
+
);
|
|
85
|
+
t.end();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
t.test('assert is there only where a failure may be expected', async t => {
|
|
89
|
+
t.type((await captureLib('test')).assert, 'function', 'test mode asserts');
|
|
90
|
+
t.type((await captureLib('debug')).assert, 'function', 'debug mode asserts too');
|
|
91
|
+
t.equal(
|
|
92
|
+
(await captureLib('production')).assert,
|
|
93
|
+
undefined,
|
|
94
|
+
'and production leaves the slot empty, so `lib.assert?.(…)` is one call',
|
|
95
|
+
);
|
|
96
|
+
t.end();
|
|
97
|
+
});
|
|
@@ -8,13 +8,14 @@ import {
|
|
|
8
8
|
callsFor,
|
|
9
9
|
capabilityOf,
|
|
10
10
|
currentIdentity,
|
|
11
|
-
|
|
11
|
+
decide,
|
|
12
12
|
declareCall,
|
|
13
|
+
enterCapability,
|
|
13
14
|
enterRequestFlow,
|
|
14
15
|
inboundIdentities,
|
|
15
16
|
isFlowId,
|
|
16
|
-
legIdFor,
|
|
17
17
|
namespaceOf,
|
|
18
|
+
point,
|
|
18
19
|
runInFlow,
|
|
19
20
|
} from './semanticContext.ts';
|
|
20
21
|
|
|
@@ -32,6 +33,30 @@ function inbound(fields: string): IMeta {
|
|
|
32
33
|
return {mtid: 'request', method: 'm', forward: {'x-semantic-trace': fields}};
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
t.test('the progress-point handles report through whichever vocabulary is attached', t => {
|
|
37
|
+
// PRD R26: a realm or a bootstrap reaches these through this module rather than through the
|
|
38
|
+
// emitter, so what they must be is the *façade's* functions — the ones that degrade when
|
|
39
|
+
// nothing is attached (this module is on the browser's path too). With the vocabulary attached
|
|
40
|
+
// above, the same calls reach the emitter itself.
|
|
41
|
+
point('handler-created', {by: 'semanticContext.test'});
|
|
42
|
+
t.same(
|
|
43
|
+
vocabulary.takePoints(),
|
|
44
|
+
[{name: 'handler-created', data: {by: 'semanticContext.test'}}],
|
|
45
|
+
'a point reported here is taken by the attached vocabulary',
|
|
46
|
+
);
|
|
47
|
+
const chosen = decide('route', {}, [
|
|
48
|
+
{name: 'a', when: () => false, run: () => 'a'},
|
|
49
|
+
{name: 'b', when: () => true, run: () => 'b'},
|
|
50
|
+
]);
|
|
51
|
+
t.equal(chosen, 'b', 'and a branch taken here still selects');
|
|
52
|
+
t.equal(
|
|
53
|
+
vocabulary.takeDecision()?.chosen,
|
|
54
|
+
'b',
|
|
55
|
+
'while its rationale reaches the vocabulary too',
|
|
56
|
+
);
|
|
57
|
+
t.end();
|
|
58
|
+
});
|
|
59
|
+
|
|
35
60
|
t.test('a request enters its flow before the hooks that may reject it', async t => {
|
|
36
61
|
// The enter-style entry. A gateway request is decided in two phases with a
|
|
37
62
|
// framework boundary between them: the auth and metering hooks run before the
|
|
@@ -40,7 +65,11 @@ t.test('a request enters its flow before the hooks that may reject it', async t
|
|
|
40
65
|
await (async () => {
|
|
41
66
|
const published = enterRequestFlow({}, 'access.role.find');
|
|
42
67
|
t.equal(isFlowId(currentContext().flow?.id), true, 'a flow is minted and entered');
|
|
43
|
-
t.equal(
|
|
68
|
+
t.equal(
|
|
69
|
+
currentContext().flow?.kind,
|
|
70
|
+
'access.role.find',
|
|
71
|
+
'named for the path it arrived on',
|
|
72
|
+
);
|
|
44
73
|
t.equal(
|
|
45
74
|
published['x-semantic-trace']?.includes(`flow=${currentContext().flow?.id ?? ''}`),
|
|
46
75
|
true,
|
|
@@ -84,31 +113,61 @@ t.test('a flow id is a ULID and nothing else', t => {
|
|
|
84
113
|
|
|
85
114
|
t.test('identity is read off the forwarded header bag', t => {
|
|
86
115
|
t.same(inboundIdentities(undefined), {}, 'a call with no meta carries nothing');
|
|
87
|
-
t.same(inboundIdentities({mtid: 'request', method: 'm'}), {}, 'and so does one with no forward block');
|
|
88
116
|
t.same(
|
|
89
|
-
inboundIdentities(
|
|
90
|
-
{
|
|
117
|
+
inboundIdentities({mtid: 'request', method: 'm'}),
|
|
118
|
+
{},
|
|
119
|
+
'and so does one with no forward block',
|
|
120
|
+
);
|
|
121
|
+
t.same(
|
|
122
|
+
inboundIdentities(
|
|
123
|
+
inbound(
|
|
124
|
+
`trace=tr-1,flow=${FLOW},leg=party.subject.find,from=access.db,to=party,seq=1.2`,
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
{
|
|
128
|
+
trace: 'tr-1',
|
|
129
|
+
flow: FLOW,
|
|
130
|
+
leg: 'party.subject.find',
|
|
131
|
+
from: 'access.db',
|
|
132
|
+
to: 'party',
|
|
133
|
+
seq: '1.2',
|
|
134
|
+
},
|
|
91
135
|
'a full set is read back out',
|
|
92
136
|
);
|
|
93
137
|
t.end();
|
|
94
138
|
});
|
|
95
139
|
|
|
96
|
-
t.test('a leg is
|
|
140
|
+
t.test('a leg is the method, and the caller travels beside it', t => {
|
|
141
|
+
// The method is what a diagram labels the arrow with, so it is the leg id verbatim -
|
|
142
|
+
// including a forwarded hop's slash (`db/party.subject.find`), which is the name the
|
|
143
|
+
// callee strips back to. The caller is its own identity: repeating it in the label said
|
|
144
|
+
// what the arrow already said, and the two are read for different things (D-249).
|
|
145
|
+
t.equal(namespaceOf('party.subject.find'), 'party', 'the method names the unit it reaches');
|
|
97
146
|
t.equal(
|
|
98
|
-
|
|
99
|
-
'
|
|
100
|
-
'a
|
|
147
|
+
namespaceOf('db/party.subject.find'),
|
|
148
|
+
'db',
|
|
149
|
+
'and a forwarded hop names the one it forwards to',
|
|
101
150
|
);
|
|
102
151
|
t.equal(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
'
|
|
152
|
+
vocabulary.isLegId('db/party.subject.find'),
|
|
153
|
+
true,
|
|
154
|
+
'a forwarded hop is a lawful leg id',
|
|
106
155
|
);
|
|
156
|
+
t.equal(
|
|
157
|
+
vocabulary.isServiceName('access.db'),
|
|
158
|
+
true,
|
|
159
|
+
'and a caller is named by the same grammar',
|
|
160
|
+
);
|
|
161
|
+
t.equal(vocabulary.isServiceName('access/db'), false, 'without the slash a method may carry');
|
|
107
162
|
t.end();
|
|
108
163
|
});
|
|
109
164
|
|
|
110
165
|
t.test('a participant is the first segment of the method', t => {
|
|
111
|
-
t.equal(
|
|
166
|
+
t.equal(
|
|
167
|
+
namespaceOf('party.subject.find'),
|
|
168
|
+
'party',
|
|
169
|
+
'a dotted method names its namespace first',
|
|
170
|
+
);
|
|
112
171
|
t.equal(namespaceOf('db/party.subject.find'), 'db', 'and so does a rewritten destination');
|
|
113
172
|
t.equal(namespaceOf('party'), 'party', 'a method in the root namespace is its own');
|
|
114
173
|
t.end();
|
|
@@ -121,13 +180,13 @@ t.test('an outermost entry mints a flow and publishes it for the calls inside it
|
|
|
121
180
|
identity: currentIdentity(),
|
|
122
181
|
}));
|
|
123
182
|
t.equal(isFlowId(seen.identity.flow), true, 'the entry has an execution id');
|
|
124
|
-
t.equal(seen.context.flow?.kind, 'access.user.find', 'whose kind is the method it was addressed to');
|
|
125
|
-
t.equal(typeof seen.context.trace, 'string', 'and it is correlated by a trace');
|
|
126
183
|
t.equal(
|
|
127
|
-
seen.
|
|
128
|
-
|
|
129
|
-
'
|
|
184
|
+
seen.context.flow?.kind,
|
|
185
|
+
'access.user.find',
|
|
186
|
+
'whose kind is the method it was addressed to',
|
|
130
187
|
);
|
|
188
|
+
t.equal(typeof seen.context.trace, 'string', 'and it is correlated by a trace');
|
|
189
|
+
t.equal(seen.identity.leg, undefined, 'the entry itself is not a call, so it declares no leg');
|
|
131
190
|
t.match(
|
|
132
191
|
meta.forward?.['x-semantic-trace'] ?? '',
|
|
133
192
|
new RegExp(`flow=${seen.identity.flow}`),
|
|
@@ -155,8 +214,16 @@ t.test('a B3 trace that cannot be used is minted instead', t => {
|
|
|
155
214
|
method: 'm',
|
|
156
215
|
forward: {'x-b3-traceid': 42},
|
|
157
216
|
} as unknown as IMeta;
|
|
158
|
-
t.not(
|
|
159
|
-
|
|
217
|
+
t.not(
|
|
218
|
+
runInFlow(empty, 'm', () => currentContext().trace),
|
|
219
|
+
'',
|
|
220
|
+
'an empty trace is not a trace',
|
|
221
|
+
);
|
|
222
|
+
t.not(
|
|
223
|
+
runInFlow(wrongType, 'm', () => currentContext().trace),
|
|
224
|
+
42,
|
|
225
|
+
'nor is one that is not a string',
|
|
226
|
+
);
|
|
160
227
|
t.end();
|
|
161
228
|
});
|
|
162
229
|
|
|
@@ -199,13 +266,15 @@ t.test('an inbound call carrying no identity is an outermost entry', t => {
|
|
|
199
266
|
});
|
|
200
267
|
|
|
201
268
|
t.test('an inbound identity the grammar refuses is dropped, never repaired', t => {
|
|
202
|
-
const badSeq = adoptInbound(
|
|
203
|
-
|
|
204
|
-
'm',
|
|
205
|
-
() => currentIdentity(),
|
|
269
|
+
const badSeq = adoptInbound(inbound(`flow=${FLOW},leg=payer.quote.rates,seq=1.`), 'm', () =>
|
|
270
|
+
currentIdentity(),
|
|
206
271
|
);
|
|
207
272
|
t.equal(badSeq.leg, 'payer.quote.rates', 'a lawful leg is adopted');
|
|
208
|
-
t.equal(
|
|
273
|
+
t.equal(
|
|
274
|
+
badSeq.seq,
|
|
275
|
+
undefined,
|
|
276
|
+
'but a position that is not one is left out rather than guessed',
|
|
277
|
+
);
|
|
209
278
|
|
|
210
279
|
const badLeg = adoptInbound(inbound(`flow=${FLOW},leg=!`), 'm', () => currentIdentity());
|
|
211
280
|
t.equal(badLeg.flow, FLOW, 'the execution is still adopted');
|
|
@@ -218,12 +287,16 @@ t.test('an inbound identity the grammar refuses is dropped, never repaired', t =
|
|
|
218
287
|
|
|
219
288
|
t.test('a call made outside any flow is not declared', t => {
|
|
220
289
|
const meta: IMeta = {mtid: 'request', method: 'm'};
|
|
221
|
-
t.equal(
|
|
290
|
+
t.equal(
|
|
291
|
+
declareCall('access.db', 'party.subject.find', () => 'ran', meta),
|
|
292
|
+
'ran',
|
|
293
|
+
'the call still runs',
|
|
294
|
+
);
|
|
222
295
|
t.equal(meta.forward, undefined, 'and nothing is published onto a meta no flow holds');
|
|
223
296
|
t.end();
|
|
224
297
|
});
|
|
225
298
|
|
|
226
|
-
t.test('a call inside a flow is named
|
|
299
|
+
t.test('a call inside a flow is named by its method, with the caller beside it', t => {
|
|
227
300
|
const meta: IMeta = {mtid: 'request', method: 'access.user.find'};
|
|
228
301
|
// Read the scope from inside the declaration: the leg only exists while the
|
|
229
302
|
// call it names is being made.
|
|
@@ -237,8 +310,13 @@ t.test('a call inside a flow is named for the caller that makes it', t => {
|
|
|
237
310
|
);
|
|
238
311
|
t.equal(
|
|
239
312
|
seen.identity.leg,
|
|
240
|
-
'
|
|
241
|
-
'the leg
|
|
313
|
+
'party.subject.find',
|
|
314
|
+
'the leg is the method the call is made by — what a diagram labels the arrow with',
|
|
315
|
+
);
|
|
316
|
+
t.equal(
|
|
317
|
+
seen.context.legFrom,
|
|
318
|
+
'access.db',
|
|
319
|
+
'and the unit that made it is its own identity, not part of the label',
|
|
242
320
|
);
|
|
243
321
|
t.match(String(seen.identity.seq), /^[0-9]+$/, 'and takes the first position in the caller');
|
|
244
322
|
t.equal(
|
|
@@ -248,8 +326,8 @@ t.test('a call inside a flow is named for the caller that makes it', t => {
|
|
|
248
326
|
);
|
|
249
327
|
t.match(
|
|
250
328
|
meta.forward?.['x-semantic-trace'] ?? '',
|
|
251
|
-
/leg=
|
|
252
|
-
'and the callee is handed the call it is part of',
|
|
329
|
+
/leg=party\.subject\.find,from=access\.db,to=party/,
|
|
330
|
+
'and the callee is handed the call it is part of, both ends stated',
|
|
253
331
|
);
|
|
254
332
|
t.end();
|
|
255
333
|
});
|
|
@@ -259,7 +337,11 @@ t.test('a call declared without a meta still binds the caller scope', t => {
|
|
|
259
337
|
const leg = runInFlow(meta, 'm', () =>
|
|
260
338
|
declareCall('access.db', 'party.subject.find', () => currentIdentity().leg),
|
|
261
339
|
);
|
|
262
|
-
t.equal(
|
|
340
|
+
t.equal(
|
|
341
|
+
leg,
|
|
342
|
+
'party.subject.find',
|
|
343
|
+
'the leg is bound whether or not there is a meta to publish on',
|
|
344
|
+
);
|
|
263
345
|
t.end();
|
|
264
346
|
});
|
|
265
347
|
|
|
@@ -281,9 +363,17 @@ t.test('the scope a caller is in can be asked about', t => {
|
|
|
281
363
|
|
|
282
364
|
t.test('a flow records calls unless something decided otherwise', t => {
|
|
283
365
|
configureCallTrace(undefined);
|
|
284
|
-
t.equal(
|
|
366
|
+
t.equal(
|
|
367
|
+
callsFor(undefined, 'access.access.find'),
|
|
368
|
+
true,
|
|
369
|
+
'nothing decided and nothing configured',
|
|
370
|
+
);
|
|
285
371
|
configureCallTrace({enabled: false});
|
|
286
|
-
t.equal(
|
|
372
|
+
t.equal(
|
|
373
|
+
callsFor(undefined, 'access.access.find'),
|
|
374
|
+
false,
|
|
375
|
+
'the configuration can opt everything out',
|
|
376
|
+
);
|
|
287
377
|
configureCallTrace({off: ['payment']});
|
|
288
378
|
t.equal(callsFor(undefined, 'payment.transfer.prepare'), false, 'or one entry');
|
|
289
379
|
configureCallTrace(undefined);
|
package/src/semanticContext.ts
CHANGED
|
@@ -18,15 +18,15 @@
|
|
|
18
18
|
* - **A participant is an RPC namespace** — `party`, `db`, `subject`. It is what
|
|
19
19
|
* a call targets, what becomes a Kubernetes service, and what the emitter
|
|
20
20
|
* derives for a record from the method name, so the two agree by construction.
|
|
21
|
-
* - **A leg is one call site**, named
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* caller
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
21
|
+
* - **A leg is one call site**, named by the method the call reaches its callee with —
|
|
22
|
+
* `party.subject.find`, or `db/party.subject.find` once blong's destination rewrite has
|
|
23
|
+
* prefixed the namespace it forwards to. The unit that made the call travels beside it as
|
|
24
|
+
* its own identity (`legFrom`, with `legTo` naming the receiver it aimed at), because a
|
|
25
|
+
* diagram draws the ends from the identity and the label from the method: repeating the
|
|
26
|
+
* caller in the label said what the arrow already said, and made every label longer than
|
|
27
|
+
* the arrow it sat on (D-249). The caller is the logical unit the port answers for — never
|
|
28
|
+
* the process it runs in — and the grammar's charset is why a `/` is lawful in a method
|
|
29
|
+
* and not in a participant name.
|
|
30
30
|
*
|
|
31
31
|
* ## Propagation, and where it rides
|
|
32
32
|
*
|
|
@@ -118,6 +118,29 @@ export const enterCapability = vocabulary.enterCapability;
|
|
|
118
118
|
*/
|
|
119
119
|
export const withStep = step;
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Report a progress point, or take a branch, from code that holds no `$meta`.
|
|
123
|
+
*
|
|
124
|
+
* Re-exported beside `withStep` for the same reason it is: a realm or a bootstrap that already
|
|
125
|
+
* reaches this module for its scope machinery should not have to import the emitter to say what
|
|
126
|
+
* the logic was doing (PRD R26). Both degrade when nothing is attached — `point` to nothing,
|
|
127
|
+
* `decide` to the selection alone, because a branch has to be taken either way.
|
|
128
|
+
*/
|
|
129
|
+
export const point = vocabulary.point;
|
|
130
|
+
export const decide = vocabulary.decide;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Take the progress announced in the scope a handler ran in, and announce it again.
|
|
134
|
+
*
|
|
135
|
+
* Both halves of one thing, and both used by the same caller: the adapter reads what its
|
|
136
|
+
* handler announced when the handler returns (`takeProgress`) and stages it so the record the
|
|
137
|
+
* call answers with carries it (`attachProgress`) — the only route a realm handler's points
|
|
138
|
+
* and branches have to a record, since the records of a call are the framework's (PRD R26/R27).
|
|
139
|
+
*/
|
|
140
|
+
export const takeProgress = vocabulary.takeProgress;
|
|
141
|
+
export const attachProgress = vocabulary.attachProgress;
|
|
142
|
+
export const beginProgress = vocabulary.beginProgress;
|
|
143
|
+
|
|
121
144
|
/** The capability decided for this scope, or `undefined` when nothing decided it. */
|
|
122
145
|
export const capabilityOf = vocabulary.capabilityState;
|
|
123
146
|
|
|
@@ -157,12 +180,6 @@ export function inboundIdentities(meta: IMeta | undefined): Identities {
|
|
|
157
180
|
return readIdentities((meta?.forward ?? {}) as Record<string, unknown>);
|
|
158
181
|
}
|
|
159
182
|
|
|
160
|
-
/** A leg id as the emitter's grammar allows it: `<caller>.<method>`, flattened. */
|
|
161
|
-
export function legIdFor(caller: string, method: string): string {
|
|
162
|
-
const flatten = (text: string): string => text.replace(/[^A-Za-z0-9._-]/g, '-');
|
|
163
|
-
return `${flatten(caller)}.${flatten(method)}`;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
183
|
/** The participant a call targets: the first segment of its method. */
|
|
167
184
|
export function namespaceOf(method: string): string {
|
|
168
185
|
const at = method.search(/[./]/);
|
|
@@ -317,22 +334,25 @@ export function adoptInbound<T>(meta: IMeta, kind: string, fn: () => T): T {
|
|
|
317
334
|
* emitter refuses to invent an execution to hold a leg (D3) and inventing one here
|
|
318
335
|
* would put a lie in the data.
|
|
319
336
|
*
|
|
320
|
-
* `caller` is the
|
|
321
|
-
*
|
|
322
|
-
*
|
|
337
|
+
* `caller` is the **logical unit** that makes the call — the namespace a port serves, not its
|
|
338
|
+
* port id and not the process it runs in — and `method` is the wire name the call is being made
|
|
339
|
+
* by, including any destination the caller has already applied, so the declaration matches the
|
|
340
|
+
* receiver the call will actually reach. The two travel as separate identities: the method is
|
|
341
|
+
* the label a diagram draws on the arrow, the caller is the arrow's source, and repeating the
|
|
342
|
+
* caller in the label said what the arrow already said (D-249).
|
|
323
343
|
*/
|
|
324
344
|
export function declareCall<T>(caller: string, method: string, fn: () => T, meta?: IMeta): T {
|
|
325
345
|
if (currentContext().flow === undefined) {
|
|
326
346
|
return fn();
|
|
327
347
|
}
|
|
328
|
-
const id =
|
|
348
|
+
const id = method;
|
|
329
349
|
const to = namespaceOf(method);
|
|
330
|
-
if (!isLegId(id) || !isServiceName(to)) {
|
|
331
|
-
// A
|
|
350
|
+
if (!isLegId(id) || !isServiceName(caller) || !isServiceName(to)) {
|
|
351
|
+
// A caller or a method the grammar cannot hold: the call still runs, it is
|
|
332
352
|
// simply not named. Throwing here would fail a request over its telemetry.
|
|
333
353
|
return fn();
|
|
334
354
|
}
|
|
335
|
-
return bindLeg({id, to}, () => {
|
|
355
|
+
return bindLeg({id, from: caller, to}, () => {
|
|
336
356
|
if (meta !== undefined) {
|
|
337
357
|
meta.forward = {...meta.forward, ...identityHeaders()};
|
|
338
358
|
}
|