@feasibleone/blong-gogo 1.38.0 → 1.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/package.json +7 -7
  3. package/src/ConfigRuntime.ts +8 -1
  4. package/src/Gateway.ts +77 -13
  5. package/src/Log.ts +83 -25
  6. package/src/Registry.ts +22 -0
  7. package/src/ResolutionK8s.ts +71 -0
  8. package/src/SemanticLog.test.ts +14 -4
  9. package/src/SemanticLog.ts +22 -2
  10. package/src/SystemDebug.test.ts +83 -0
  11. package/src/SystemDebug.ts +29 -2
  12. package/src/acl.test.ts +74 -0
  13. package/src/adapter/schema/knex/types.ts +18 -0
  14. package/src/adapter/server/acl.ts +6 -1
  15. package/src/adapter/server/k8s.test.ts +189 -0
  16. package/src/adapter/server/k8s.ts +540 -172
  17. package/src/adapter/server/kafka.ts +100 -47
  18. package/src/adapter/server/knex-connection.test.ts +79 -0
  19. package/src/adapter/server/knex.ts +70 -12
  20. package/src/adapter/server/pathRefresh.test.ts +26 -0
  21. package/src/adapter/server/pathRefresh.ts +30 -6
  22. package/src/chain.allure.test.ts +55 -0
  23. package/src/chain.ts +82 -6
  24. package/src/cliHelp.ts +7 -7
  25. package/src/codec/server.ts +5 -0
  26. package/src/devKeys.ts +2 -2
  27. package/src/exit.intent.test.ts +2 -2
  28. package/src/frameworkRealms.test.ts +74 -6
  29. package/src/gatewayKeys.test.ts +104 -0
  30. package/src/gatewayKeys.ts +68 -0
  31. package/src/grant.ts +8 -7
  32. package/src/intentsExclusion.test.ts +71 -0
  33. package/src/jwt.ts +2 -6
  34. package/src/lib.test.ts +46 -1
  35. package/src/lib.ts +31 -5
  36. package/src/load.ts +224 -9
  37. package/src/mle.test.ts +124 -0
  38. package/src/mle.ts +13 -0
  39. package/src/pino-pretty.test.ts +81 -0
  40. package/src/pino-pretty.ts +119 -16
  41. package/src/releaseRc.test.ts +92 -0
  42. package/src/runServer.test.ts +34 -2
  43. package/src/runServer.ts +10 -2
  44. package/src/semanticRecord.ts +20 -8
  45. package/src/static.test.ts +57 -0
  46. package/src/static.ts +13 -1
  47. package/template/.storybook/preview.tsx +8 -14
  48. package/template/meta/dbTest/accessAuthorizationMerge.yaml +14 -6
  49. package/template/meta/fixture/$subjectFixture.ts +11 -10
  50. package/template/src/stories/$Object.stories.tsx +4 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.39.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.38.1...blong-gogo-v1.39.0) (2026-10-11)
