@compforge/harness-toolbox 0.1.1 → 0.1.2

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
@@ -1,5 +1,9 @@
1
1
  # Harness Toolbox
2
2
 
3
+ Client, DataSource and ClientManager are owned by the sibling TypeScript package
4
+ `@compforge/harness-common`. Toolbox provides protocol implementations and transports;
5
+ its lifecycle entrypoints re-export the same common symbols.
6
+
3
7
  Shared infrastructure for diagnostic tools, test runners and business adapters. Use the same
4
8
  clients to access Kubernetes, MySQL, Redis, OpenSearch and S3-compatible object storage without depending on a particular
5
9
  command, plugin protocol, test model or verdict.
@@ -81,3 +85,17 @@ for mapped routes; direct endpoints also support virtual-hosted buckets. Signed
81
85
  identity remain the logical endpoint, not the forwarded address. Private CAs may be supplied
82
86
  explicitly with `ca`; certificate verification stays enabled. Initialization prepares the client
83
87
  and route without listing buckets or claiming that credentials have been validated.
88
+
89
+ ## Bounded MySQL reads
90
+
91
+ `MysqlClient.database.queryReadonly(target, sql, values, { timeoutMs, maxRows, maxBytes })` uses
92
+ the shared query slot and a disposable native session with `NO_BACKSLASH_ESCAPES`, a SELECT deadline,
93
+ and a READ ONLY transaction. Prepared execution binds values and retains rows incrementally,
94
+ returning `rows`, `columns`, `bytes`, `truncated`, and `truncation`. An extra row proves row truncation;
95
+ a row exceeding the remaining byte budget is not retained.
96
+
97
+ Callers own SQL classification, function policy, and least-privilege identities: READ ONLY alone
98
+ is not an arbitrary-SQL sandbox. This API requires MySQL 5.7.8+ `max_execution_time` and does not
99
+ fall back to Pod Python. Completion, truncation, timeout, and cancellation destroy the disposable
100
+ session without replay or changes to ordinary borrowed sessions. Disconnect does not prove immediate
101
+ server cancellation. The byte budget limits retained JSON rows, not individual wire packets or fields.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  dataSourceKey
3
- } from "./chunk-hqx7j9xe.js";
3
+ } from "./chunk-fnc291vk.js";
4
4
  import {
5
5
  S3Client
6
6
  } from "./chunk-zp1s833x.js";
@@ -1,6 +1,10 @@
1
1
  import {
2
2
  queryMysqlViaPod
3
3
  } from "./chunk-s06hs7a8.js";
4
+ import {
5
+ queryReadonlySession,
6
+ validateQueryLimits
7
+ } from "./chunk-kbewz29a.js";
4
8
  import {
5
9
  ConcurrencyPool
6
10
  } from "./chunk-v3hxvnrc.js";
