@feasibleone/blong-gogo 1.35.0 → 1.37.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 CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.37.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.36.0...blong-gogo-v1.37.0) (2026-09-26)
4
+
5
+
6
+ ### Features
7
+
8
+ * progress points in test reports ([fb73036](https://github.com/feasibleone/blong/commit/fb730360877eb371ab3d1603ae7bf35976c170dc))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * chain dynamic import ([7db222f](https://github.com/feasibleone/blong/commit/7db222f0a6469b624fa1935027f63b0593585ee6))
14
+ * misc CI failures ([950289d](https://github.com/feasibleone/blong/commit/950289d65e6187d10658dd0fd76361bd4d3d56e8))
15
+
16
+ ## [1.36.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.35.0...blong-gogo-v1.36.0) (2026-09-24)
17
+
18
+
19
+ ### Features
20
+
21
+ * add from property to leg ([4925e76](https://github.com/feasibleone/blong/commit/4925e76a7d69000e83804cd9a5fa1ea58ab26d81))
22
+ * docs screenshots and diagrams ([65e37d9](https://github.com/feasibleone/blong/commit/65e37d93ef3e71d65b340541e14b50a25f14e3c8))
23
+ * sequence diagram progress points ([dc5b153](https://github.com/feasibleone/blong/commit/dc5b15327f31698a26a75ec73bdf9a0956a43dd9))
24
+
25
+
26
+ ### Bug Fixes
27
+
28
+ * build errors ([145a424](https://github.com/feasibleone/blong/commit/145a424ffc2dc4dfc14801f1002c7fa1e7a543ce))
29
+
3
30
  ## [1.35.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.34.0...blong-gogo-v1.35.0) (2026-09-21)
4
31
 
5
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feasibleone/blong-gogo",
3
- "version": "1.35.0",
3
+ "version": "1.37.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.5.0"
95
+ "@feasibleone/blong-dev": "1.7.0"
95
96
  },
96
97
  "scripts": {
97
98
  "browser-check": "node scripts/browser-compat-check.mjs",
@@ -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 {adoptInbound, inboundIdentities, isFlowId} from './semanticContext.ts';
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
- if (isFlowId(inbound.flow) && inbound.leg !== undefined) {
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
- return run();
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
@@ -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
  }),
@@ -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 leg whose caller was
596
- // also called `gateway` drew the public surface and that
597
- // namespace as one participant, and a realm answering a call
598
- // to its own namespace read as a self-call. A leg id names a
599
- // unit, and the API surface is a unit of its own - not the
600
- // process either: one process hosts a whole suite, so naming
601
- // the process here would draw every flow of a monolith as the
602
- // same participant.
603
- // `methodName` is already the dotted wire name the leg
604
- // grammar wants, so the leg names the method the request
605
- // asked for. The callee's receipt then carries the same leg,
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(
@@ -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
@@ -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: attachCheckpoint ? nodeAssert : undefined,
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/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
  }
@@ -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
@@ -276,7 +324,17 @@ t.test('every level is bound, and emits at its own level', async t => {
276
324
  // `fatal` is written synchronously, so it is staged rather than stored: it is
277
325
  // the one level whose record survives an exit that drains no I/O, and it
278
326
  // reaches the store on the next open. The other five take the queued path.
279
- 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
+ );
280
338
  const staged = readFileSync(cachePaths.sidecarFile(dir), 'utf8')
281
339
  .trim()
282
340
  .split('\n')
@@ -284,8 +342,8 @@ t.test('every level is bound, and emits at its own level', async t => {
284
342
  .map(entry => JSON.parse(entry.json) as {levelName: string});
285
343
  t.same(
286
344
  staged.map(entry => entry.levelName),
287
- ['fatal'],
288
- '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',
289
347
  );
290
348
  await cache.close();
291
349
  });
@@ -329,13 +387,14 @@ t.test('a record made inside a flow names the flow, the call and the position',
329
387
  );
330
388
  const [id] = await cacheRecordIds(dir);
331
389
  const record = (await log.store.get(id ?? '')) as unknown as {
332
- flow?: {id?: string; leg?: string; legTo?: string; legSeq?: string};
390
+ flow?: {id?: string; leg?: string; legFrom?: string; legTo?: string; legSeq?: string};
333
391
  };
334
392
  t.equal(
335
393
  record.flow?.leg,
336
- 'access.db.party.subject.find',
337
- 'the record names the call it was made in',
394
+ 'party.subject.find',
395
+ 'the record names the call it was made in, by the method it was made by',
338
396
  );
397
+ t.equal(record.flow?.legFrom, 'access.db', 'and the unit that made it, beside the method');
339
398
  t.equal(record.flow?.legTo, 'party', 'and the participant the caller aimed at');
340
399
  t.match(String(record.flow?.legSeq), /^[0-9]+$/, 'and its position in the execution');
341
400
  t.equal(isFlowId(record.flow?.id), true, 'and the execution it is part of');
@@ -382,8 +441,8 @@ t.test('the records of a flow reach the cluster service and come back as a diagr
382
441
  t.equal(diagram?.statusCode, 200, 'the execution can be drawn from the records alone');
383
442
  t.match(
384
443
  diagram?.body ?? '',
385
- /access\.db\.party\.subject\.find/,
386
- 'and the drawing names the call the caller declared',
444
+ /party\.subject\.find/,
445
+ 'and the drawing names the call the caller declared, by its method',
387
446
  );
388
447
  });
389
448
 
@@ -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.ts CHANGED
@@ -22,6 +22,12 @@ import type {Dirent} from 'node:fs';
22
22
  import merge from 'ut-function.merge';
23
23
 
24
24
  import layerProxy from './layerProxy.ts';
25
+ // The chain is server-side code — it pulls `node:assert` and the event emitter the test
26
+ // executor is built on — so only its types may be imported statically: a value import
27
+ // puts the chain in the browser's eager module graph, where linking its `node:events`
28
+ // import fails before a single handler runs. The Allure session is ended through a
29
+ // dynamic import for the same reason.
30
+ import type {IAllureRunOptions} from './chain.ts';
25
31
  import {runInFlow, withStep} from './semanticContext.ts';
26
32
 
27
33
  export interface IWatch {
@@ -82,6 +88,8 @@ interface IConfig {
82
88
  ignored: string[];
83
89
  configs: string[];
84
90
  logLevel: Parameters<ILog['logger']>[0];
91
+ /** Allure reporting options, as the framework config supplies them. */
92
+ allure?: IAllureRunOptions;
85
93
  }
86
94
 
87
95
  // ---------------------------------------------------------------------------
@@ -115,6 +123,7 @@ export default class Watch extends Internal implements IWatch {
115
123
  ignored: [],
116
124
  configs: [],
117
125
  logLevel: 'debug',
126
+ allure: undefined,
118
127
  };
119
128
 
120
129
  #handlerFolders: Map<string, {name: string; pkg: IModuleConfig['pkg']; base: string}> =
@@ -739,10 +748,6 @@ export default class Watch extends Internal implements IWatch {
739
748
  test?: unknown;
740
749
  };
741
750
  try {
742
- const chain = await (
743
- await import('./chain.ts')
744
- ).default(test as ITestContext, this.log);
745
-
746
751
  const groups = ([] as string[]).concat(this.#config.test);
747
752
  let executed = 0;
748
753
  await withProgress(
@@ -763,6 +768,22 @@ export default class Watch extends Internal implements IWatch {
763
768
  // were then made in no flow at all, and the observed flow the
764
769
  // run was supposed to leave behind never existed.
765
770
  const meta: IMeta = {mtid: 'event', method};
771
+ // The chain is built per group because it carries the
772
+ // group's wire name: Allure labels come from it, and the
773
+ // result's own name is the group's display name, which is
774
+ // set on the steps just below.
775
+ const chain = await (
776
+ await import('./chain.ts')
777
+ ).default(test as ITestContext, this.log, {
778
+ method,
779
+ allure: this.#config.allure,
780
+ // The Allure integration writes files, which a browser
781
+ // platform has nowhere to do: the platform is handed over
782
+ // so the chain can explain the report a browser run asked
783
+ // for and cannot have, instead of failing to import its
784
+ // way to the same end once per group.
785
+ platform: this.#platform.platform,
786
+ });
766
787
  await runInFlow(meta, method, async () => {
767
788
  const steps = await remote.remote(method)({}, meta);
768
789
  if (Array.isArray(steps) && !('name' in steps)) {
@@ -795,6 +816,14 @@ export default class Watch extends Internal implements IWatch {
795
816
  this.log?.error?.(error);
796
817
  done?.(error);
797
818
  return;
819
+ } finally {
820
+ // The results directory belongs to the run, not to any one group,
821
+ // so the session closes once every group has reported. The chain is
822
+ // imported here rather than at the top of the file so that a
823
+ // platform which never runs a group never links it.
824
+ await (await import('./chain.ts'))
825
+ .endAllureSession()
826
+ .catch(error => this.log?.error?.(error));
798
827
  }
799
828
  done?.();
800
829
  });
@@ -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
+ });