4
+
5
+
6
+ ### Features
7
+
8
+ * blong-kustomize ([0ad6915](https://github.com/feasibleone/blong/commit/0ad6915af0d3c300a8741a509232aec7649999a1))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * address CI issues ([83b8957](https://github.com/feasibleone/blong/commit/83b895773bb020e1e1424513ecffd3e15b13dec6))
14
+ * address CI issues ([d23b652](https://github.com/feasibleone/blong/commit/d23b652b79eebfbaf46716ee4d7e7381730b384e))
15
+ * address CI issues ([10cc9bf](https://github.com/feasibleone/blong/commit/10cc9bfe68f3f8bc40dba97eb627b303e1fe64b5))
16
+ * address CI issues ([a2ff403](https://github.com/feasibleone/blong/commit/a2ff4035086ecb726d9df08798dae4a5556af7dd))
17
+ * address CI issues ([5ce588b](https://github.com/feasibleone/blong/commit/5ce588b763bee75afc2fca603998252d41443da5))
18
+ * address CI issues ([d32fbb1](https://github.com/feasibleone/blong/commit/d32fbb10cc945db43eb492606792f63bfb848ae6))
19
+ * address CI issues ([bea0f62](https://github.com/feasibleone/blong/commit/bea0f62aba2dcef95cc4363b02c4ee3e05c620b1))
20
+ * address CI issues, improve snapshots ([a86310a](https://github.com/feasibleone/blong/commit/a86310a8317e28e610a33072a483afc83b4b5bd2))
21
+ * address latency issue ([7504310](https://github.com/feasibleone/blong/commit/750431029b7345100e4124675f36404b6e1693fa))
22
+ * kustomize layout ([1e0eb50](https://github.com/feasibleone/blong/commit/1e0eb506617862db0e5e19f33d18ee0121c6aa42))
23
+ * test ACL with cucumber ([3a84be9](https://github.com/feasibleone/blong/commit/3a84be99a1544dd7ae599cdd976191cf21cabba9))
24
+
25
+ ## [1.38.1](https://github.com/feasibleone/blong/compare/blong-gogo-v1.38.0...blong-gogo-v1.38.1) (2026-10-01)
26
+
27
+
28
+ ### Bug Fixes
29
+
30
+ * error handling ([fc1af53](https://github.com/feasibleone/blong/commit/fc1af536ae814a88a661341936bd9f9c519b38d1))
31
+
3
32
  ## [1.38.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.37.0...blong-gogo-v1.38.0) (2026-09-29)
4
33
 
5
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feasibleone/blong-gogo",
3
- "version": "1.38.0",
3
+ "version": "1.39.0",
4
4
  "repository": {
5
5
  "url": "git+https://github.com/feasibleone/blong.git"
6
6
  },
@@ -38,6 +38,7 @@
38
38
  "@feasibleone/blong-lib": "^1.0.0",
39
39
  "@feasibleone/blong-mle": "^1.0.0",
40
40
  "@feasibleone/blong-mock": "^1.0.0",
41
+ "@feasibleone/blong-template": "1.0.0",
41
42
  "@feasibleone/semantic-log": "^1.0.0",
42
43
  "@keycloak/keycloak-admin-client": "^26.6.2",
43
44
  "@kubernetes/client-node": "^1.4.0",
@@ -79,26 +80,25 @@
79
80
  "ut-function.interpolate": "1.1.3",
80
81
  "ut-function.merge": "^1.5.6",
81
82
  "uuid": "^13.0.2",
82
- "yaml": "^2.8.3",
83
- "@feasibleone/blong-template": "1.0.0"
83
+ "yaml": "^2.8.3"
84
84
  },
85
85
  "devDependencies": {
86
+ "@feasibleone/blong-dev": "1.9.0",
86
87
  "@rushstack/heft": "^1.2.6",
87
88
  "@rushstack/heft-lint-plugin": "^1.2.6",
88
89
  "@rushstack/heft-typescript-plugin": "^1.3.1",
89
90
  "@types/cacache": "^20.0.1",
90
91
  "@types/node": "^24",
91
- "playwright": "^1.60.0",
92
+ "playwright": "^1.63.0",
92
93
  "tap": "^21.6.3",
93
94
  "typescript": "^6.0.3",
94
- "vite": "^8.2.1",
95
- "@feasibleone/blong-dev": "1.7.1"
95
+ "vite": "^8.2.1"
96
96
  },
97
97
  "scripts": {
98
98
  "browser-check": "node scripts/browser-compat-check.mjs",
99
99
  "build": "true",
100
100
  "ci-coverage": "./run-coverage.sh",
101
- "ci-lint": "blong-dev lint",
101
+ "ci-lint": "blong-dev lint && node docker/manifests.mjs --check",
102
102
  "ci-publish": "node ../../common/scripts/install-run-rush-pnpm.js publish --access public --provenance",
103
103
  "ci-test": "blong-dev test",
104
104
  "test": "blong-dev test"
@@ -314,7 +314,14 @@ export default class ConfigRuntime implements IConfigRuntime {
314
314
  }
315
315
 
316
316
  private mergeConfigs(blongConfig: object): object {
317
- const loaded = loadBlong(blongConfig);
317
+ // The intents travel into `blong-config`, which reads them out of `env`. They decide which
318
+ // rc files a process reads at all — the *trailing* one is the suffix both source names are
319
+ // built from, the framework's existing convention — so a config step that cannot see them
320
+ // leaves every process reading the `dev` file whatever it was asked to be (Phase 15 E).
321
+ const intents = (this.#baseConfig as {configNames?: string[]}).configNames;
322
+ const loaded = loadBlong(
323
+ intents?.length ? {env: intents.join(','), ...blongConfig} : blongConfig,
324
+ );
318
325
  return merge({}, this.#baseConfig, ...this.#configs, loaded);
319
326
  }
320
327
 
package/src/Gateway.ts CHANGED
@@ -14,7 +14,12 @@ import type {
14
14
  IPlatformApi,
15
15
  } from '@feasibleone/blong/types';
16
16
  import {Internal} from '@feasibleone/blong/types';
17
- import fastify, {type FastifyReply, type FastifyRequest, type RouteOptions} from 'fastify';
17
+ import fastify, {
18
+ LogController,
19
+ type FastifyReply,
20
+ type FastifyRequest,
21
+ type RouteOptions,
22
+ } from 'fastify';
18
23
  import os from 'os';
19
24
  import type {LevelWithSilent} from 'pino';
20
25
  import {Type, type TSchema} from 'typebox';
@@ -23,6 +28,7 @@ import {v4} from 'uuid';
23
28
 
24
29
  import {TRACE_HEADER, withoutCapabilities} from '@feasibleone/semantic-log/capability';
25
30
  import {CALLS_CAPABILITY, GRANT_HEADER} from './callTrace.ts';
31
+ import {GENERATED_KEY_SPEC, GENERATED_PAIR_WARNING} from './gatewayKeys.ts';
26
32
  import {grantedCalls, grantKeyFrom} from './grant.ts';
27
33
  import {
28
34
  callsFor,
@@ -61,6 +67,17 @@ type GatewayRequest = FastifyRequest;
61
67
 
62
68
  const errorMap: IErrorMap = {
63
69
  'gateway.jwtMissingHeader': {message: 'Missing bearer authorization header', statusCode: 401},
70
+ // The method-level refusal of RBAC. Typed because a test that probes a denial
71
+ // on purpose declares it in `expect`, and the demotion matches on the type —
72
+ // an untyped `Error` is always logged at error level.
73
+ 'gateway.notAllowed': {
74
+ message: 'Authorization denied: method "{methodName}" not allowed',
75
+ statusCode: 403,
76
+ },
77
+ 'gateway.noActions': {
78
+ message: 'Authorization denied: no actions resolved',
79
+ statusCode: 403,
80
+ },
64
81
  };
65
82
 
66
83
  declare module 'fastify' {
@@ -229,6 +246,21 @@ export default class Gateway extends Internal implements IGateway {
229
246
  };
230
247
  });
231
248
  }
249
+
250
+ /**
251
+ * The browser build this process was configured to serve, as the merge resolved it.
252
+ *
253
+ * `config()` is protected on purpose: that subtree carries the key pair (`sign`, `encrypt`), the
254
+ * exposure D-448 keeps shut. *Which* static root arrived is a different question, and one a test
255
+ * could not ask anywhere — the plugin's own behaviour is covered (`static.test.ts`: it serves a
256
+ * bundle and tolerates a missing one), while nothing could see that a process's own block reaches
257
+ * this port at all. That leg is what a suite's `release` block naming the artifact's browser build
258
+ * depends on, and its failure is silent: the plugin is registered only when the config is there,
259
+ * so `/s` answers nothing while every other surface works (T-246).
260
+ */
261
+ public staticRoot(): string | undefined {
262
+ return this.#config.static?.root;
263
+ }
232
264
  #errorFields: [string, unknown][] = [];
233
265
  #plugins: {plugin: unknown; options: unknown}[] = [];
234
266
  #platform: IPlatformApi;
@@ -259,16 +291,22 @@ export default class Gateway extends Internal implements IGateway {
259
291
  this.#platform = platform!;
260
292
 
261
293
  this.merge(this.#config, config);
262
- this.#config.sign ||= (
263
- process.env.GATEWAY_SIGN_KEY
264
- ? {env: 'GATEWAY_SIGN_KEY'}
265
- : {generate: {alg: 'ES384', crv: 'P-384', use: 'sig'}}
266
- ) as IConfig['sign'];
267
- this.#config.encrypt ||= (
268
- process.env.GATEWAY_ENCRYPT_KEY
269
- ? {env: 'GATEWAY_ENCRYPT_KEY'}
270
- : {generate: {alg: 'ECDH-ES+A256KW', crv: 'P-384', use: 'enc'}}
271
- ) as IConfig['encrypt'];
294
+ // A pair reaches this config as ordinary configuration — the deployment's rc file
295
+ // (`gateway.sign` / `gateway.encrypt` under the pod's home), the `dev` block's committed
296
+ // literals, or a `--gateway.sign=…` flag — and a process given none generates its own. That
297
+ // last case is worth a warning rather than silence: two replicas of one suite would then
298
+ // mint tokens the other cannot verify, and the symptom is a rejected session somewhere else,
299
+ // long after the cause. A deployment that mounts the file never sees this line (D-441).
300
+ const generated = !this.#config.sign || !this.#config.encrypt;
301
+ this.#config.sign ||= GENERATED_KEY_SPEC.sign as IConfig['sign'];
302
+ this.#config.encrypt ||= GENERATED_KEY_SPEC.encrypt as IConfig['encrypt'];
303
+ if (generated) {
304
+ // The loader resolves the pair for every process that serves a gateway
305
+ // (`resolveGatewayPair`), so a missing half here means a gateway built *without* one — a
306
+ // component constructed directly by a test. Kept as the last resort it always was, with
307
+ // the same descriptors and the same sentence the loader uses.
308
+ this.#log?.logger('warn', {name: 'gateway'})?.warn?.({}, GENERATED_PAIR_WARNING);
309
+ }
272
310
  this.#errorFields = Object.entries({
273
311
  type: true,
274
312
  message: true,
@@ -436,6 +474,15 @@ export default class Gateway extends Internal implements IGateway {
436
474
  id: Type.Optional(Type.Union([Type.String(), Type.Number()])),
437
475
  method: isWildcard ? Type.String() : Type.Literal(method),
438
476
  params: value.params,
477
+ // A caller may declare the errors it expects. The
478
+ // gateway only *honours* it when configured to
479
+ // (`expectedErrors`), but the field has to survive
480
+ // validation for the body to reach the route at all —
481
+ // without it the declaration was silently dropped and
482
+ // every expected failure was logged as a surprise.
483
+ expect: Type.Optional(
484
+ Type.Union([Type.String(), Type.Array(Type.String())]),
485
+ ),
439
486
  }),
440
487
  }
441
488
  : undefined),
@@ -706,7 +753,12 @@ export default class Gateway extends Internal implements IGateway {
706
753
  // both more than a reader needs and less than the flow records say.
707
754
  // Nothing else depends on those two lines: the request has a flow, the
708
755
  // flow has its calls, and both are recorded where they belong.
709
- disableRequestLogging: true,
756
+ //
757
+ // Passed as a `LogController` instance rather than as fastify's own
758
+ // top-level `disableRequestLogging`, which is deprecated and warned
759
+ // about a line per process — one line per package in a CI run — for a
760
+ // flag whose replacement does exactly the same thing.
761
+ logController: new LogController({disableRequestLogging: true}),
710
762
  ajv: {
711
763
  customOptions: {
712
764
  allErrors: true,
@@ -739,7 +791,19 @@ export default class Gateway extends Internal implements IGateway {
739
791
 
740
792
  this.#server.setErrorHandler(
741
793
  (error: Record<string, unknown>, request: FastifyRequest, reply: FastifyReply) => {
742
- request.log.error({err: error}, 'gateway unhandled error');
794
+ // A hook that refuses before the route runs (authentication, RBAC)
795
+ // never reaches the handler that resolves `expect` for `$meta`, so
796
+ // the declaration is read from the request body here: the caller
797
+ // asked for this failure and the error stream is for the ones it did
798
+ // not ask for.
799
+ const expected = this.#config.expectedErrors
800
+ ? (request.body as {expect?: string | string[]} | undefined)?.expect
801
+ : undefined;
802
+ if (isExpectedError(error.type as string | undefined, expected)) {
803
+ request.log.debug?.({err: error}, 'gateway expected error');
804
+ } else {
805
+ request.log.error({err: error}, 'gateway unhandled error');
806
+ }
743
807
  return reply.status((error as {statusCode?: number}).statusCode || 500).send({
744
808
  jsonrpc: '2.0',
745
809
  id: (request.body as {id?: unknown})?.id,
package/src/Log.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import {withProgress} from '@feasibleone/blong-lib';
2
2
  import {Internal, type ICallLog, type ILog, type ILogger} from '@feasibleone/blong/types';
3
3
  import {createCallChannel} from '@feasibleone/semantic-log/capability';
4
- import {pino, type Logger, type LoggerOptions} from 'pino';
4
+ import {callerSite, messageMatches} from '@feasibleone/semantic-log/emitter';
5
+ import {pino, type Logger, type LoggerOptions, type TransportSingleOptions} from 'pino';
5
6
  import {monotonicFactory} from 'ulidx';
6
7
  import {callRecord, configureCallTrace, type CallTraceConfig} from './callTrace.ts';
7
8
  import type {CacacheTransportOptions} from './pino-cacache.js';
@@ -18,6 +19,23 @@ export interface LogConfig extends LoggerOptions {
18
19
  impl?: string;
19
20
  /** The semantic implementation's store. Not a pino option; stripped below. */
20
21
  cache?: unknown;
22
+ /**
23
+ * Put the `semlog://` reference group at the end of a human line — the
24
+ * pretty printer renders it, and this implementation has no other way to
25
+ * show one. Not a pino option; stripped below. On by default, off in a
26
+ * released process (`log.refs: false`), whose stdout nobody resolves a
27
+ * reference from.
28
+ */
29
+ refs?: boolean;
30
+ /**
31
+ * The messages whose caller is worth naming — `log.callSite`.
32
+ *
33
+ * Each entry is a regular expression source, so a substring (`unauthorized`, `adapter.ready`)
34
+ * is written as one and `['']` names the caller of every record. The semantic implementation
35
+ * reads the same list, so one entry places an unexplained line whichever logger a process
36
+ * chose, and nothing is captured for a message that matches none of the patterns.
37
+ */
38
+ callSite?: string | string[];
21
39
  /**
22
40
  * The call channel: whether a flow's calls are recorded, and whether they
23
41
  * are shown.
@@ -34,6 +52,14 @@ export interface LogConfig extends LoggerOptions {
34
52
 
35
53
  const ulid = monotonicFactory();
36
54
 
55
+ /**
56
+ * The files a pino record's stack runs through before it reaches the call site.
57
+ *
58
+ * This module's own frames — the hook that asks for the site, since it runs inside pino's write
59
+ * path — and pino's, which are between that hook and the caller. Everything else is the call site.
60
+ */
61
+ const PINO_FRAME = /[\\/](?:Log|semantic-log|pino|thread-stream)[\\/.]/;
62
+
37
63
  const ignoreArgPatterns = [
38
64
  '--tls-cipher-list=',
39
65
  '--v8-pool-size=',
@@ -73,39 +99,37 @@ const WORKER_OPTS = {
73
99
  ),
74
100
  };
75
101
 
76
- const PRETTY_TRANSPORT = {
77
- target: './pino-pretty.mjs',
102
+ const PRETTY_TRANSPORT = (refs?: boolean): TransportSingleOptions<Record<string, unknown>> => ({
103
+ target: './pino-pretty.mjs' as const,
78
104
  worker: WORKER_OPTS,
79
105
  options: {
80
106
  singleLine: true,
81
107
  colorizeObjects: true,
82
- ignore: [
83
- 'context',
84
- 'prefix',
85
- 'pid',
86
- 'hostname',
87
- '$meta.mtid',
88
- '$meta.method',
89
- 'req',
90
- 'res',
91
- 'config',
92
- 'configBase',
93
- 'id',
94
- ].join(','),
108
+ // The reference group is the printer's to render, so the switch travels
109
+ // with the transport the process chose.
110
+ refs,
111
+ // No `ignore` here: what the header already printed is the printer's own knowledge, and it
112
+ // lives beside the header that prints it (`pino-pretty.ts` → `ALREADY_IN_HEADER`). It used to
113
+ // be a list in this file, and an entry per envelope member meant the emptied
114
+ // `{"$meta":{}}` was printed instead of the two members it had dropped.
95
115
  },
96
- };
116
+ });
97
117
 
98
118
  export default class Log extends Internal implements ILog {
99
119
  #logger: Logger;
100
120
  #config: LogConfig = {
101
121
  level: 'info',
102
- transport: PRETTY_TRANSPORT,
122
+ transport: PRETTY_TRANSPORT(),
103
123
  };
104
124
 
105
125
  public constructor(config: LogConfig) {
106
126
  super();
107
127
  this.merge(this.#config, config);
108
128
  configureCallTrace(this.#config.calls);
129
+ // The printer renders the reference group, so the switch has to be known
130
+ // before the transport is built — the default above is there for a caller
131
+ // that never merges a config at all.
132
+ this.#config.transport = PRETTY_TRANSPORT(this.#config.refs);
109
133
 
110
134
  // Inject a monotonic ULID `id` into every log entry before it reaches any transport
111
135
 
@@ -115,7 +139,7 @@ export default class Log extends Internal implements ILog {
115
139
  const cacacheOptions = this.#config.cacache;
116
140
  this.#config.transport = {
117
141
  targets: [
118
- PRETTY_TRANSPORT,
142
+ PRETTY_TRANSPORT(this.#config.refs),
119
143
  {
120
144
  target: './pino-cacache.mjs',
121
145
  worker: WORKER_OPTS,
@@ -134,6 +158,8 @@ export default class Log extends Internal implements ILog {
134
158
  cacache: _cacacheConfig,
135
159
  impl: _impl,
136
160
  cache: _semanticCache,
161
+ refs: _refs,
162
+ callSite: _callSite,
137
163
  calls: _calls,
138
164
  ...pinoConfig
139
165
  } = this.#config;
@@ -158,6 +184,38 @@ export default class Log extends Internal implements ILog {
158
184
  bindings: object,
159
185
  ): ReturnType<ILog['logger']> {
160
186
  const child = this.#logger.child(bindings, {level});
187
+ // The caller of the messages `log.callSite` names. pino has no hook that sees a message:
188
+ // `logMethod` is not inherited by a child, and a formatter is handed the fields without
189
+ // the text — so the match is made here, where the framework builds the logger it hands
190
+ // out, and every component's logger is covered by construction.
191
+ const locations =
192
+ typeof this.#config.callSite === 'string'
193
+ ? [this.#config.callSite]
194
+ : this.#config.callSite;
195
+ const bind = <A extends unknown[]>(
196
+ method: (...args: A) => void,
197
+ ): ((...args: A) => void) => {
198
+ if (!locations?.length) return method;
199
+ return (...args: A) => {
200
+ const call = [...args] as unknown[];
201
+ // The message is whichever argument is text: pino's order is `(bag, message)` or
202
+ // `(message, values)`, and the text is the part both of those have.
203
+ const message = [...call].reverse().find(arg => typeof arg === 'string');
204
+ if (message !== undefined && messageMatches(locations, message)) {
205
+ // The site has to sit where pino looks for fields, which is the *first*
206
+ // argument: an object after the message is a printf value, and pino drops one
207
+ // the message has no placeholder left for. A call that already leads with a
208
+ // bag keeps it, and the site joins it.
209
+ const leading = call[0];
210
+ if (leading && typeof leading === 'object' && !Array.isArray(leading)) {
211
+ call[0] = {...(leading as object), site: callerSite(PINO_FRAME)};
212
+ } else {
213
+ call.unshift({site: callerSite(PINO_FRAME)});
214
+ }
215
+ }
216
+ return (method as (...args: unknown[]) => void)(...call);
217
+ };
218
+ };
161
219
  const result: ILogger = {
162
220
  trace: undefined,
163
221
  debug: undefined,
@@ -168,17 +226,17 @@ export default class Log extends Internal implements ILog {
168
226
  };
169
227
  switch (level) {
170
228
  case 'trace':
171
- result.trace = child.trace.bind(child);
229
+ result.trace = bind(child.trace.bind(child));
172
230
  case 'debug':
173
- result.debug = child.debug.bind(child);
231
+ result.debug = bind(child.debug.bind(child));
174
232
  case 'info':
175
- result.info = child.info.bind(child);
233
+ result.info = bind(child.info.bind(child));
176
234
  case 'warn':
177
- result.warn = child.warn.bind(child);
235
+ result.warn = bind(child.warn.bind(child));
178
236
  case 'error':
179
- result.error = child.error.bind(child);
237
+ result.error = bind(child.error.bind(child));
180
238
  case 'fatal':
181
- result.fatal = child.fatal.bind(child);
239
+ result.fatal = bind(child.fatal.bind(child));
182
240
  }
183
241
  return {
184
242
  ...result,
package/src/Registry.ts CHANGED
@@ -87,6 +87,14 @@ export default class Registry extends Internal implements IRegistry {
87
87
  #apiSchema: IApiSchema;
88
88
  #platform: IPlatformApi;
89
89
  #config: IConfig = {};
90
+ /**
91
+ * What each realm declares about itself, keyed by realm name.
92
+ *
93
+ * Handed over by {@link setRealmConfig} rather than read from this component's config, because
94
+ * a realm loads *after* the config of the realm whose child it is has merged: a copy taken while
95
+ * any config is built holds the realms loaded up to that moment and nothing more (D-433).
96
+ */
97
+ #realmConfig: Record<string, unknown> = {};
90
98
  #attachCheckpoint?: (meta: IMeta) => void;
91
99
 
92
100
  #manifest: IManifest | undefined;
@@ -518,9 +526,23 @@ export default class Registry extends Internal implements IRegistry {
518
526
  folders: disk?.folders ?? [],
519
527
  files: disk?.files ?? [],
520
528
  layerFiles: disk?.layerFiles ?? [],
529
+ // What each realm declares about itself, keyed by realm name: the loader records the
530
+ // slice and hands the map here, so a planner reads a realm's own `k8sRealmRole` rather
531
+ // than inferring it from the ports the realm happens to own (D-433).
532
+ realmConfig: this.#realmConfig,
521
533
  };
522
534
  }
523
535
 
536
+ /**
537
+ * Publish the per-realm config map.
538
+ *
539
+ * The map is taken by reference, and the loader keeps adding to it as realms load, so a caller
540
+ * that asks later sees the realms that were not loaded yet when the registry was created.
541
+ */
542
+ public setRealmConfig(realmConfig: Record<string, unknown>): void {
543
+ this.#realmConfig = realmConfig;
544
+ }
545
+
524
546
  public async stop(): Promise<IRegistry> {
525
547
  if (this.#stopped) return this;
526
548
  await this.#watch?.stop();
@@ -0,0 +1,71 @@
1
+ import {Internal, type IManifest} from '@feasibleone/blong/types';
2
+
3
+ import type {IResolution} from './Resolution.ts';
4
+
5
+ interface IConfig {
6
+ /** The port `RpcServer` serves an orchestrator namespace on. */
7
+ portRpc: number;
8
+ /** The gateway port (unused for namespace resolution, kept for symmetry). */
9
+ portGateway: number;
10
+ /** The cluster DNS domain the Service suffix is built from. */
11
+ domain: string;
12
+ /** The namespace the suite is deployed into. */
13
+ namespace: string;
14
+ }
15
+
16
+ /**
17
+ * Kubernetes resolution: a service id resolves to the Service of the same name
18
+ * in the suite's namespace, instead of to a local port.
19
+ *
20
+ * `GatewayCodec` asks for `rpc-<namespace>` and `RpcServer` serves namespaces on
21
+ * the RPC port, so the `rpc-` prefix is stripped and the port is the
22
+ * namespace's, not the gateway's. `blong-kustomize` generates one Service per
23
+ * orchestrator namespace with that name, which is what makes the namespace the
24
+ * suite's abstract name for a process (see the PRD and the deployment docs).
25
+ *
26
+ * The namespace comes from config, then from `BLONG_NAMESPACE` /
27
+ * `KUBERNETES_NAMESPACE` — the downward API that pins a pod to its own
28
+ * namespace — and only then falls back to `default`.
29
+ *
30
+ * Pinned down with `resolution.impl: kubernetes` in the suite's config; the
31
+ * loader then loads this module instead of `ResolutionLocal` (see the
32
+ * `resolution` component in `load.ts`).
33
+ */
34
+ export default class ResolutionK8s extends Internal implements IResolution {
35
+ #config: IConfig = {
36
+ portRpc: 8091,
37
+ portGateway: 8080,
38
+ domain: 'svc.cluster.local',
39
+ namespace: '',
40
+ };
41
+ #manifest: IManifest | undefined;
42
+
43
+ public constructor(config: IConfig, {manifest}: {manifest?: IManifest} = {}) {
44
+ super();
45
+ this.merge(this.#config, config);
46
+ this.#manifest = manifest;
47
+ if (!this.#config.namespace) {
48
+ const env = (globalThis as {process?: {env?: Record<string, string | undefined>}})
49
+ .process?.env;
50
+ this.#config.namespace = env?.BLONG_NAMESPACE ?? env?.KUBERNETES_NAMESPACE ?? 'default';
51
+ }
52
+ }
53
+
54
+ public async resolve(service: string): ReturnType<IResolution['resolve']> {
55
+ const isRpc = service.startsWith('rpc-');
56
+ const name = service.replace(/^rpc-/, '');
57
+ // The manifest port is deliberately NOT preferred here: a random dev port
58
+ // would point at a Service port the cluster does not publish. In the
59
+ // cluster the Service is the address, and its port is the component's.
60
+ return {
61
+ hostname: `${name}.${this.#config.namespace}.${this.#config.domain}`,
62
+ port: `${isRpc ? this.#config.portRpc : this.#config.portGateway}`,
63
+ };
64
+ }
65
+
66
+ public announce(): void {}
67
+
68
+ public async start(): Promise<void> {}
69
+
70
+ public async stop(): Promise<void> {}
71
+ }
@@ -127,12 +127,22 @@ t.test('the argument adapter lifts the framework envelope into the record slots'
127
127
  t.equal(envelope.fields.operation, 'subject.subjectModelList');
128
128
  t.equal(envelope.fields.messageId, 'request');
129
129
  t.equal(envelope.fields.extra, 1);
130
- t.equal(
131
- '$meta' in envelope.fields,
132
- true,
133
- 'the envelope itself is carried through, not dropped',
130
+ t.same(
131
+ envelope.fields.$meta,
132
+ undefined,
133
+ 'and the envelope is not repeated beside the slots that hold its members',
134
134
  );
135
135
 
136
+ // A member the slots did not take is still carried, which is what "a reader still sees the
137
+ // request it came from" means, and why the envelope is pruned rather than dropped.
138
+ const rest = toLogCall([{$meta: {mtid: 'event', method: 'a.b', expect: 'notFound'}}]);
139
+ t.same(rest.fields.$meta, {expect: 'notFound'}, 'the rest of the envelope stays');
140
+
141
+ // And a member that could not become a slot stays too, rather than vanishing: the header shows
142
+ // a `method` only when it is text, so a non-string one is nowhere else on the record.
143
+ const odd = toLogCall([{$meta: {mtid: 1, method: 2}}]);
144
+ t.same(odd.fields.$meta, {mtid: 1, method: 2}, 'a member that did not become a slot is kept');
145
+
136
146
  // The envelope wins over the same keys on the bag: it is the structured source.
137
147
  const overridden = toLogCall([
138
148
  {
@@ -80,6 +80,23 @@ export interface SemanticLogConfig {
80
80
  format?: Format;
81
81
  /** ANSI colour in human format. Opt-in, as the emitter's own option is. */
82
82
  color?: boolean;
83
+ /**
84
+ * Put the `semlog://` reference group at the end of a human line. On by
85
+ * default, off in a released process (`log.refs: false`): a reference is a
86
+ * development aid, and resolving one needs a store open beside the terminal.
87
+ */
88
+ refs?: boolean;
89
+ /**
90
+ * The messages whose caller is worth naming — `log.callSite`.
91
+ *
92
+ * Each entry is a regular expression source, so a substring (`unauthorized`, `adapter.ready`)
93
+ * is written as one and `['']` names the caller of every record. A list rather than a switch,
94
+ * because the interesting line is usually one particular message: a record whose text matches
95
+ * none of the patterns is emitted exactly as it was, with no stack captured for it. A call in
96
+ * pino's printf shape is always named, whatever this says — the repository writes no such
97
+ * call, so one arriving is a dependency's.
98
+ */
99
+ callSite?: string | string[];
83
100
  /** The service name carried on every record; the framework's own by default. */
84
101
  service?: string;
85
102
  /**
@@ -139,6 +156,8 @@ export default class SemanticLog extends Internal implements ILog {
139
156
  level: thresholdOf(merged.level),
140
157
  format: merged.format,
141
158
  color: merged.color,
159
+ refs: merged.refs,
160
+ callSite: merged.callSite,
142
161
  cache: merged.cache,
143
162
  // Only the printing switch is the base's business; `enabled`/`off` are
144
163
  // the gate's, and are read where a flow is minted.
@@ -150,9 +169,10 @@ export default class SemanticLog extends Internal implements ILog {
150
169
  openCluster: async () =>
151
170
  (await import('@feasibleone/semantic-log/service')).openCluster,
152
171
  // The framework's envelope, on the record and on the call site's own
153
- // arguments: `{$meta: {mtid, method}}` is what its readers match on.
172
+ // arguments: `{$meta: {mtid, method}}` is what its readers match on — and the
173
+ // caller's frame, when the log configuration asks for it.
154
174
  envelope: ({leg, phase}) => callRecord(leg, phase),
155
- translate: args => toLogCall(args),
175
+ translate: args => toLogCall(args, {callSite: merged.callSite}),
156
176
  });
157
177
  }
158
178