@@ -24,6 +28,19 @@ class MysqlDatabase {
24
28
  query(target, sql, values) {
25
29
  return this.#queries.run(() => this.#query(target, sql, values), this.options.signal);
26
30
  }
31
+ queryReadonly(target, sql, values, limits) {
32
+ validateQueryLimits(limits);
33
+ return this.#queries.run(async () => {
34
+ const session = await this.#open(target);
35
+ if (session.kind !== "tcp")
36
+ throw new Error("Bounded readonly MySQL queries require a native TCP transport");
37
+ try {
38
+ return await queryReadonlySession(session.connection, sql, values, limits, this.options.signal);
39
+ } finally {
40
+ session.connection.destroy();
41
+ }
42
+ }, this.options.signal);
43
+ }
27
44
  initialize(target) {
28
45
  return this.#queries.run(async () => {
29
46
  await this.#session(target);
@@ -0,0 +1,4 @@
1
+ // src/datasource.ts
2
+ import { dataSourceKey, ClientManager } from "@compforge/harness-common";
3
+
4
+ export { dataSourceKey, ClientManager };
@@ -0,0 +1,95 @@
1
+ // src/mysql/readonly.ts
2
+ function validateQueryLimits(limits) {
3
+ for (const name of ["timeoutMs", "maxRows", "maxBytes"]) {
4
+ const value = limits[name];
5
+ if (!Number.isSafeInteger(value) || value < 1 || value > 2147483647) {
6
+ throw new Error(`${name} must be a positive integer <= 2147483647`);
7
+ }
8
+ }
9
+ }
10
+ async function queryReadonlySession(connection, sql, values, limits, signal) {
11
+ validateQueryLimits(limits);
12
+ signal?.throwIfAborted();
13
+ let timer;
14
+ let abort;
15
+ try {
16
+ return await Promise.race([
17
+ (async () => {
18
+ await connection.query("SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_BACKSLASH_ESCAPES'");
19
+ await connection.query(`SET SESSION max_execution_time = ${limits.timeoutMs}`);
20
+ await connection.query("START TRANSACTION READ ONLY");
21
+ signal?.throwIfAborted();
22
+ return await collectRows(connection, sql, values, limits);
23
+ })(),
24
+ new Promise((_resolve, reject) => {
25
+ const fail = (reason) => {
26
+ connection.destroy();
27
+ reject(reason);
28
+ };
29
+ abort = () => fail(signal?.reason ?? new Error("MySQL query cancelled"));
30
+ signal?.addEventListener("abort", abort, { once: true });
31
+ timer = setTimeout(() => fail(new Error("MySQL readonly query timed out")), limits.timeoutMs);
32
+ if (signal?.aborted)
33
+ abort();
34
+ })
35
+ ]);
36
+ } finally {
37
+ if (timer)
38
+ clearTimeout(timer);
39
+ if (abort)
40
+ signal?.removeEventListener("abort", abort);
41
+ connection.destroy();
42
+ }
43
+ }
44
+ function collectRows(connection, sql, values, limits) {
45
+ const native = connection.connection;
46
+ return new Promise((resolve, reject) => {
47
+ const result = { rows: [], columns: [], bytes: 0, truncated: false };
48
+ let settled = false;
49
+ const finish = (truncation) => {
50
+ if (settled)
51
+ return;
52
+ settled = true;
53
+ result.truncated = !!truncation;
54
+ if (truncation)
55
+ result.truncation = truncation;
56
+ resolve(result);
57
+ };
58
+ const query = native.execute({ sql, values: [...values], timeout: limits.timeoutMs });
59
+ query.on("fields", (fields) => {
60
+ result.columns = fields?.map((field) => field.name) ?? [];
61
+ });
62
+ query.on("result", (row) => {
63
+ if (settled)
64
+ return;
65
+ if (result.rows.length >= limits.maxRows) {
66
+ finish("rows");
67
+ connection.destroy();
68
+ return;
69
+ }
70
+ try {
71
+ const bytes = Buffer.byteLength(JSON.stringify(row));
72
+ if (result.bytes + bytes > limits.maxBytes) {
73
+ finish("bytes");
74
+ connection.destroy();
75
+ return;
76
+ }
77
+ result.rows.push(row);
78
+ result.bytes += bytes;
79
+ } catch (error) {
80
+ settled = true;
81
+ connection.destroy();
82
+ reject(error);
83
+ }
84
+ });
85
+ query.on("error", (error) => {
86
+ if (!settled) {
87
+ settled = true;
88
+ reject(error);
89
+ }
90
+ });
91
+ query.on("end", () => finish());
92
+ });
93
+ }
94
+
95
+ export { validateQueryLimits, queryReadonlySession };
@@ -10,7 +10,7 @@ import {
10
10
  } from "./chunk-1vfwvgkj.js";
11
11
  import {
12
12
  dataSourceKey
13
- } from "./chunk-hqx7j9xe.js";
13
+ } from "./chunk-fnc291vk.js";
14
14
 
