@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/src/load.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  type SolutionFactory,
19
19
  } from '@feasibleone/blong/types';
20
20
 
21
- import {withProgress} from '@feasibleone/blong-lib';
21
+ import {SLOW_STEP_MS, withProgress} from '@feasibleone/blong-lib';
22
22
  import {WELL_KNOWN_LAYERS} from '@feasibleone/blong-lib/layers';
23
23
  import {Type, type TSchema} from 'typebox';
24
24
  import merge from 'ut-function.merge';
@@ -216,6 +216,42 @@ async function discoverRealmTestMethods(
216
216
  return methods;
217
217
  }
218
218
 
219
+ /**
220
+ * The margin a step of a load is measured against when the realm names none.
221
+ *
222
+ * Read off the log config (`log.slowMs`) so a realm or a runner can raise it for
223
+ * a machine that is legitimately slow, and so one number tunes both halves of a
224
+ * delayed start: the loader's steps and the retention store's own.
225
+ */
226
+ function slowStepMs(log: unknown): number {
227
+ const configured = (log as {slowMs?: unknown} | undefined)?.slowMs;
228
+ return typeof configured === 'number' && configured >= 0 ? configured : SLOW_STEP_MS;
229
+ }
230
+
231
+ /**
232
+ * A logger for the steps that run before the realm's own log exists.
233
+ *
234
+ * A root load builds the log component first, and until it is up there is nothing
235
+ * to log through — which is exactly when a slow step is least visible and most
236
+ * confusing, because the process prints nothing at all for as long as it takes.
237
+ * This writes to stderr, where the framework's other startup messages go, and
238
+ * stays silent in a platform that has no process (the browser bundle). Only
239
+ * `warn` is answered: a step that is not slow has nothing to say, and the
240
+ * loader's ordinary progress belongs in the log the realm is about to have.
241
+ */
242
+ function bootstrapLogger(): {warn: (...args: unknown[]) => void} {
243
+ return {
244
+ // Pino-shaped, like every other logger `withProgress` is handed: the
245
+ // details object first, the message second.
246
+ warn: (...args: unknown[]) => {
247
+ const [details, message] = args as [Record<string, unknown> | undefined, string];
248
+ const stderr = (globalThis as {process?: {stderr?: {write: (text: string) => void}}})
249
+ .process?.stderr;
250
+ stderr?.write(`warn blong ${message} ${JSON.stringify(details ?? {})}\n`);
251
+ },
252
+ };
253
+ }
254
+
219
255
  const System: symbol = Symbol('system');
220
256
 
221
257
  export function system(original: {[key: symbol]: boolean}): void {
@@ -232,12 +268,24 @@ function activeConfigs<T extends TSchema>(
232
268
  platformConfigs: string[],
233
269
  ): (boolean | object)[] {
234
270
  return (
235
- (['default'] as string[])
236
- .concat(configNames)
237
- .concat(platformConfigs)
238
- .map(name => (mod.config as unknown as Record<string, unknown>)?.[name])
239
- .filter(Boolean) as (boolean | object)[]
240
- ).concat({pkg: mod.pkg, children: mod.children, url: mod.url});
271
+ (
272
+ (['default'] as string[])
273
+ .concat(configNames)
274
+ .concat(platformConfigs)
275
+ // A CI run *is* a captured run, so the `ci` block applies whether or not the
276
+ // entry named the intent — the same reason `exit` is derived from `isCI()`
277
+ // in the synthetic config. Which entry happened to know about CI decided
278
+ // whether a package's tap leg wrote Allure results at all, and a package
279
+ // whose only runner is tap then had no report to link.
280
+ //
281
+ // Named last, which is where a caller that passes it puts it: the Playwright
282
+ // webServer passes `ci` after `playwright` so its colours win over the block's
283
+ // `log.color = false`.
284
+ .concat(isCI() ? ['ci'] : [])
285
+ .map(name => (mod.config as unknown as Record<string, unknown>)?.[name])
286
+ .filter(Boolean) as (boolean | object)[]
287
+ ).concat({pkg: mod.pkg, children: mod.children, url: mod.url})
288
+ );
241
289
  }
242
290
 
243
291
  /** A thenable value that can be externally resolved/rejected. */
