@sibylline-labs/eris 0.0.0-stage → 0.3.1
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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +146 -2
- package/bin/eris.ts +42 -0
- package/docs/ARCHITECTURE.md +188 -0
- package/docs/COMPLETION.md +106 -0
- package/docs/ERRORS.md +632 -0
- package/docs/OUTPUT-CONTRACT.md +219 -0
- package/docs/README.md +30 -0
- package/docs/UPDATE.md +159 -0
- package/docs/YAK.md +395 -0
- package/docs/dev/ADDING-A-COMMAND.md +79 -0
- package/docs/dev/DEVELOPMENT.md +88 -0
- package/docs/dev/LAYOUT.md +74 -0
- package/docs/dev/PLUGINS.md +382 -0
- package/docs/dev/RELEASING.md +157 -0
- package/docs/dev/TESTING.md +84 -0
- package/docs/dev/surfaces/secrets.md +95 -0
- package/package.json +55 -4
- package/packages/yak-contract/package.json +28 -0
- package/packages/yak-contract/schema/yak-v2.schema.json +416 -0
- package/packages/yak-contract/src/index.ts +1 -0
- package/packages/yak-contract/src/v2.ts +375 -0
- package/plugins/index.ts +12 -0
- package/plugins/yak/handlers.ts +128 -0
- package/plugins/yak/index.ts +51 -0
- package/plugins/yak/login.ts +300 -0
- package/plugins/yak/yak-context.ts +280 -0
- package/plugins/yak/yak-identity.ts +399 -0
- package/plugins/yak/yak-redact.ts +686 -0
- package/plugins/yak/yak-setup.ts +133 -0
- package/plugins/yak/yak-submit.ts +1166 -0
- package/plugins/yak/yak.ts +485 -0
- package/src/build.ts +248 -0
- package/src/cli/args.ts +434 -0
- package/src/cli/commands/completion/bash.ts +24 -0
- package/src/cli/commands/completion/domain.ts +203 -0
- package/src/cli/commands/completion/emit.ts +246 -0
- package/src/cli/commands/completion/shell.ts +24 -0
- package/src/cli/commands/completion/zsh.ts +24 -0
- package/src/cli/commands/completion.ts +48 -0
- package/src/cli/commands/update/github.ts +328 -0
- package/src/cli/commands/update/opt-in.ts +32 -0
- package/src/cli/commands/update/refusal.ts +21 -0
- package/src/cli/commands/update/release.ts +351 -0
- package/src/cli/commands/update/replace.ts +143 -0
- package/src/cli/commands/update/semver.ts +48 -0
- package/src/cli/commands/update.ts +47 -0
- package/src/cli/commands/usage.ts +68 -0
- package/src/cli/commands/version.ts +29 -0
- package/src/cli/compose.ts +273 -0
- package/src/cli/output.ts +81 -0
- package/src/cli/plugin.ts +85 -0
- package/src/cli/registry.ts +180 -0
- package/src/cli/router.ts +31 -0
- package/src/cli/ts-verb.ts +126 -0
- package/src/main.ts +223 -0
- package/src/runtime/env.ts +16 -0
- package/src/runtime/eris-assets.d.ts +5 -0
- package/src/runtime/home.ts +306 -0
- package/src/runtime/logging.ts +43 -0
- package/src/runtime/process.ts +388 -0
- package/src/runtime/settings.ts +191 -0
- package/src/sdk.ts +62 -0
- package/testing/build-child.ts +55 -0
- package/testing/fake-runner.ts +23 -0
- package/testing/index.ts +16 -0
- package/ts/lib/eris-fail.ts +180 -0
- package/ts/lib/pool.ts +63 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* build-child.ts — `build()` in a child bun process, for a suite that needs a
|
|
3
|
+
* compiled binary.
|
|
4
|
+
*
|
|
5
|
+
* A compile in the test process is not repeatable across test files: on Linux
|
|
6
|
+
* (bun 1.3.13, linux-x64 runner and a linux container) a later file's
|
|
7
|
+
* `Bun.build` fails with `EBADF` / `Unexpected reading file` on a module an
|
|
8
|
+
* earlier file's compile read, while each file alone passes. One process per
|
|
9
|
+
* build shares no bundler state with the suite or with another build.
|
|
10
|
+
*
|
|
11
|
+
* buildInChild({ module: join(root, "build.ts"), outfile, tag: "v0.0.1" })
|
|
12
|
+
*
|
|
13
|
+
* `module` is the file of a build, an absolute path to a module that exports
|
|
14
|
+
* `BUILD`, the `BuildOptions` to build with (examples/distribution/build.ts and
|
|
15
|
+
* scripts/build.ts are two). The rest of the spec is passed to `build()` over
|
|
16
|
+
* those. Refusals that throw before bundling still run in-process: they never
|
|
17
|
+
* reach `Bun.build`.
|
|
18
|
+
*
|
|
19
|
+
* Like `bun test` itself this is for a checkout: the child runs the build
|
|
20
|
+
* module under the bun that runs the tests. Never import it from the program.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
import { dirname, isAbsolute } from "node:path";
|
|
25
|
+
import { build, type BuildOptions, type BuildResult } from "../src/build";
|
|
26
|
+
|
|
27
|
+
export interface ChildBuild {
|
|
28
|
+
/** Absolute path of a module exporting `BUILD: BuildOptions`. */
|
|
29
|
+
readonly module: string;
|
|
30
|
+
readonly outfile: string;
|
|
31
|
+
readonly tag?: string;
|
|
32
|
+
readonly releaseRepo?: string;
|
|
33
|
+
readonly versionExtra?: string;
|
|
34
|
+
readonly docsBase?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Run one build in a fresh bun; its result, or the child's stderr as the error. */
|
|
38
|
+
export function buildInChild(spec: ChildBuild): BuildResult {
|
|
39
|
+
if (!isAbsolute(spec.module)) throw new Error(`buildInChild: module must be an absolute path: ${JSON.stringify(spec.module)}`);
|
|
40
|
+
const r = spawnSync(process.execPath, ["--no-env-file", "--config=/dev/null", import.meta.path, JSON.stringify(spec)], {
|
|
41
|
+
cwd: dirname(spec.module),
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
timeout: 170_000,
|
|
44
|
+
});
|
|
45
|
+
if (r.status !== 0) throw new Error(`build in a child bun failed (status ${r.status}, signal ${r.signal}):\n${r.stderr}`);
|
|
46
|
+
return JSON.parse(r.stdout) as BuildResult;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (import.meta.main) {
|
|
50
|
+
const { module, ...rest } = JSON.parse(process.argv[2] ?? "{}") as ChildBuild;
|
|
51
|
+
const { BUILD } = (await import(module)) as { BUILD?: BuildOptions };
|
|
52
|
+
if (BUILD === undefined) throw new Error(`${module} does not export BUILD`);
|
|
53
|
+
const r = await build({ ...BUILD, ...rest });
|
|
54
|
+
process.stdout.write(`${JSON.stringify(r)}\n`);
|
|
55
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fake-runner.ts — a `Runner` that records every call and answers from a
|
|
3
|
+
* function, so a test injects one instead of stubbing PATH
|
|
4
|
+
* (src/runtime/process.ts holds the real adapter and the interface).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RunOptions, RunResult, Runner } from "../src/runtime/process";
|
|
8
|
+
|
|
9
|
+
export type FakeRunner = Runner & { calls: Array<{ argv: readonly string[]; opts: RunOptions }> };
|
|
10
|
+
|
|
11
|
+
/** A Runner that records every call and answers from `respond`. */
|
|
12
|
+
export function createFakeRunner(
|
|
13
|
+
respond: (argv: readonly string[], opts: RunOptions) => RunResult | Promise<RunResult>,
|
|
14
|
+
): FakeRunner {
|
|
15
|
+
const calls: FakeRunner["calls"] = [];
|
|
16
|
+
return {
|
|
17
|
+
calls,
|
|
18
|
+
async run(argv, opts) {
|
|
19
|
+
calls.push({ argv, opts });
|
|
20
|
+
return respond(argv, opts);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
package/testing/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `eris/sdk/testing`: the test helpers a distribution's own suites reuse
|
|
3
|
+
* instead of copying (docs/dev/PLUGINS.md#testing-a-distribution).
|
|
4
|
+
*
|
|
5
|
+
* - `createFakeRunner`, `FakeRunner`: a `Runner` (eris/sdk) that records its
|
|
6
|
+
* calls and answers from a function;
|
|
7
|
+
* - `buildInChild`: `build()` in a child bun, one process per compile.
|
|
8
|
+
*
|
|
9
|
+
* Separate from `eris/sdk` and outside src/ so a binary never carries it: the
|
|
10
|
+
* `runtime-imports-no-testing` rule keeps src/, ts/ and plugins/ from importing
|
|
11
|
+
* it, and `distribution-imports-sdk-only` does the same for a distribution's
|
|
12
|
+
* source in this repository.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export { buildInChild, type ChildBuild } from "./build-child";
|
|
16
|
+
export { createFakeRunner, type FakeRunner } from "./fake-runner";
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* eris-fail.ts — the `ERROR`/`slug:`/`fix:`/`docs:` failure block, in TS.
|
|
3
|
+
*
|
|
4
|
+
* The block predates this module: an earlier Bash implementation of eris
|
|
5
|
+
* printed it (`eris::report`), and this module reproduces it BYTE FOR BYTE
|
|
6
|
+
* for the same inputs, because `docs/OUTPUT-CONTRACT.md` §4 says the shape is
|
|
7
|
+
* a **contract**: consumers line-prefix-parse it, and a re-implementation is
|
|
8
|
+
* required to *reproduce* the grammar, never to re-derive it.
|
|
9
|
+
* `tests/eris-fail.test.ts` proves byte-identity against what that Bash
|
|
10
|
+
* implementation printed, captured in a real bash subprocess and frozen
|
|
11
|
+
* (tests/helpers/frozen-oracle.ts) — not by eyeballing the two
|
|
12
|
+
* implementations side by side.
|
|
13
|
+
*
|
|
14
|
+
* The grammar (docs/OUTPUT-CONTRACT.md §4), reproduced here exactly:
|
|
15
|
+
*
|
|
16
|
+
* ERROR <what failed>
|
|
17
|
+
* slug: <slug>
|
|
18
|
+
* fix: <line> — zero or more, one per line of the fix text
|
|
19
|
+
* docs: <local path> — $ERIS_HOME/<relpath>
|
|
20
|
+
* docs: <github url> — <docs base>/<relpath>
|
|
21
|
+
*
|
|
22
|
+
* The docs base is https://github.com/sibylline-labs/eris/blob/main unless a
|
|
23
|
+
* distribution named its own: baked by `build({ docsBase })` (src/build.ts), or
|
|
24
|
+
* handed to `runProgram({ identity })` for a run from source (`identity()` in
|
|
25
|
+
* src/runtime/home.ts).
|
|
26
|
+
*
|
|
27
|
+
* All on stderr; stdout is never touched by anything in this file. Field
|
|
28
|
+
* order is fixed, every prefix carries its single trailing space, and the
|
|
29
|
+
* two `docs:` lines are always both present (local first). `<relpath>`
|
|
30
|
+
* defaults to `docs/ERRORS.md#<slug>`, exactly matching the Bash
|
|
31
|
+
* `eris::report`'s `docs="${4:-docs/ERRORS.md#$1}"`.
|
|
32
|
+
*
|
|
33
|
+
* The `fix:` line-splitting mirrors a bash quirk deliberately, not
|
|
34
|
+
* accidentally: `eris::report` feeds `$fix` through
|
|
35
|
+
* `<<< "$fix"` into a `while IFS= read -r _line || [[ -n "$_line" ]]`
|
|
36
|
+
* loop. A here-string ALWAYS appends one `\n` to its content before
|
|
37
|
+
* feeding it to the loop, so the loop's real input is `${fix}\n` — and
|
|
38
|
+
* `read` terminates the instant it hits EOF with a truly empty buffer,
|
|
39
|
+
* never on the placeholder `\n` it appended. Reproducing that requires the
|
|
40
|
+
* same transform: append one `\n`, split on `\n`, and drop exactly the
|
|
41
|
+
* one trailing empty element the appended `\n` introduces (see
|
|
42
|
+
* `splitFixLines` below). That transform is equal to a plain
|
|
43
|
+
* `fix.split("\n")` for every non-empty `fix` (a trailing `\n` yields one
|
|
44
|
+
* trailing empty `fix: ` line either way — mutation-checked); the
|
|
45
|
+
* only case that needs care is the EMPTY fix, which emits no `fix:` line at
|
|
46
|
+
* all. `tests/eris-fail-golden.test.ts` compares every documented slug,
|
|
47
|
+
* including a trailing-newline fix and an empty fix, against the real bash.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { docsRoot, identity, installRoot, installRootIsUnwritten } from "../../src/runtime/home";
|
|
51
|
+
|
|
52
|
+
export interface ErisFailureInput {
|
|
53
|
+
/** Stable machine key, `^[a-z0-9]+(-[a-z0-9]+)+$` — a `docs/ERRORS.md`
|
|
54
|
+
* heading anchor. `tests/errors-doc.test.ts` harvests every `slug: "…"`
|
|
55
|
+
* literal and fails when its heading is missing. */
|
|
56
|
+
slug: string;
|
|
57
|
+
/** The `ERROR <what>` line's message. Never a secret value — name the
|
|
58
|
+
* slot/path being resolved, per `docs/OUTPUT-CONTRACT.md` §4. */
|
|
59
|
+
what: string;
|
|
60
|
+
/** Zero or more `fix:` lines, `\n`-joined — mirrors `eris::report`'s
|
|
61
|
+
* third positional exactly (multi-line fixes are literal, indentation
|
|
62
|
+
* included). Omit or pass `""` for no fix lines at all. */
|
|
63
|
+
fix?: string;
|
|
64
|
+
/** Repo-relative doc path, optionally with a `#anchor`. Defaults to
|
|
65
|
+
* `docs/ERRORS.md#<slug>`, same as bash's `"${4:-docs/ERRORS.md#$1}"`. */
|
|
66
|
+
docs?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `$ERIS_HOME`: `ERIS_DIR` wins when set; otherwise the install root (src/runtime/home.ts): the
|
|
71
|
+
* checkout this file sits in, a distribution's own root, or a compiled binary's
|
|
72
|
+
* asset home. The local `docs:` line names the doc under that root, or under this
|
|
73
|
+
* package's when a distribution run from source does not carry it (`docsRoot`), so it
|
|
74
|
+
* names a file that exists every way eris runs.
|
|
75
|
+
*/
|
|
76
|
+
export function erisHome(env: Record<string, string | undefined> = process.env): string {
|
|
77
|
+
const override = env.ERIS_DIR;
|
|
78
|
+
if (override && override.length > 0) return override;
|
|
79
|
+
return installRoot();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Split a `fix:` block into its per-line renderings, reproducing
|
|
84
|
+
* `eris::report`'s here-string + `read` loop byte for byte (see file
|
|
85
|
+
* header). Returns `[]` for an absent/empty `fix` — matching bash's
|
|
86
|
+
* `if [[ -n "$fix" ]]` guard, which skips the loop entirely rather than
|
|
87
|
+
* emitting one empty `fix: ` line.
|
|
88
|
+
*/
|
|
89
|
+
export function splitFixLines(fix: string | undefined): string[] {
|
|
90
|
+
if (!fix || fix.length === 0) return [];
|
|
91
|
+
// Simulate the here-string's appended `\n`, split, then drop the ONE
|
|
92
|
+
// trailing empty element that append introduces — never a real line.
|
|
93
|
+
const withTrailingNewline = `${fix}\n`;
|
|
94
|
+
const parts = withTrailingNewline.split("\n");
|
|
95
|
+
parts.pop();
|
|
96
|
+
return parts;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The failure block's lines, in order, with NO trailing `\n` on any of
|
|
100
|
+
* them — `renderFailureBlock` is what adds one per line. Exposed
|
|
101
|
+
* separately so a test can assert on individual lines without
|
|
102
|
+
* re-parsing the joined block.
|
|
103
|
+
*
|
|
104
|
+
* With no local copy of the docs to name (`home` null, or the install root of a
|
|
105
|
+
* compiled binary whose asset home could not be written, `eris-cache-unwritable`),
|
|
106
|
+
* the first `docs:` line is the repo-relative
|
|
107
|
+
* path, so the block keeps its two `docs:` lines and the GitHub one stays the
|
|
108
|
+
* readable one. */
|
|
109
|
+
export function failureLines(input: ErisFailureInput, home: string | null): string[] {
|
|
110
|
+
const lines: string[] = [];
|
|
111
|
+
lines.push(`ERROR ${input.what}`);
|
|
112
|
+
lines.push(`slug: ${input.slug}`);
|
|
113
|
+
for (const fixLine of splitFixLines(input.fix)) {
|
|
114
|
+
lines.push(`fix: ${fixLine}`);
|
|
115
|
+
}
|
|
116
|
+
const docsRel = input.docs && input.docs.length > 0 ? input.docs : `docs/ERRORS.md#${input.slug}`;
|
|
117
|
+
const local = home === null || (installRootIsUnwritten() && home === installRoot()) ? null : home;
|
|
118
|
+
lines.push(`docs: ${local === null ? docsRel : `${docsRoot(local, docsRel)}/${docsRel}`}`);
|
|
119
|
+
lines.push(`docs: ${identity().docsBase}/${docsRel}`);
|
|
120
|
+
return lines;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The full block, `\n`-terminated on every line — byte-identical to what
|
|
124
|
+
* `eris::report`/`eris::fail` writes to stderr for the same inputs. */
|
|
125
|
+
export function renderFailureBlock(input: ErisFailureInput, home: string | null = erisHome()): string {
|
|
126
|
+
return failureLines(input, home)
|
|
127
|
+
.map((line) => `${line}\n`)
|
|
128
|
+
.join("");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A write-only stream: process.stderr, or a capture in tests. */
|
|
132
|
+
export interface FailureSink {
|
|
133
|
+
write(chunk: string): unknown;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The TS `eris::report`: write the block and RETURN 1
|
|
138
|
+
* instead of exiting, for a caller that owns something which must run after
|
|
139
|
+
* the failure (e.g. a failed WAL result after a failed write). Prefer
|
|
140
|
+
* `emitFailureAndExit`; the caller of this adds its own summary.
|
|
141
|
+
* `tests/eris-fail-golden.test.ts` pins it against every documented slug.
|
|
142
|
+
*/
|
|
143
|
+
export function reportFailure(
|
|
144
|
+
input: ErisFailureInput,
|
|
145
|
+
home: string = erisHome(),
|
|
146
|
+
sink: FailureSink = process.stderr,
|
|
147
|
+
): 1 {
|
|
148
|
+
sink.write(renderFailureBlock(input, home));
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Write the block to stderr and exit 1 — the TS `eris::fail` equivalent.
|
|
153
|
+
* Stdout is never touched (§1 of the output contract: a failure writes
|
|
154
|
+
* nothing to stdout). */
|
|
155
|
+
export function emitFailureAndExit(
|
|
156
|
+
input: ErisFailureInput,
|
|
157
|
+
home: string = erisHome(),
|
|
158
|
+
sink: FailureSink = process.stderr,
|
|
159
|
+
exit: (code: number) => never = (code) => process.exit(code),
|
|
160
|
+
): never {
|
|
161
|
+
sink.write(renderFailureBlock(input, home));
|
|
162
|
+
return exit(1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The `WARN` line `eris yak` emits for its environment/config/
|
|
167
|
+
* transport outcomes: concise, on
|
|
168
|
+
* stderr, exit 0 — never the four-line failure block, which is reserved
|
|
169
|
+
* for agent misuse. Two lines, not a bare `WARN <reason>`: the slug alone
|
|
170
|
+
* is what makes an unset-token/unreachable-endpoint/timeout warning
|
|
171
|
+
* machine-legible on stderr without promoting it to the exit-1 failure
|
|
172
|
+
* contract, and `docs/ERRORS.md`'s per-slug headings apply.
|
|
173
|
+
*/
|
|
174
|
+
export function renderWarnLines(reason: string, slug: string): string {
|
|
175
|
+
return `WARN ${reason}\nslug: ${slug}\n`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function emitWarn(reason: string, slug: string): void {
|
|
179
|
+
process.stderr.write(renderWarnLines(reason, slug));
|
|
180
|
+
}
|
package/ts/lib/pool.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pool.ts — env-driven concurrency/deadline resolution, and the in-process
|
|
3
|
+
* bounded-concurrency runner.
|
|
4
|
+
*
|
|
5
|
+
* `ERIS_BATCH_CONCURRENCY` caps how many tasks run at once and
|
|
6
|
+
* `ERIS_BATCH_TIMEOUT` (seconds) bounds each one. An unset, non-numeric, zero
|
|
7
|
+
* or negative value CLAMPS to the default rather than disabling the bound:
|
|
8
|
+
* a typo in an env var must never turn a large batch into an unbounded
|
|
9
|
+
* fan-out.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Digits only — no sign, no decimal point. An empty, non-numeric, zero or
|
|
13
|
+
* negative value clamps to 8 rather than disabling the cap. */
|
|
14
|
+
export function resolveConcurrency(raw: string | undefined): number {
|
|
15
|
+
if (raw !== undefined && /^[0-9]+$/.test(raw)) {
|
|
16
|
+
const n = Number(raw);
|
|
17
|
+
if (n > 0) return n;
|
|
18
|
+
}
|
|
19
|
+
return 8;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Same integer-seconds grammar, converted to milliseconds for `AbortSignal`.
|
|
23
|
+
* Defaults to 60 seconds. Invalid
|
|
24
|
+
* input clamps to the default rather than producing NaN (which would mean
|
|
25
|
+
* "no deadline at all" — the opposite of the safety property a timeout
|
|
26
|
+
* exists to provide). */
|
|
27
|
+
export function resolveDeadlineMs(raw: string | undefined): number {
|
|
28
|
+
if (raw !== undefined && /^[0-9]+$/.test(raw)) {
|
|
29
|
+
const n = Number(raw);
|
|
30
|
+
if (n > 0) return n * 1000;
|
|
31
|
+
}
|
|
32
|
+
return 60_000;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run `worker` over every item in `items`, at most `concurrency` in flight
|
|
37
|
+
* at once, preserving input order in the returned array regardless of
|
|
38
|
+
* completion order. A thrown worker error propagates (rejects the whole
|
|
39
|
+
* pool) rather than being swallowed — callers that want per-item
|
|
40
|
+
* ok/error/absent results encode that in R itself, so a THROW here always
|
|
41
|
+
* means a programming error, not a classified task failure.
|
|
42
|
+
*/
|
|
43
|
+
export async function runPool<T, R>(
|
|
44
|
+
items: readonly T[],
|
|
45
|
+
concurrency: number,
|
|
46
|
+
worker: (item: T, index: number) => Promise<R>
|
|
47
|
+
): Promise<R[]> {
|
|
48
|
+
const results: R[] = new Array(items.length);
|
|
49
|
+
if (items.length === 0) return results;
|
|
50
|
+
|
|
51
|
+
let next = 0;
|
|
52
|
+
async function runner(): Promise<void> {
|
|
53
|
+
while (true) {
|
|
54
|
+
const i = next++;
|
|
55
|
+
if (i >= items.length) return;
|
|
56
|
+
results[i] = await worker(items[i] as T, i);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const workerCount = Math.max(1, Math.min(concurrency, items.length));
|
|
61
|
+
await Promise.all(Array.from({ length: workerCount }, () => runner()));
|
|
62
|
+
return results;
|
|
63
|
+
}
|