15
15
  // src/kubernetes/pod-log-client.ts
16
16
  import { copyFileSync, mkdtempSync, rmSync } from "node:fs";
@@ -1,14 +1 @@
1
- import type { Client } from "./client";
2
- import type { DataSource } from "./datasource";
3
- /** Consumers borrow clients; only the root owner receives the disposal capability. */
4
- export interface ClientProvider {
5
- get<C extends Client>(source: DataSource<C>): Promise<C>;
6
- }
7
- /** @spec One root execution shares successful initialization per identity and closes clients at finalize. */
8
- export declare class ClientManager implements ClientProvider {
9
- #private;
10
- readonly signal: AbortSignal;
11
- constructor(signal?: AbortSignal);
12
- get<C extends Client>(source: DataSource<C>): Promise<C>;
13
- dispose(): Promise<void>;
14
- }
1
+ export { ClientManager, type ClientProvider } from "@compforge/harness-common";
@@ -1,6 +1,5 @@
1
- import {
2
- ClientManager
3
- } from "./chunk-3dt25p98.js";
1
+ // src/client-manager.ts
2
+ import { ClientManager } from "@compforge/harness-common";
4
3
  export {
5
4
  ClientManager
6
5
  };
package/dist/client.d.ts CHANGED
@@ -1,5 +1 @@
1
- /** A protocol client owns its initialization and idempotent cleanup, including partial initialization. */
2
- export interface Client {
3
- initialize(): Promise<void>;
4
- dispose(): Promise<void>;
5
- }
1
+ export type { Client } from "@compforge/harness-common";
@@ -1,13 +1,5 @@
1
- export type { Client } from "./client";
2
- export { ClientManager, type ClientProvider } from "./client-manager";
3
- import type { Client } from "./client";
1
+ export { dataSourceKey, ClientManager, type Client, type ClientProvider, type DataSource, type ServiceDataSource } from "@compforge/harness-common";
4
2
  import type { Transport } from "./transport";
5
- /** Identity must include protocol, target, configuration and credentials; never an object address. */
6
- export interface DataSource<C extends Client> {
7
- readonly key: string;
8
- /** Construct only; Client.initialize owns external work. */
9
- createClient(signal: AbortSignal): C;
10
- }
11
3
  /** Resolution owns configuration semantics; transports preserve the resolved target and identity. */
12
4
  export interface ConnectionSource<Target> {
13
5
  resolve(): Promise<Target>;
@@ -22,5 +14,3 @@ export interface ClientLifecycle {
22
14
  reason?: string;
23
15
  }) => void;
24
16
  }
25
- /** Stable in-memory identity; connection credentials must not appear in observable cache keys. */
26
- export declare function dataSourceKey(protocol: string, configuration: unknown): string;
@@ -1,9 +1,7 @@
1
1
  import {
2
+ ClientManager,
2
3
  dataSourceKey
3
- } from "./chunk-hqx7j9xe.js";
4
- import {
5
- ClientManager
6
- } from "./chunk-3dt25p98.js";
4
+ } from "./chunk-fnc291vk.js";
7
5
  export {
8
6
  dataSourceKey,
9
7
  ClientManager
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  ServicePortForwarder
3
- } from "../chunk-p097seg7.js";
4
- import"../chunk-j46x7j7c.js";
5
- import"../chunk-6fnhzhdb.js";
3
+ } from "../chunk-205a4vma.js";
6
4
  import"../chunk-hv2kx47y.js";
7
5
  import {
8
6
  KubectlExecutor
9
7
  } from "../chunk-0rs1aacd.js";
10
8
  import"../chunk-ztenxgjv.js";
9
+ import"../chunk-j46x7j7c.js";
10
+ import"../chunk-6fnhzhdb.js";
11
11
 
12
12
  // src/kubernetes/client.ts