@@ -449,7 +497,15 @@ export default async function loadRealm<T extends TSchema>(
449
497
  // always false, and every framework realm is silently absent: the gateway then
450
498
  // refuses every unauthenticated route (no login realm) and RBAC resolves nothing.
451
499
  const isPlatformRoot = api === undefined;
452
- const mod = await def({type: Type, manifest});
500
+ const bootstrap = bootstrapLogger();
501
+ // A factory may answer with the module or with a promise for it, so the
502
+ // measurement wraps whatever it answered rather than assuming the shape.
503
+ const mod = await withProgress(
504
+ bootstrap,
505
+ `suite ${name}`,
506
+ Promise.resolve(def({type: Type, manifest})),
507
+ {slowMs: SLOW_STEP_MS},
508
+ );
453
509
  // Record the realm's own file in the tree's set, so a realm reached twice -
454
510
  // once as a suite's child and once by the framework's `frameworkRealms` - is
455
511
  // recognised the second time. A realm answers with its *factory*, so its url
@@ -489,6 +545,26 @@ export default async function loadRealm<T extends TSchema>(
489
545
  default: {
490
546
  watch: {
491
547
  test: [],
548
+ /**
549
+ * Allure reporting for handler tests. Off by default: a report is
550
+ * something a run is asked for, which is what `--watch.allure.enabled=true`
551
+ * on the blong CLI does for a dev run and what the `ci` block below does
552
+ * for a captured one. `blong-allure` is a dependency of this package, so
553
+ * the CLI that turns the results into HTML is the one they were written
554
+ * for rather than whatever is on the runner's PATH.
555
+ *
556
+ * The results have a directory of their own because a package can report
557
+ * to Allure from two producers — the browser tests of a realm and its
558
+ * handler tests — and each clears the directory it writes to before a run.
559
+ * `blong-dev` merges every producer's directory into the one report a
560
+ * package publishes.
561
+ */
562
+ allure: {
563
+ enabled: false,
564
+ outputDir: 'allure-results-tap',
565
+ historyPath: '.allure/history.jsonl',
566
+ generateOnEnd: false,
567
+ },
492
568
  },
493
569
  // The server's log implementation. The browser platform
494
570
  // keeps its own logger regardless: the loader checks the
@@ -522,7 +598,7 @@ export default async function loadRealm<T extends TSchema>(
522
598
  // The semantic implementation's store, and the pino
523
599
  // implementation's cache beside it: they share one
524
600
  // directory and one key space, so a
525
- // `semantic-log://record/<id>` reference resolves
601
+ // `semlog://r/<id>` reference resolves
526
602
  // whichever implementation wrote the entry.
527
603
  cache: {
528
604
  dir: '~/.blong/log-cache',
@@ -606,9 +682,22 @@ export default async function loadRealm<T extends TSchema>(
606
682
  /**
607
683
  * A run whose output is captured rather than watched. The
608
684
  * Playwright webServer passes this last in CI, so it wins over the
609
- * `playwright` block's colours.
685
+ * `playwright` block's colours. A captured run is also the run whose
686
+ * report nobody is watching, so its Allure results are written and
687
+ * its HTML report generated at the end.
688
+ *
689
+ * On CI the block applies whether or not the intent was named: the
690
+ * environment already says the output is captured, and an entry that
691
+ * has to remember to pass `ci` for its package to have a report is a
692
+ * trap for the next package (`activeConfigs`).
610
693
  */
611
- ci: {log: {color: false}},
694
+ ci: {
695
+ log: {color: false},
696
+ // Results, not a report: `blong-dev` turns every producer's results
697
+ // into the one report a package publishes, so generating here would
698
+ // be the same work again, into a directory nobody uploads.
699
+ watch: {allure: {enabled: true}},
700
+ },
612
701
  /**
613
702
  * A deployed process never runs the service itself: the
614
703
  * service is deployed once, beside the processes whose
@@ -806,7 +895,7 @@ export default async function loadRealm<T extends TSchema>(
806
895
  });
807
896
  }
808
897
  loadedConfigs.push(...activeConfigs(mod, configNames, platformApi.configs));
809
- const {loadedConfig: mergedConfig, configRuntime} = await platformApi.loadConfig(
898
+ const configPromise = platformApi.loadConfig(
810
899
  {
811
900
  name,
812
901
  pkg: {name, version: '0.0.0'},
@@ -834,6 +923,14 @@ export default async function loadRealm<T extends TSchema>(
834
923
  parentConfig,
835
924
  loadedConfigs.filter(Boolean) as object[],
836
925
  );
926
+ // The config is what says how slow a step may be (`log.slowMs`), so it cannot
927
+ // itself be measured against a configured margin — only against the default.
928
+ const {loadedConfig: mergedConfig, configRuntime} = await withProgress(
929
+ bootstrap,
930
+ `config ${name}`,
931
+ configPromise,
932
+ {slowMs: SLOW_STEP_MS},
933
+ );
837
934
 
838
935
  // Populate the manifest from config values (e.g. `--manifest.gatewayPort=8080`
839
936
  // parsed by blong-config into `mergedConfig.manifest.gatewayPort`).
@@ -866,6 +963,12 @@ export default async function loadRealm<T extends TSchema>(
866
963
  context: `${defKind}`,
867
964
  },
868
965
  );
966
+ // From here on every step of the load is measured against the margin the
967
+ // config names, and a step that crosses it is reported at warn level through
968
+ // whichever logger exists — the realm's own, or the bootstrap one while the
969
+ // log component is still being built.
970
+ const slowMs = slowStepMs(mergedConfig.log);
971
+ const stepLog = () => logger ?? bootstrap;
869
972
  if (typeof parentConfig === 'string' && mergedConfig.watch)
870
973
  mergedConfig.watch.configs = mergedConfig.configs;
871
974
 
@@ -891,11 +994,11 @@ export default async function loadRealm<T extends TSchema>(
891
994
  const explicitChildren = new Set(
892
995
  children.filter(c => typeof c === 'string').map(c => platformApi.basename(c as string)),
893
996
  );
894
- const discoveredFolders = await discoverLayerFolders(
895
- platformApi,
896
- base,
897
- rootKind,
898
- explicitChildren,
997
+ const discoveredFolders = await withProgress(
998
+ stepLog(),
999
+ `discover layers of ${name}`,
1000
+ discoverLayerFolders(platformApi, base, rootKind, explicitChildren),
1001
+ {slowMs},
899
1002
  );
900
1003
  for (const [folderName, activation] of discoveredFolders) {
901
1004
  if (!(folderName in mergedConfig))
@@ -1300,12 +1403,22 @@ export default async function loadRealm<T extends TSchema>(
1300
1403
  );
1301
1404
  item = async () => loaded.filter(Boolean);
1302
1405
  }
1303
- const loadedModules = await (item as () => Promise<unknown[]>)();
1406
+ // The import and the start are separate steps with separate costs: an
1407
+ // import is the module graph, which is slow on a cold filesystem, while
1408
+ // a start is the component's own init. A delayed start that names only
1409
+ // "load log" would not say which of the two to look at, so the two are
1410
+ // reported under labels that do.
1411
+ const loadedModules = await withProgress(
1412
+ stepLog(),
1413
+ `import ${itemName}`,
1414
+ (item as () => Promise<unknown[]>)(),
1415
+ {slowMs},
1416
+ );
1304
1417
  const modules = Array.isArray(loadedModules) ? loadedModules : [loadedModules];
1305
1418
  let loadedCount = 0;
1306
1419
  await withProgress(
1307
- logger,
1308
- `load ${itemName}`,
1420
+ stepLog(),
1421
+ `start ${itemName}`,
1309
1422
  (async () => {
1310
1423
  for (const module of modules) {
1311
1424
  const item = await module;
package/src/mle.ts CHANGED
@@ -94,7 +94,7 @@ export default fp<IConfig>(async function mlePlugin(fastify: FastifyInstance, co
94
94
  jsonrpc?: unknown;
95
95
  result?: Record<string, unknown>;
96
96
  error?: Record<string, unknown>;
97
- checkpoints?: unknown;
97
+ progress?: unknown;
98
98
  },
99
99
  ) => {
100
100
  if (isPublic(request.originalUrl)) return payload;
@@ -119,7 +119,7 @@ export default fp<IConfig>(async function mlePlugin(fastify: FastifyInstance, co
119
119
  id: undefined,
120
120
  jsonrpc: undefined,
121
121
  error: undefined,
122
- checkpoints: undefined,
122
+ progress: undefined,
123
123
  };
124
124
  let result,
125
125
  error = undefined as string | undefined;
@@ -141,7 +141,7 @@ export default fp<IConfig>(async function mlePlugin(fastify: FastifyInstance, co
141
141
  jsonrpc: where.jsonrpc,
142
142
  result: result && (await encrypt(Buffer.from(result))),
143
143
  error: error && (await encrypt(Buffer.from(error))),
144
- checkpoints: where.checkpoints,
144
+ progress: where.progress,
145
145
  });
146
146
  } catch (error) {
147
147
  reply.code(400);
@@ -1,5 +1,5 @@
1
1
  import {orchestrator, type IMeta} from '@feasibleone/blong/types';
2
- import {legName, recordedCall} from '../../lib.ts';
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 spelled as every other leg is, `<caller>.<target>.<method>`:
39
- // slash routing is the wire form only, and a `/` in an id is flattened to `-`
40
- // by the leg grammar, which would hide the target the arrow names.
41
- const declared = legName(forwarded);
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}),
@@ -44,8 +44,48 @@ export interface CacacheTransportOptions {
44
44
  const RETENTION_STATE_KEY = '__blong_retention_state__';
45
45
  const ONE_DAY_MS = 24 * 60 * 60 * 1000;
46
46
 
47
+ /**
48
+ * `cacache.rm.entry` with its third argument, which the published types do not
49
+ * declare.
50
+ *
51
+ * `removeFully` is real: `cacache`'s own `lib/rm.js` hands the options to
52
+ * `index.delete`, which removes the key's index file rather than appending a
53
+ * deletion to it. The types shipped for `cacache` 20 model only the two-argument
54
+ * form, so the option is declared here once instead of being cast at the call.
55
+ */
56
+ const removeEntryFully = cacache.rm.entry as unknown as (
57
+ cachePath: string,
58
+ key: string,
59
+ opts: {removeFully?: boolean},
60
+ ) => Promise<unknown>;
61
+
62
+ /**
63
+ * How long a retention step may take before it is reported.
64
+ *
65
+ * The transport runs in a pino worker thread with no logger of its own, so it
66
+ * writes to stderr — which is where the loader's own messages go, and where a
67
+ * reader of the process's output will see it. Matches the framework's margin for
68
+ * a slow step: a retention pass that took longer is the kind of delay that has
69
+ * cost a start its first seconds.
70
+ */
71
+ const SLOW_STEP_MS = 1_000;
72
+
73
+ function reportSlow(step: string, started: number, details?: Record<string, unknown>): void {
74
+ const elapsedMs = Date.now() - started;
75
+ if (elapsedMs < SLOW_STEP_MS) {
76
+ return;
77
+ }
78
+ process.stderr.write(
79
+ `warn pino-cacache ${step} took ${elapsedMs}ms` +
80
+ (details ? ` ${JSON.stringify(details)}` : '') +
81
+ '\n',
82
+ );
83
+ }
84
+
47
85
  async function pruneOldEntries(cachePath: string, retentionCount: number): Promise<void> {
86
+ const scanStarted = Date.now();
48
87
  const index = await cacache.ls(cachePath);
88
+ reportSlow('index scan', scanStarted, {entries: Object.keys(index).length});
49
89
 
50
90
  // Collect all real log entries (skip the retention-state entry itself)
51
91
  const entries = Object.values(index).filter(e => e.key !== RETENTION_STATE_KEY);
@@ -61,13 +101,25 @@ async function pruneOldEntries(cachePath: string, retentionCount: number): Promi
61
101
  return ta - tb;
62
102
  });
63
103
 
104
+ const pruneStarted = Date.now();
64
105
  const toDelete = entries.slice(0, entries.length - retentionCount);
65
106
  for (const entry of toDelete) {
66
- await cacache.rm.entry(cachePath, entry.key);
107
+ // `removeFully` deletes the entry's index file. Without it `cacache`
108
+ // appends a deletion and keeps the file, so the index grows one file per
109
+ // entry ever written — and `cacache.ls` above, which reads all of them,
110
+ // becomes the slowest thing a process does. See
111
+ // `core/semantic-log/src/cache.ts`, which shares this directory and makes
112
+ // the same call.
113
+ await removeEntryFully(cachePath, entry.key, {removeFully: true});
67
114
  }
68
115
 
69
116
  // Garbage-collect content that is no longer referenced by any index entry
117
+ const verifyStarted = Date.now();
70
118
  await cacache.verify(cachePath);
119
+ reportSlow('retention pass', pruneStarted, {
120
+ pruned: toDelete.length,
121
+ verifyMs: Date.now() - verifyStarted,
122
+ });
71
123
  }
72
124
 
73
125
  async function retentionCheckRun(cachePath: string, retentionCount: number): Promise<void> {
@@ -100,8 +152,12 @@ export default async function transport(options: CacacheTransportOptions) {
100
152
  const cachePath = resolveHome(rawCachePath);
101
153
 
102
154
  // Run retention check once on transport startup (at most once per day)
103
- retentionCheckRun(cachePath, retentionCount).catch(() => {
104
- // Retention errors must not crash the transport
155
+ retentionCheckRun(cachePath, retentionCount).catch((error: unknown) => {
156
+ // Retention errors must not crash the transport — but a retention that
157
+ // fails is why a cache grows without bound, so it is reported rather than
158
+ // swallowed: silently skipping the pass is indistinguishable from running
159
+ // it and finding nothing to do.
160
+ process.stderr.write(`warn pino-cacache retention failed: ${String(error)}\n`);
105
161
  });
106
162
 
107
163
  return build(async function (source) {
@@ -46,7 +46,7 @@ export default (options: PinoPretty.PrettyOptions) =>
46
46
  $meta,
47
47
  } = log;
48
48
  return [
49
- id && colors.dim(`semantic-log://record/${id}`),
49
+ id && colors.dim(`semlog://r/${id}`),
50
50
  context && colors.greenBright(context),
51
51
  prefix,
52
52
  $meta?.mtid && colors.magenta($meta.mtid),
@@ -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
+ });