@compforge/harness-toolbox 0.5.1 → 0.5.3

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/README.md CHANGED
@@ -426,3 +426,7 @@ MySQL, OpenSearch and Redis use the same acquisition lifecycle. `connectTimeoutM
426
426
  and handshake/probe; optional `acquireTimeoutMs` bounds all routes together (default: per-route budget × route count).
427
427
  Cancellation preserves the caller reason and prevents late mappings from starting a handshake. Query and command
428
428
  timeouts remain separate, and failed operations are never replayed on another transport.
429
+
430
+ DataLoader accepts an optional `logger.debug(message)` and a non-sensitive `label`.
431
+ Diagnostics distinguish miss, wait, hit, bypass, not-retained and close using scope/read
432
+ identifiers, never keys, values or errors. Logging does not change retention or retry policy.
@@ -3,6 +3,11 @@ export interface DataLoaderOptions {
3
3
  /** Serialized result bytes retained, excluding keys/errors and active operation memory. */
4
4
  maxBytes: number;
5
5
  signal: AbortSignal;
6
+ /** Non-sensitive label supplied by the adapter; keys and values never enter diagnostics. */
7
+ label?: string;
8
+ logger?: {
9
+ debug(message: string): void;
10
+ };
6
11
  }
7
12
  /** A scoped async loading cache, similar to Guava's LoadingCache.
8
13
  * Shares results, failures and in-flight reads until close; consumers must not mutate results.
@@ -1,8 +1,12 @@
1
1
  // src/data-loader.ts
2
2
  import { serialize } from "node:v8";
3
+ var nextScopeId = 0;
3
4
 
4
5
  class DataLoader {
5
6
  options;
7
+ #scopeId = ++nextScopeId;
8
+ #readIds = new WeakMap;
9
+ #nextReadId = 0;
6
10
  #entries = new Map;
7
11
  #pending = new Set;
8
12
  #controller = new AbortController;
@@ -20,30 +24,46 @@ class DataLoader {
20
24
  #abort = () => {
21
25
  this.close(this.options.signal.reason);
22
26
  };
27
+ #debug(event, readId, reason) {
28
+ if (!this.options.logger)
29
+ return;
30
+ try {
31
+ this.options.logger.debug(`[data-loader] scope=${this.#scopeId}` + (this.options.label ? ` label=${this.options.label}` : "") + (readId === undefined ? "" : ` read=${readId}`) + ` event=${event}` + (reason ? ` reason=${reason}` : ""));
32
+ } catch {}
33
+ }
23
34
  async read(key, operation, signal) {
24
35
  this.#controller.signal.throwIfAborted();
25
36
  signal?.throwIfAborted();
26
37
  let pending = key === undefined ? undefined : this.#entries.get(key);
27
- if (!pending) {
38
+ if (pending) {
39
+ this.#debug(this.#pending.has(pending) ? "wait" : "hit", this.#readIds.get(pending));
40
+ } else {
41
+ const readId = ++this.#nextReadId;
28
42
  const retain = key !== undefined && this.#entries.size < this.options.maxEntries;
43
+ this.#debug(retain ? "miss" : "bypass", readId, retain ? undefined : key === undefined ? "no-key" : "entry-limit");
29
44
  pending = Promise.resolve().then(async () => {
30
45
  this.#controller.signal.throwIfAborted();
31
46
  const value = await operation(this.#controller.signal);
32
47
  this.#controller.signal.throwIfAborted();
33
48
  if (retain) {
34
49
  let bytes;
50
+ let reason = "byte-limit";
35
51
  try {
36
52
  bytes = serialize(value).byteLength;
37
53
  } catch {
38
54
  bytes = Infinity;
55
+ reason = "not-serializable";
39
56
  }
40
57
  if (this.#bytes + bytes <= this.options.maxBytes)
41
58
  this.#bytes += bytes;
42
- else
59
+ else {
43
60
  this.#entries.delete(key);
61
+ this.#debug("not-retained", readId, reason);
62
+ }
44
63
  }
45
64
  return value;
46
65
  });
66
+ this.#readIds.set(pending, readId);
47
67
  if (retain)
48
68
  this.#entries.set(key, pending);
49
69
  this.#pending.add(pending);
@@ -74,6 +94,7 @@ class DataLoader {
74
94
  }
75
95
  close(reason = new Error("DataLoader scope is closed")) {
76
96
  if (!this.#closing) {
97
+ this.#debug("close");
77
98
  this.#closing = Promise.resolve().then(async () => {
78
99
  await Promise.allSettled([...this.#pending]);
79
100
  this.#entries.clear();
@@ -0,0 +1,44 @@
1
+ import { type Client, type ClientManager, type ClientProvider, type DataSource } from "@compforge/harness-common";
2
+ import { type ResourceAccess, type ResourceRequestOptions } from "./resources";
3
+ export interface KubernetesSecretTarget {
4
+ readonly namespace: string;
5
+ readonly name: string;
6
+ }
7
+ export interface KubernetesSecretRead {
8
+ resourceVersion?: string;
9
+ observedAt: string;
10
+ values: Record<string, string>;
11
+ /** A successful read can establish absence without treating it as an API failure. */
12
+ missingKeys: string[];
13
+ }
14
+ export type KubernetesSecretAccess = Client & Pick<ResourceAccess, "get">;
15
+ export declare function secretKeys(keys: readonly string[]): readonly string[];
16
+ /**
17
+ * @spec Only declared UTF-8 keys enter results; Kubernetes failures retain their kind and never become missing keys.
18
+ * @rule The borrowed resource access owns transport capacity and byte limits; request deadlines and cancellation flow through get.
19
+ * @see {@link component://../../../docs/toolbox.md}
20
+ */
21
+ export declare class KubernetesSecretClient implements Client {
22
+ #private;
23
+ private readonly acquire;
24
+ readonly keys: readonly string[];
25
+ constructor(keys: readonly string[], signal: AbortSignal, acquire: () => Promise<{
26
+ target: KubernetesSecretTarget;
27
+ access: Pick<ResourceAccess, "get">;
28
+ }>);
29
+ get target(): Readonly<KubernetesSecretTarget>;
30
+ initialize(): Promise<void>;
31
+ read(options?: ResourceRequestOptions): Promise<KubernetesSecretRead>;
32
+ private capture;
33
+ /** @spec Disposal cancels and drains owned reads without closing borrowed Kubernetes access. */
34
+ dispose(): Promise<void>;
35
+ }
36
+ /** Named Secret acquisition; callers own discovery, access policy and interpretation of the selected values. */
37
+ export declare class KubernetesSecretDataSource implements DataSource<KubernetesSecretClient> {
38
+ private readonly kubernetes;
39
+ readonly clientKey: string;
40
+ readonly target: Readonly<KubernetesSecretTarget>;
41
+ readonly keys: readonly string[];
42
+ constructor(kubernetes: ClientProvider<KubernetesSecretAccess>, target: KubernetesSecretTarget, keys: readonly string[]);
43
+ createClient(clients: Pick<ClientManager, "get">, signal: AbortSignal): KubernetesSecretClient;
44
+ }
@@ -0,0 +1,118 @@
1
+ import {
2
+ resourcePath
3
+ } from "../chunk-dpcge1xt.js";
4
+ import {
5
+ KubernetesError
6
+ } from "../chunk-rkt41za2.js";
7
+
8
+ // src/kubernetes/secret-datasource.ts
9
+ import { clientKey } from "@compforge/harness-common";
10
+ function secretKeys(keys) {
11
+ if (!keys.length || keys.some((key) => !/^[A-Za-z0-9._-]+$/.test(key)) || new Set(keys).size !== keys.length) {
12
+ throw new KubernetesError("Secret keys must be nonempty, distinct Kubernetes data keys", { kind: "invalid_argument" });
13
+ }
14
+ return Object.freeze([...keys]);
15
+ }
16
+ function secretTarget(target) {
17
+ if (!target.namespace || !target.name)
18
+ throw new KubernetesError("Secret target requires namespace and name", { kind: "invalid_argument" });
19
+ resourcePath({ apiVersion: "v1", resource: "secrets", namespace: target.namespace }, target.name);
20
+ return Object.freeze({ namespace: target.namespace, name: target.name });
21
+ }
22
+
23
+ class KubernetesSecretClient {
24
+ acquire;
25
+ keys;
26
+ #controller = new AbortController;
27
+ #signal;
28
+ #operations = new Set;
29
+ #target;
30
+ #access;
31
+ #disposal;
32
+ constructor(keys, signal, acquire) {
33
+ this.acquire = acquire;
34
+ this.keys = secretKeys(keys);
35
+ this.#signal = AbortSignal.any([signal, this.#controller.signal]);
36
+ }
37
+ get target() {
38
+ if (!this.#target)
39
+ throw new KubernetesError("Secret client is not initialized", { kind: "operation_failed" });
40
+ return this.#target;
41
+ }
42
+ async initialize() {
43
+ this.#signal.throwIfAborted();
44
+ const { target, access } = await this.acquire();
45
+ this.#signal.throwIfAborted();
46
+ this.#target = secretTarget(target);
47
+ this.#access = access;
48
+ }
49
+ async read(options = {}) {
50
+ const signal = options.signal ? AbortSignal.any([this.#signal, options.signal]) : this.#signal;
51
+ signal.throwIfAborted();
52
+ const target = this.target;
53
+ const operation = this.capture(target, { ...options, signal });
54
+ this.#operations.add(operation);
55
+ try {
56
+ return await operation;
57
+ } finally {
58
+ this.#operations.delete(operation);
59
+ }
60
+ }
61
+ async capture(target, options) {
62
+ const secret = await this.#access.get({ apiVersion: "v1", resource: "secrets", ...target }, options);
63
+ options.signal.throwIfAborted();
64
+ const values = Object.create(null);
65
+ const missingKeys = [];
66
+ for (const key of this.keys) {
67
+ if (!Object.hasOwn(secret.data ?? {}, key)) {
68
+ missingKeys.push(key);
69
+ continue;
70
+ }
71
+ const encoded = secret.data[key];
72
+ try {
73
+ const bytes = Buffer.from(encoded, "base64");
74
+ if (bytes.toString("base64") !== encoded)
75
+ throw new Error("invalid base64");
76
+ values[key] = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
77
+ } catch {
78
+ throw new KubernetesError(`Secret key is not valid base64 UTF-8: ${key}`, { kind: "invalid_response" });
79
+ }
80
+ }
81
+ return {
82
+ values,
83
+ missingKeys,
84
+ observedAt: new Date().toISOString(),
85
+ ...typeof secret.metadata?.resourceVersion === "string" ? { resourceVersion: secret.metadata.resourceVersion } : {}
86
+ };
87
+ }
88
+ dispose() {
89
+ this.#disposal ??= (async () => {
90
+ this.#controller.abort(new KubernetesError("Secret client disposed", { kind: "operation_failed" }));
91
+ await Promise.allSettled([...this.#operations]);
92
+ this.#target = undefined;
93
+ this.#access = undefined;
94
+ })();
95
+ return this.#disposal;
96
+ }
97
+ }
98
+
99
+ class KubernetesSecretDataSource {
100
+ kubernetes;
101
+ clientKey;
102
+ target;
103
+ keys;
104
+ constructor(kubernetes, target, keys) {
105
+ this.kubernetes = kubernetes;
106
+ this.target = secretTarget(target);
107
+ this.keys = secretKeys(keys);
108
+ this.clientKey = clientKey("kubernetes-secret", { access: kubernetes.clientKey, target: this.target, keys: this.keys });
109
+ }
110
+ createClient(clients, signal) {
111
+ return new KubernetesSecretClient(this.keys, signal, async () => ({ target: this.target, access: await clients.get(this.kubernetes) }));
112
+ }
113
+ }
114
+ export {
115
+ secretKeys,
116
+ KubernetesSecretDataSource,
117
+ KubernetesSecretClient
118
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compforge/harness-toolbox",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Shared infrastructure clients, transports and bounded data capture",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -104,7 +104,7 @@
104
104
  ],
105
105
  "dependencies": {
106
106
  "@aws-sdk/client-s3": "3.1133.0",
107
- "@compforge/harness-common": "0.5.10",
107
+ "@compforge/harness-common": "0.5.11",
108
108
  "@kubernetes/client-node": "1.4.0",
109
109
  "@opensearch-project/opensearch": "3.6.0",
110
110
  "@redis/client": "6.1.0",
@@ -122,6 +122,9 @@
122
122
  "@types/node": "^22",
123
123
  "@types/ws": "^8.18.1"
124
124
  },
125
+ "overrides": {
126
+ "@compforge/harness-common": "file:../common"
127
+ },
125
128
  "publishConfig": {
126
129
  "access": "public"
127
130
  },