13
13
  class KubernetesClient {
@@ -1,11 +1,10 @@
1
1
  import {
2
2
  PodLogClient
3
- } from "../chunk-1at7zs0z.js";
3
+ } from "../chunk-vjsr2jb3.js";
4
4
  import"../chunk-8nm9f0hh.js";
5
5
  import"../chunk-mp88s7r7.js";
6
6
  import"../chunk-1vfwvgkj.js";
7
- import"../chunk-hqx7j9xe.js";
8
- import"../chunk-3dt25p98.js";
7
+ import"../chunk-fnc291vk.js";
9
8
  import"../chunk-v3hxvnrc.js";
10
9
  export {
11
10
  PodLogClient
@@ -1,13 +1,12 @@
1
1
  import {
2
2
  PodLogClient
3
- } from "../chunk-1at7zs0z.js";
3
+ } from "../chunk-vjsr2jb3.js";
4
4
  import"../chunk-8nm9f0hh.js";
5
5
  import"../chunk-mp88s7r7.js";
6
6
  import"../chunk-1vfwvgkj.js";
7
7
  import {
8
8
  dataSourceKey
9
- } from "../chunk-hqx7j9xe.js";
10
- import"../chunk-3dt25p98.js";
9
+ } from "../chunk-fnc291vk.js";
11
10
  import"../chunk-v3hxvnrc.js";
12
11
 
13
12
  // src/kubernetes/pod-log-datasource.ts
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  ServicePortForwarder
3
- } from "../chunk-p097seg7.js";
4
- import"../chunk-j46x7j7c.js";
5
- import"../chunk-6fnhzhdb.js";
3
+ } from "../chunk-205a4vma.js";
6
4
  import"../chunk-hv2kx47y.js";
7
5
  import"../chunk-0rs1aacd.js";
8
6
  import"../chunk-ztenxgjv.js";
7
+ import"../chunk-j46x7j7c.js";
8
+ import"../chunk-6fnhzhdb.js";
9
9
  export {
10
10
  ServicePortForwarder
11
11
  };
@@ -1,6 +1,6 @@
1
1
  import type { Client } from "../client";
2
2
  import { type Connection, type ConnectionOptions } from "mysql2/promise";
3
- import type { Database, DatabaseTarget, DatabaseRow } from "./types";
3
+ import type { Database, DatabaseTarget, DatabaseRow, DatabaseQueryLimits, DatabaseQueryResult } from "./types";
4
4
  import type { ConnectionSource, ClientLifecycle } from "../datasource";
5
5
  import { type Transport } from "../transport";
6
6
  export interface MysqlDatabaseOptions extends ClientLifecycle {
@@ -16,6 +16,8 @@ export declare class MysqlDatabase implements Database {
16
16
  private readonly connect;
17
17
  constructor(transports: readonly Transport[], options: MysqlDatabaseOptions, connect?: ConnectionFactory);
18
18
  query(target: DatabaseTarget, sql: string, values: readonly unknown[]): Promise<DatabaseRow[]>;
19
+ /** Bounded, single-statement prepared execution on an isolated READ ONLY session (native only). */
20
+ queryReadonly(target: DatabaseTarget, sql: string, values: readonly unknown[], limits: DatabaseQueryLimits): Promise<DatabaseQueryResult>;
19
21
  initialize(target: DatabaseTarget): Promise<void>;
20
22
  queryOne(target: DatabaseTarget, sql: string, values: readonly unknown[]): Promise<DatabaseRow | undefined>;
21
23
  close(): Promise<void>;
@@ -28,7 +30,7 @@ export declare class MysqlClient<Target extends DatabaseTarget = DatabaseTarget>
28
30
  readonly signal: AbortSignal;
29
31
  constructor(source: ConnectionSource<Target>, options: MysqlDatabaseOptions);
30
32
  initialize(): Promise<void>;
31
- get database(): Database;
33
+ get database(): MysqlDatabase;
32
34
  get target(): Target;
33
35
  dispose(): Promise<void>;
34
36
  }
@@ -2,8 +2,9 @@ import {
2
2
  MysqlClient,
3
3
  MysqlDatabase,
4
4
  openMysql
5
- } from "../chunk-sry9e02n.js";
5
+ } from "../chunk-9x1apyp4.js";
6
6
  import"../chunk-s06hs7a8.js";
7
+ import"../chunk-kbewz29a.js";
7
8
  import"../chunk-v3hxvnrc.js";
8
9
  import"../chunk-xy4g56g4.js";
9
10
  export {
@@ -6,8 +6,9 @@ import {
6
6
  MysqlClient,
7
7
  MysqlDatabase,
8
8
  openMysql
9
- } from "../chunk-sry9e02n.js";
9
+ } from "../chunk-9x1apyp4.js";
10
10
  import"../chunk-s06hs7a8.js";
11
+ import"../chunk-kbewz29a.js";
11
12
  import"../chunk-v3hxvnrc.js";
12
13
  import"../chunk-xy4g56g4.js";
13
14
  export {
@@ -0,0 +1,9 @@
1
+ import type { Connection } from "mysql2/promise";
2
+ import type { DatabaseQueryLimits, DatabaseQueryResult } from "./types";
3
+ export declare function validateQueryLimits(limits: DatabaseQueryLimits): void;
4
+ /**
5
+ * Owns a disposable session: setup, execution and cancellation cannot affect another borrower.
6
+ * Caller must classify SQL and use a least-privilege identity. READ ONLY is not a SQL sandbox
7
+ * (for example routines and temporary tables need additional caller policy).
8
+ */
9
+ export declare function queryReadonlySession(connection: Connection, sql: string, values: readonly unknown[], limits: DatabaseQueryLimits, signal?: AbortSignal): Promise<DatabaseQueryResult>;
@@ -0,0 +1,8 @@
1
+ import {
2
+ queryReadonlySession,
3
+ validateQueryLimits
4
+ } from "../chunk-kbewz29a.js";
5
+ export {
6
+ validateQueryLimits,
7
+ queryReadonlySession
8
+ };
@@ -8,6 +8,19 @@ export interface DatabaseTarget extends DatabaseIdentity {
8
8
  database: string;
9
9
  }
10
10
  export type DatabaseRow = Record<string, unknown>;
11
+ export interface DatabaseQueryLimits {
12
+ timeoutMs: number;
13
+ maxRows: number;
14
+ /** Maximum retained UTF-8 JSON row bytes, not a limit on an individual wire packet. */
15
+ maxBytes: number;
16
+ }
17
+ export interface DatabaseQueryResult {
18
+ rows: DatabaseRow[];
19
+ columns: string[];
20
+ bytes: number;
21
+ truncated: boolean;
22
+ truncation?: "rows" | "bytes";
23
+ }
11
24
  /** Storage-neutral operations shared by infrastructure consumers. */
12
25
  export interface Database {
13
26
  query(target: DatabaseTarget, sql: string, values: readonly unknown[]): Promise<DatabaseRow[]>;
@@ -1,8 +1,7 @@
1
1
  import {
2
2
  S3DataSource
3
- } from "../chunk-9j36zvn4.js";
4
- import"../chunk-hqx7j9xe.js";
5
- import"../chunk-3dt25p98.js";
3
+ } from "../chunk-94anqtxh.js";
4
+ import"../chunk-fnc291vk.js";
6
5
  import"../chunk-zp1s833x.js";
7
6
  import"../chunk-v3hxvnrc.js";
8
7
  import"../chunk-x4ntb898.js";
package/dist/s3/index.js CHANGED
@@ -1,8 +1,7 @@
1
1
  import {
2
2
  S3DataSource
3
- } from "../chunk-9j36zvn4.js";
4
- import"../chunk-hqx7j9xe.js";
5
- import"../chunk-3dt25p98.js";
3
+ } from "../chunk-94anqtxh.js";
4
+ import"../chunk-fnc291vk.js";
6
5
  import {
7
6
  S3Client
8
7
  } from "../chunk-zp1s833x.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compforge/harness-toolbox",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Shared infrastructure clients, transports and bounded data capture",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -83,6 +83,7 @@
83
83
  "README.md"
84
84
  ],
85
85
  "dependencies": {
86
+ "@compforge/harness-common": "0.0.1",
86
87
  "@aws-sdk/client-s3": "3.1133.0",
87
88
  "@kubernetes/client-node": "1.4.0",
88
89
  "@opensearch-project/opensearch": "3.6.0",
@@ -1,59 +0,0 @@
1
- // src/client-manager.ts
2
- class ClientManager {
3
- #controller = new AbortController;
4
- signal;
5
- #clients = new Map;
6
- #ready = [];
7
- #disposal;
8
- constructor(signal) {
9
- this.signal = signal ? AbortSignal.any([signal, this.#controller.signal]) : this.#controller.signal;
10
- }
11
- get(source) {
12
- this.signal.throwIfAborted();
13
- const existing = this.#clients.get(source.key);
14
- if (existing)
15
- return existing;
16
- const pending = Promise.resolve().then(async () => {
17
- this.signal.throwIfAborted();
18
- const client = source.createClient(this.signal);
19
- try {
20
- await client.initialize();
21
- this.signal.throwIfAborted();
22
- this.#ready.push(client);
23
- return client;
24
- } catch (error) {
25
- try {
26
- await client.dispose();
27
- } catch (cleanup) {
28
- throw new AggregateError([error, cleanup], "Client initialization and cleanup failed");
29
- }
30
- throw error;
31
- }
32
- });
33
- this.#clients.set(source.key, pending);
34
- pending.catch(() => {
35
- if (this.#clients.get(source.key) === pending)
36
- this.#clients.delete(source.key);
37
- });
38
- return pending;
39
- }
40
- dispose() {
41
- return this.#disposal ??= (async () => {
42
- this.#controller.abort(new Error("Client manager disposed"));
43
- const settled = await Promise.allSettled(this.#clients.values());
44
- this.#clients.clear();
45
- const errors = settled.flatMap((result) => result.status === "rejected" && result.reason instanceof AggregateError ? [result.reason] : []);
46
- for (const client of this.#ready.splice(0).reverse()) {
47
- try {
48
- await client.dispose();
49
- } catch (error) {
50
- errors.push(error);
51
- }
52
- }
53
- if (errors.length)
54
- throw new AggregateError(errors, "Client cleanup failed");
55
- })();
56
- }
57
- }
58
-
59
- export { ClientManager };
@@ -1,14 +0,0 @@
1
- // src/datasource.ts
2
- import { createHash } from "node:crypto";
3
- function dataSourceKey(protocol, configuration) {
4
- const canonical = (value) => {
5
- if (Array.isArray(value))
6
- return value.map(canonical);
7
- if (value && typeof value === "object")
8
- return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)]));
9
- return value;
10
- };
11
- return `${protocol}:${createHash("sha256").update(JSON.stringify(canonical(configuration))).digest("hex")}`;
12
- }
13
-
14
- export { dataSourceKey };
@@ -1,3 +1,6 @@
1
+ import {
2
+ PortForwardScope
3
+ } from "./chunk-hv2kx47y.js";
1
4
  import {
2
5
  findPodsForService,
3
6
  findService,
@@ -6,9 +9,6 @@ import {
6
9
  import {
7
10
  findPod
8
11
  } from "./chunk-6fnhzhdb.js";
9
- import {
10
- PortForwardScope
11
- } from "./chunk-hv2kx47y.js";
12
12
 
13
13
  // src/kubernetes/service-port-forward.ts
14
14
  class ServicePortForwarder {