diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b957ad..57ad35c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,34 @@ jobs: - name: Conformance run: python conformance/run.py + typescript: + name: TypeScript runtime + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/codeupipe-ts + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - uses: actions/setup-node@v5 + with: + node-version: "24" + - name: Install + run: npm ci + - name: Type check + run: npm run typecheck + - name: Tests + run: npm test + - name: Build + run: npm run build + - name: Example + run: node examples/pricing/main.ts + - name: Conformance + working-directory: . + run: python conformance/run.py typescript:run + export: name: Export against dynamic run runs-on: ubuntu-latest diff --git a/README.md b/README.md index bebed8c..43f8f78 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # codeupipe -codeupipe is a Python library for writing a program as a pipeline of small -steps. +codeupipe is a library for writing a program as a pipeline of small steps. +It is for Python. A TypeScript runtime is in this repository and is not on +npm yet. There are eight primitives. Each one does one thing. @@ -28,12 +29,13 @@ If you know Unix, these are close to things you know. | Hook | A git hook: code that runs before and after a step | | State | `$?` and `PIPESTATUS` | -This repository has two packages. +This repository has three packages. | Package | Contents | Reference | |---|---|---| | `codeupipe` | The eight primitives. An export command that writes a pipeline as plain Python with no import of codeupipe. A config file reader. No dependencies | [conformance/API.md](conformance/API.md), [docs/static-export.md](docs/static-export.md), [docs/config.md](docs/config.md) | | `codeupipe-lint` | Two linters for pipeline code. Depends on `codeupipe` only | [packages/codeupipe-lint/README.md](packages/codeupipe-lint/README.md) | +| `codeupipe` for TypeScript | The eight primitives. No dependencies. No export and no linter | [packages/codeupipe-ts/README.md](packages/codeupipe-ts/README.md) | ## Install @@ -150,7 +152,7 @@ An editable install needs pip 21.3 or later. | Command | What it does | |---|---| | `python -m pytest tests -q` | Runs the tests | -| `python conformance/run.py` | Runs the 27 conformance cases against the dynamic run and the three exports | +| `python conformance/run.py` | Runs the 27 conformance cases against the Python dynamic run and the three exports, and against the TypeScript run when the machine has Node 22.18 or later | | `python -m codeupipe_lint.anonymous_steps` | Runs the anonymous-step linter. Paths come from `cup.config.json` | | `python -m codeupipe_lint.invariants` | Runs the invariant linter. Paths come from `cup.config.json` | | `python -m codeupipe.lower examples.pricing.pricing_pipeline:PricingPipeline --out build/pricing` | Exports the example to `build/pricing/src/steps/` | @@ -163,8 +165,9 @@ An editable install needs pip 21.3 or later. |---|---| | `packages/codeupipe/` | The `codeupipe` package: `core/`, `lower/` (the export), `config.py` | | `packages/codeupipe-lint/` | The `codeupipe-lint` package: `anonymous_steps/`, `invariants/` | -| `conformance/` | The contract ([API.md](conformance/API.md)), the cases, the runner, the benchmark | -| `tests/` | The tests for the two packages | +| `packages/codeupipe-ts/` | The TypeScript runtime, with its tests and its example | +| `conformance/` | The contract ([API.md](conformance/API.md)), the cases, the runners, the benchmark | +| `tests/` | The tests for the two Python packages | | `examples/pricing/` | The example, with its test in `tests/examples/` | | `scorecard/` | The comparison of the export with the dynamic run on random pipelines | | `docs/` | Reference for the export and the config file | @@ -173,9 +176,11 @@ The root `pyproject.toml` is not a package. It holds the test settings. ## Other languages -This repository is Python only. The contract and the conformance cases do -not depend on Python. A runtime in another language must implement the -contract and add a conformance runner. +The contract and the conformance cases do not depend on a language. Python +is the first runtime and TypeScript is the second +([packages/codeupipe-ts/](packages/codeupipe-ts/)). The export and the +linters are for Python only. A runtime in another language must implement +the contract and add a conformance runner. ## License diff --git a/conformance/API.md b/conformance/API.md index d909af5..876fec8 100644 --- a/conformance/API.md +++ b/conformance/API.md @@ -14,9 +14,10 @@ parameters, their defaults, and how they fail. | State | records what happened | | Pipeline | runs the steps in order | -Python implements it. A runtime in another language implements the same -contract, with identifier casing in that language's style, and is judged by -the same conformance cases. +Python and TypeScript implement it. Each runtime has the same contract, +with identifier casing in the style of its language, and the same +conformance cases judge it. The tables below show the Python names. The +TypeScript names and differences are in "Runtimes". ## Principles @@ -78,7 +79,7 @@ Rules: `test(payload) -> bool`. -- A Predicate is an object. It is identified by its class like a step, and +- A Predicate is an object. Its class gives it its name, and any setting (a threshold, a flag) is on the instance: `AtLeastPredicate("qty", 10)`. Functions, lambdas and closures are not Predicates. @@ -202,3 +203,29 @@ map and fails the comparison. it changes timing for callers who wait on a Tap's side effect. - **Other runtimes.** A runtime in another language is written to this contract and added with its conformance runner. + +## Runtimes + +| | Python | TypeScript | +|---|---|---| +| Package | `packages/codeupipe` | `packages/codeupipe-ts` | +| Names | snake_case | camelCase: `addFilter`, `addValve`, `toDict`, `withTrace`, `traceId`, `onError` | +| Run | `run` is sync. `run_async` is opt-in | One `run`. It returns a Promise. A step can be sync or async | +| Absent | `None` | `null` | +| Label of a step | `type(step).__name__` | `step.constructor.name` | +| Export | `codeupipe.lower` | none | +| Linter | `codeupipe-lint` | none | + +Notes on TypeScript: + +- **Principle 3.** JavaScript always has an event loop, so there is no + second entry point. Inside `run`, a step that returns a value and not a + Promise is not awaited. +- **A function is not a step.** Every JavaScript function has a `call` + method, so a step must be an object. +- **Labels in minified code.** A minifier changes class names. A label is + then the changed name, unless the build keeps class names. A class with no + name has the label `anonymous`. +- **State and concurrent runs.** Two runs of one pipeline at the same time + each write to their own State. `state` is the State of the run that + started last. diff --git a/conformance/MATRIX.md b/conformance/MATRIX.md index 7be1505..7771eba 100644 --- a/conformance/MATRIX.md +++ b/conformance/MATRIX.md @@ -5,47 +5,49 @@ live copy is `results/latest.md` (not committed). The command exits non-zero if any target has a ❌. **Every target passes every case, and nothing is unsupported.** The targets -are the dynamic run, the async run, and the three static exports. +are the Python dynamic run, async run and three static exports, and the +TypeScript run. The two fast exports (`static-steps`, `static-inline`) expose no State or Hooks, so they pass those cases as ✅\*. `static-observe` (export with `observe=True`) records both and passes every case outright. See [docs/static-export.md](../docs/static-export.md). -| case | python:run | python:run_async | python:static-steps | python:static-inline | python:static-observe | -|---|---|---|---|---|---| -| `hook/lifecycle-on-success` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `hook/on-error-when-a-filter-fails` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `hook/not-notified-of-a-tap-failure` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `hook/on-error-names-a-failing-valve` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `payload/insert-adds-a-key` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/insert-overwrites` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/missing-key-reads-as-null` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/nested-input-is-never-mutated` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/json-value-types-round-trip` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/remove-drops-a-key` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/remove-of-a-missing-key-is-a-no-op` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/merge-right-side-wins` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/has-reports-presence-even-for-null` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `payload/mutable-escape-hatch-sets-in-place` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `pipeline/empty-returns-input` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `pipeline/steps-run-in-order` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `pipeline/filter-error-halts-the-run` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `pipeline/nested-pipeline-is-a-step` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `tap/observes-without-changing-output` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `tap/failure-never-halts-the-pipeline` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `tap/failure-does-not-stop-later-taps` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `tap/sees-payload-after-a-skipped-valve` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `valve/runs-when-predicate-true` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `valve/skips-when-predicate-false` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `valve/missing-key-is-falsy` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `valve/sees-upstream-changes` | ✅ | ✅ | ✅* | ✅* | ✅ | -| `valve/independent-gates` | ✅ | ✅ | ✅* | ✅* | ✅ | +| case | python:run | python:run_async | python:static-steps | python:static-inline | python:static-observe | typescript:run | +|---|---|---|---|---|---|---| +| `hook/lifecycle-on-success` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `hook/on-error-when-a-filter-fails` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `hook/not-notified-of-a-tap-failure` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `hook/on-error-names-a-failing-valve` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `payload/insert-adds-a-key` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/insert-overwrites` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/missing-key-reads-as-null` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/nested-input-is-never-mutated` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/json-value-types-round-trip` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/remove-drops-a-key` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/remove-of-a-missing-key-is-a-no-op` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/merge-right-side-wins` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/has-reports-presence-even-for-null` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `payload/mutable-escape-hatch-sets-in-place` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `pipeline/empty-returns-input` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `pipeline/steps-run-in-order` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `pipeline/filter-error-halts-the-run` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `pipeline/nested-pipeline-is-a-step` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `tap/observes-without-changing-output` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `tap/failure-never-halts-the-pipeline` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `tap/failure-does-not-stop-later-taps` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `tap/sees-payload-after-a-skipped-valve` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `valve/runs-when-predicate-true` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `valve/skips-when-predicate-false` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `valve/missing-key-is-falsy` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `valve/sees-upstream-changes` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | +| `valve/independent-gates` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ | - **python:run**: 27 pass, 0 pass*, 0 fail, 0 unsupported - **python:run_async**: 27 pass, 0 pass*, 0 fail, 0 unsupported - **python:static-steps**: 11 pass, 16 pass*, 0 fail, 0 unsupported - **python:static-inline**: 11 pass, 16 pass*, 0 fail, 0 unsupported - **python:static-observe**: 27 pass, 0 pass*, 0 fail, 0 unsupported +- **typescript:run**: 27 pass, 0 pass*, 0 fail, 0 unsupported ✅* passed everything the target can observe; it does not expose State or Hooks (the fast static export). ➖ the target cannot express the case. diff --git a/conformance/README.md b/conformance/README.md index 26cb6bd..43d2a75 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -42,6 +42,7 @@ load a number can leave the band. | `python:run`, `python:run_async` | `codeupipe.core` Pipeline, sync and async entry points | | `python:static-steps`, `python:static-inline` | `codeupipe.lower` fast export, loaded from disk (no State or Hooks: ✅\*) | | `python:static-observe` | `codeupipe.lower` export with `observe=True`: State and Hooks judged too | +| `typescript:run` | `packages/codeupipe-ts` Pipeline. Node runs the `.ts` runner as it is (Node 22.18 or later). With no arguments, `run.py` leaves this target out when the machine has no such Node | ## Statuses @@ -115,4 +116,8 @@ never halts the pipeline). Where it is silent, they encode Python 2. **Hooks around a skipped Valve.** Python fires `before:v` / `after:v` even when the Valve skips its filter. 3. **`equals` across types.** Python's `==` makes `1 == true`; JSON does not. + TypeScript uses `===`, which does not compare lists or maps by content. No case depends on it yet. +4. **`truthy` across languages.** An empty list or map is false in Python + and true in JavaScript. Each runner uses the rule of its language. No + case depends on it yet. diff --git a/conformance/run.py b/conformance/run.py index 1a11a15..3497db9 100644 --- a/conformance/run.py +++ b/conformance/run.py @@ -2,6 +2,10 @@ python conformance/run.py # all targets python conformance/run.py python:run python:static-inline + python conformance/run.py typescript:run + +With no arguments, the TypeScript target runs only when the machine has +Node 22.18 or later. A target that is named and cannot run is a failure. A runner executes cases and reports what happened. Only this file decides pass or fail, so every target is held to exactly the same comparison. @@ -18,6 +22,7 @@ import json import os import pathlib +import shutil import subprocess import sys @@ -37,15 +42,43 @@ def _python(target): } +def _typescript(target): + # Node runs the .ts file as it is. Nothing is built or downloaded. + return { + "cmd": ["node", str(HERE / "runners/typescript/runner.ts"), target], + "env": {}, + "cwd": REPO, + } + + +def _node_runs_typescript(): + """Whether there is a Node that runs a .ts file as it is (22.18 or later).""" + node = shutil.which("node") + if node is None: + return False + try: + version = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=30).stdout + major, minor = (int(part) for part in version.strip().lstrip("v").split(".")[:2]) + except (OSError, ValueError, subprocess.SubprocessError): + return False + return (major, minor) >= (22, 18) + + TARGETS = { "python:run": _python("run"), "python:run_async": _python("run_async"), "python:static-steps": _python("static-steps"), "python:static-inline": _python("static-inline"), "python:static-observe": _python("static-observe"), + "typescript:run": _typescript("run"), } +def default_targets(): + """Every target that this machine can run. TypeScript needs Node.""" + return [name for name in TARGETS if not name.startswith("typescript:") or _node_runs_typescript()] + + def load_cases(): cases = [] for path in sorted(CASES.glob("*.json")): @@ -130,7 +163,7 @@ def judge(case, result): def main(argv): cases = load_cases() - names = argv or list(TARGETS) + names = argv or default_targets() report = {"cases": len(cases), "targets": {}} matrix = {c["id"]: {} for c in cases} @@ -156,7 +189,7 @@ def main(argv): (RESULTS / "latest.json").write_text(json.dumps(report, indent=2)) (RESULTS / "latest.md").write_text(_markdown(cases, names, matrix, report)) print(_markdown(cases, names, matrix, report)) - failed = any(t.get("FAIL") for t in report["targets"].values()) + failed = any(t.get("FAIL") or "error" in t for t in report["targets"].values()) return 1 if failed else 0 diff --git a/conformance/runners/typescript/cup-ops/add-filter.ts b/conformance/runners/typescript/cup-ops/add-filter.ts new file mode 100644 index 0000000..7e9ec65 --- /dev/null +++ b/conformance/runners/typescript/cup-ops/add-filter.ts @@ -0,0 +1,16 @@ +import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** insert key = get(key, 0) + by */ +export class AddFilter implements Filter { + readonly key: string; + readonly by: number; + + constructor(key: string, by: number) { + this.key = key; + this.by = by; + } + + call(payload: Payload): Payload { + return payload.insert(this.key, payload.get(this.key, 0) + this.by); + } +} diff --git a/conformance/runners/typescript/cup-ops/append-filter.ts b/conformance/runners/typescript/cup-ops/append-filter.ts new file mode 100644 index 0000000..0bdd068 --- /dev/null +++ b/conformance/runners/typescript/cup-ops/append-filter.ts @@ -0,0 +1,17 @@ +import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** insert key = a copy of get(key), or of an empty list, with value added */ +export class AppendFilter implements Filter { + readonly key: string; + readonly value: unknown; + + constructor(key: string, value: unknown) { + this.key = key; + this.value = value; + } + + call(payload: Payload): Payload { + const list = payload.get(this.key) ?? []; + return payload.insert(this.key, [...list, this.value]); + } +} diff --git a/conformance/runners/typescript/cup-ops/copy-filter.ts b/conformance/runners/typescript/cup-ops/copy-filter.ts new file mode 100644 index 0000000..b0426db --- /dev/null +++ b/conformance/runners/typescript/cup-ops/copy-filter.ts @@ -0,0 +1,16 @@ +import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** insert to = get(from), null if missing */ +export class CopyFilter implements Filter { + readonly from: string; + readonly to: string; + + constructor(from: string, to: string) { + this.from = from; + this.to = to; + } + + call(payload: Payload): Payload { + return payload.insert(this.to, payload.get(this.from)); + } +} diff --git a/conformance/runners/typescript/cup-ops/equals-predicate.ts b/conformance/runners/typescript/cup-ops/equals-predicate.ts new file mode 100644 index 0000000..81e018b --- /dev/null +++ b/conformance/runners/typescript/cup-ops/equals-predicate.ts @@ -0,0 +1,16 @@ +import type { Payload, Predicate } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** get(key) == value */ +export class EqualsPredicate implements Predicate { + readonly key: string; + readonly value: unknown; + + constructor(key: string, value: unknown) { + this.key = key; + this.value = value; + } + + test(payload: Payload): boolean { + return payload.get(this.key) === this.value; + } +} diff --git a/conformance/runners/typescript/cup-ops/fail-filter.ts b/conformance/runners/typescript/cup-ops/fail-filter.ts new file mode 100644 index 0000000..29f5cf4 --- /dev/null +++ b/conformance/runners/typescript/cup-ops/fail-filter.ts @@ -0,0 +1,14 @@ +import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** throw an error that carries message */ +export class FailFilter implements Filter { + readonly message: string; + + constructor(message: string) { + this.message = message; + } + + call(_payload: Payload): Payload { + throw new Error(this.message); + } +} diff --git a/conformance/runners/typescript/cup-ops/fail-tap.ts b/conformance/runners/typescript/cup-ops/fail-tap.ts new file mode 100644 index 0000000..f9f40dc --- /dev/null +++ b/conformance/runners/typescript/cup-ops/fail-tap.ts @@ -0,0 +1,14 @@ +import type { Payload, Tap } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** throw */ +export class FailTap implements Tap { + readonly message: string; + + constructor(message: string) { + this.message = message; + } + + observe(_payload: Payload): void { + throw new Error(this.message); + } +} diff --git a/conformance/runners/typescript/cup-ops/has-filter.ts b/conformance/runners/typescript/cup-ops/has-filter.ts new file mode 100644 index 0000000..6527f5f --- /dev/null +++ b/conformance/runners/typescript/cup-ops/has-filter.ts @@ -0,0 +1,16 @@ +import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** insert to = has(key) */ +export class HasFilter implements Filter { + readonly key: string; + readonly to: string; + + constructor(key: string, to: string) { + this.key = key; + this.to = to; + } + + call(payload: Payload): Payload { + return payload.insert(this.to, payload.has(this.key)); + } +} diff --git a/conformance/runners/typescript/cup-ops/merge-filter.ts b/conformance/runners/typescript/cup-ops/merge-filter.ts new file mode 100644 index 0000000..8841cc6 --- /dev/null +++ b/conformance/runners/typescript/cup-ops/merge-filter.ts @@ -0,0 +1,15 @@ +import { Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; +import type { Filter } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** merge(Payload(value)) */ +export class MergeFilter implements Filter { + readonly value: Record; + + constructor(value: Record) { + this.value = value; + } + + call(payload: Payload): Payload { + return payload.merge(new Payload(this.value)); + } +} diff --git a/conformance/runners/typescript/cup-ops/mutate-filter.ts b/conformance/runners/typescript/cup-ops/mutate-filter.ts new file mode 100644 index 0000000..9acc3a2 --- /dev/null +++ b/conformance/runners/typescript/cup-ops/mutate-filter.ts @@ -0,0 +1,17 @@ +import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** insert(key, value) as a statement of its own, then return the payload */ +export class MutateFilter implements Filter { + readonly key: string; + readonly value: unknown; + + constructor(key: string, value: unknown) { + this.key = key; + this.value = value; + } + + call(payload: Payload): Payload { + payload.insert(this.key, this.value); + return payload; + } +} diff --git a/conformance/runners/typescript/cup-ops/nested-pipeline.ts b/conformance/runners/typescript/cup-ops/nested-pipeline.ts new file mode 100644 index 0000000..6db705d --- /dev/null +++ b/conformance/runners/typescript/cup-ops/nested-pipeline.ts @@ -0,0 +1,4 @@ +import { Pipeline } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** A named pipeline type, for a pipeline that is a step of another. */ +export class NestedPipeline extends Pipeline {} diff --git a/conformance/runners/typescript/cup-ops/noop-tap.ts b/conformance/runners/typescript/cup-ops/noop-tap.ts new file mode 100644 index 0000000..b9fbb55 --- /dev/null +++ b/conformance/runners/typescript/cup-ops/noop-tap.ts @@ -0,0 +1,6 @@ +import type { Payload, Tap } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** do nothing */ +export class NoopTap implements Tap { + observe(_payload: Payload): void {} +} diff --git a/conformance/runners/typescript/cup-ops/record-hook.ts b/conformance/runners/typescript/cup-ops/record-hook.ts new file mode 100644 index 0000000..057fced --- /dev/null +++ b/conformance/runners/typescript/cup-ops/record-hook.ts @@ -0,0 +1,26 @@ +import { Hook } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** + * Records before:, after: and error:. Events of the + * pipeline itself have an empty step. + */ +export class RecordHook extends Hook { + readonly events: string[]; + + constructor(events: string[]) { + super(); + this.events = events; + } + + override before(step: string | null): void { + this.events.push("before:" + (step ?? "")); + } + + override after(step: string | null): void { + this.events.push("after:" + (step ?? "")); + } + + override onError(step: string | null): void { + this.events.push("error:" + (step ?? "")); + } +} diff --git a/conformance/runners/typescript/cup-ops/record-tap.ts b/conformance/runners/typescript/cup-ops/record-tap.ts new file mode 100644 index 0000000..78dc53f --- /dev/null +++ b/conformance/runners/typescript/cup-ops/record-tap.ts @@ -0,0 +1,16 @@ +import type { Payload, Tap } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** append get(key) to the runner's tap log */ +export class RecordTap implements Tap { + readonly key: string; + readonly log: unknown[]; + + constructor(key: string, log: unknown[]) { + this.key = key; + this.log = log; + } + + observe(payload: Payload): void { + this.log.push(payload.get(this.key)); + } +} diff --git a/conformance/runners/typescript/cup-ops/remove-filter.ts b/conformance/runners/typescript/cup-ops/remove-filter.ts new file mode 100644 index 0000000..462c657 --- /dev/null +++ b/conformance/runners/typescript/cup-ops/remove-filter.ts @@ -0,0 +1,14 @@ +import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** remove(key) */ +export class RemoveFilter implements Filter { + readonly key: string; + + constructor(key: string) { + this.key = key; + } + + call(payload: Payload): Payload { + return payload.remove(this.key); + } +} diff --git a/conformance/runners/typescript/cup-ops/set-filter.ts b/conformance/runners/typescript/cup-ops/set-filter.ts new file mode 100644 index 0000000..75fe72a --- /dev/null +++ b/conformance/runners/typescript/cup-ops/set-filter.ts @@ -0,0 +1,16 @@ +import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** insert key = value */ +export class SetFilter implements Filter { + readonly key: string; + readonly value: unknown; + + constructor(key: string, value: unknown) { + this.key = key; + this.value = value; + } + + call(payload: Payload): Payload { + return payload.insert(this.key, this.value); + } +} diff --git a/conformance/runners/typescript/cup-ops/truthy-predicate.ts b/conformance/runners/typescript/cup-ops/truthy-predicate.ts new file mode 100644 index 0000000..066cb51 --- /dev/null +++ b/conformance/runners/typescript/cup-ops/truthy-predicate.ts @@ -0,0 +1,14 @@ +import type { Payload, Predicate } from "../../../../packages/codeupipe-ts/src/index.ts"; + +/** get(key) is truthy; a missing key is false */ +export class TruthyPredicate implements Predicate { + readonly key: string; + + constructor(key: string) { + this.key = key; + } + + test(payload: Payload): boolean { + return Boolean(payload.get(this.key)); + } +} diff --git a/conformance/runners/typescript/package.json b/conformance/runners/typescript/package.json new file mode 100644 index 0000000..e986b24 --- /dev/null +++ b/conformance/runners/typescript/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/conformance/runners/typescript/runner.ts b/conformance/runners/typescript/runner.ts new file mode 100644 index 0000000..ed36af6 --- /dev/null +++ b/conformance/runners/typescript/runner.ts @@ -0,0 +1,144 @@ +/** + * TypeScript conformance runner: runs the cases and reports what happened. + * + * node runner.ts run < cases.json > results.json + * + * The runner never judges; conformance/run.py compares the results of every + * target with the same expectations. Node runs this file as it is (Node + * 22.18 or later removes the types), so nothing is built or downloaded. + */ +import { readFileSync } from "node:fs"; + +import { Payload, Pipeline } from "../../../packages/codeupipe-ts/src/index.ts"; +import type { Filter, Predicate, Tap } from "../../../packages/codeupipe-ts/src/index.ts"; +import { AddFilter } from "./cup-ops/add-filter.ts"; +import { AppendFilter } from "./cup-ops/append-filter.ts"; +import { CopyFilter } from "./cup-ops/copy-filter.ts"; +import { EqualsPredicate } from "./cup-ops/equals-predicate.ts"; +import { FailFilter } from "./cup-ops/fail-filter.ts"; +import { FailTap } from "./cup-ops/fail-tap.ts"; +import { HasFilter } from "./cup-ops/has-filter.ts"; +import { MergeFilter } from "./cup-ops/merge-filter.ts"; +import { MutateFilter } from "./cup-ops/mutate-filter.ts"; +import { NestedPipeline } from "./cup-ops/nested-pipeline.ts"; +import { NoopTap } from "./cup-ops/noop-tap.ts"; +import { RecordHook } from "./cup-ops/record-hook.ts"; +import { RecordTap } from "./cup-ops/record-tap.ts"; +import { RemoveFilter } from "./cup-ops/remove-filter.ts"; +import { SetFilter } from "./cup-ops/set-filter.ts"; +import { TruthyPredicate } from "./cup-ops/truthy-predicate.ts"; + +type Spec = Record; +type Case = { id: string; hooks?: boolean; steps: Spec[]; input: Record }; + +class Unsupported extends Error {} + +function makeFilter(spec: Spec): Filter { + switch (spec.op) { + case "set": return new SetFilter(spec.key, spec.value); + case "copy": return new CopyFilter(spec.from, spec.to); + case "add": return new AddFilter(spec.key, spec.by); + case "append": return new AppendFilter(spec.key, spec.value); + case "remove": return new RemoveFilter(spec.key); + case "merge": return new MergeFilter(spec.value); + case "has": return new HasFilter(spec.key, spec.to); + case "mutate": return new MutateFilter(spec.key, spec.value); + case "fail": return new FailFilter(spec.message); + default: throw new Unsupported(`unknown filter op '${spec.op}'`); + } +} + +function makeTap(spec: Spec, taps: unknown[]): Tap { + switch (spec.op) { + case "record": return new RecordTap(spec.key, taps); + case "fail": return new FailTap(spec.message); + case "noop": return new NoopTap(); + default: throw new Unsupported(`unknown tap op '${spec.op}'`); + } +} + +function makePredicate(when: Spec): Predicate { + switch (when.op) { + case "equals": return new EqualsPredicate(when.key, when.value); + case "truthy": return new TruthyPredicate(when.key); + default: throw new Unsupported(`unknown valve op '${when.op}'`); + } +} + +function build(steps: Spec[], pipeline: Pipeline, taps: unknown[]): Pipeline { + for (const step of steps) { + if (step.kind === "filter") { + pipeline.addFilter(makeFilter(step)); + } else if (step.kind === "tap") { + pipeline.addTap(makeTap(step, taps)); + } else if (step.kind === "valve") { + pipeline.addValve(makePredicate(step.when), makeFilter(step.filter)); + } else if (step.kind === "pipeline") { + pipeline.addPipeline(build(step.steps, new NestedPipeline(), taps)); + } else { + throw new Unsupported(`unknown step kind '${step.kind}'`); + } + } + return pipeline; +} + +/** + * Label to the step name of the case. The cases name their steps, and the + * runtime reports labels (conformance/API.md). The steps are added in the + * order of the case, so the two lists are side by side. A label that the + * runtime reports and that is not in this map stays as it is, and fails the + * comparison. + */ +function namesByLabel(pipeline: Pipeline, steps: Spec[]): Map { + return new Map(pipeline.labels.map((label, index) => [label, String(steps[index]?.name)])); +} + +async function runCase(spec: Case): Promise> { + const taps: unknown[] = []; + const events: string[] = []; + const pipeline = build(spec.steps, new Pipeline(), taps); + const names = namesByLabel(pipeline, spec.steps); + const named = (label: string) => names.get(label) ?? label; + if (spec.hooks) pipeline.useHook(new RecordHook(events)); + + const data = structuredClone(spec.input); + const result: Record = { id: spec.id }; + try { + result.output = (await pipeline.run(new Payload(data))).toDict(); + } catch (error) { + result.error = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + } + const state = pipeline.state; + result.state = { + executed: state.executed.map(named), + skipped: state.skipped.map(named), + errors: state.errors.map(([label]) => named(label)), + }; + result.taps = taps; + if (spec.hooks) { + result.hooks = events.map((event) => { + const at = event.indexOf(":"); + return event.slice(0, at + 1) + named(event.slice(at + 1)); + }); + } + result.input_after = data; + return result; +} + +const target = process.argv[2]; +if (target !== "run") { + console.error(`unknown target '${target}'. The target is: run`); + process.exit(2); +} + +const cases: Case[] = JSON.parse(readFileSync(0, "utf8")); +const results: Record[] = []; +for (const spec of cases) { + try { + results.push(await runCase(spec)); + } catch (error) { + if (!(error instanceof Unsupported)) throw error; + results.push({ id: spec.id, unsupported: error.message }); + } +} +process.stdout.write(JSON.stringify(results)); diff --git a/packages/codeupipe-ts/.gitignore b/packages/codeupipe-ts/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/packages/codeupipe-ts/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/codeupipe-ts/LICENSE b/packages/codeupipe-ts/LICENSE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/packages/codeupipe-ts/LICENSE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/packages/codeupipe-ts/NOTICE b/packages/codeupipe-ts/NOTICE new file mode 100644 index 0000000..fd0d109 --- /dev/null +++ b/packages/codeupipe-ts/NOTICE @@ -0,0 +1,4 @@ +codeupipe +Copyright 2026 Orchestrate LLC + +Licensed under the Apache License, Version 2.0. See LICENSE. diff --git a/packages/codeupipe-ts/README.md b/packages/codeupipe-ts/README.md new file mode 100644 index 0000000..5bde8b8 --- /dev/null +++ b/packages/codeupipe-ts/README.md @@ -0,0 +1,159 @@ +# codeupipe for TypeScript + +codeupipe is a library for writing a program as a pipeline of small steps. +This is the TypeScript runtime. It has no dependencies. The built JavaScript +runs in a browser and in Node. + +It is not on npm yet. It implements the same contract as the Python +package, and the same 27 conformance cases judge it: +[conformance/API.md](../../conformance/API.md). + +## The eight primitives + +| Primitive | What it does | Who writes it | +|---|---|---| +| **Payload** | Carries the data | The library | +| **Filter** | Changes the data | You | +| **Predicate** | Asks a yes-or-no question about the data | You | +| **Valve** | Joins a Predicate to a Filter: the Filter runs when the answer is yes | The library. You add one with `addValve(predicate, filter)` | +| **Tap** | Reads the data as it goes by | You | +| **Hook** | Is called before and after each step | You | +| **State** | Records which steps ran | The library | +| **Pipeline** | Runs the steps in order | You | + +## Example + +[examples/pricing/](examples/pricing/) has one class in each file. The +pipeline file imports them and adds them. + +```ts +// discount-filter.ts +import type { Filter, Payload } from "codeupipe"; + +export class DiscountFilter implements Filter { + readonly rate: number; + + constructor(rate: number) { + this.rate = rate; + } + + call(payload: Payload): Payload { + const price = payload.get("price", 0) * (1 - this.rate); + return payload.insert("price", Math.round(price * 100) / 100); + } +} +``` + +```ts +// at-least-predicate.ts +import type { Payload, Predicate } from "codeupipe"; + +export class AtLeastPredicate implements Predicate { + readonly key: string; + readonly minimum: number; + + constructor(key: string, minimum: number) { + this.key = key; + this.minimum = minimum; + } + + test(payload: Payload): boolean { + return payload.get(this.key, 0) >= this.minimum; + } +} +``` + +```ts +// pricing-pipeline.ts +import { Pipeline } from "codeupipe"; + +import { AtLeastPredicate } from "./at-least-predicate.ts"; +import { AuditTap } from "./audit-tap.ts"; +import { DiscountFilter } from "./discount-filter.ts"; +import { MarkBulkFilter } from "./mark-bulk-filter.ts"; + +export class PricingPipeline extends Pipeline { + constructor() { + super(); + this.addFilter(new DiscountFilter(0.1)); + // MarkBulkFilter runs only when the predicate is true. + this.addValve(new AtLeastPredicate("qty", 10), new MarkBulkFilter()); + // A Tap failure is recorded and does not stop the run. + this.addTap(new AuditTap()); + } +} +``` + +```ts +import { Payload } from "codeupipe"; + +const result = await new PricingPipeline().run(new Payload({ price: 100.0, qty: 12 })); +result.toDict(); +// { price: 90, qty: 12, bulk: true } +``` + +In this repository the example imports from `../../src/index.ts`, because +the package is not installed. + +## Names + +The names are the names of the contract, in camelCase. + +| Contract | TypeScript | +|---|---| +| `add_filter`, `add_valve`, `add_tap`, `add_pipeline`, `use_hook` | `addFilter`, `addValve`, `addTap`, `addPipeline`, `useHook` | +| `run`, `state`, `labels`, `observe` | `run`, `state`, `labels`, `observe({ timing, lineage })` | +| `get`, `has`, `insert`, `remove`, `merge`, `copy` | the same | +| `to_dict`, `with_trace`, `trace_id`, `lineage` | `toDict`, `withTrace`, `traceId`, `lineage` | +| Hook: `before`, `after`, `on_error` | `before`, `after`, `onError` | + +`Filter`, `Predicate` and `Tap` are interfaces: a class with the method is +one. `Hook` is a class with three methods that do nothing: extend it and +override the methods that you need. + +## Differences from Python + +- **There is one `run`, and it returns a Promise.** A Filter, a Tap or a + Hook method can be sync or async. Python has `run` (sync) and `run_async`. +- **Absent is `null`.** `get` of a key that is not there gives `null`, and a + Hook gets `null` as the step for the pipeline itself. +- **A function is not a step.** Every JavaScript function has a `call` + method, so `addFilter` refuses a function by its type. +- **Two runs of one pipeline at the same time** each write to their own + State. `pipeline.state` is the State of the run that started last. +- **This package has no export command and no linter.** Python has both. + +## Labels and minified code + +State and Hooks report a step by its label, which is the name of its class. +A minifier changes class names, so a label in minified code can be a name +such as `b`. To keep the names, turn on the option of your tool that keeps +class names (for example `keepNames` in esbuild, `keep_classnames` in +terser). A class with no name has the label `anonymous`. + +`tests/bundle.test.ts` checks this with esbuild: the pricing example gives +the same result in a plain bundle, a minified bundle and a minified bundle +that keeps names, and the labels change only in the second. +`examples/pricing/browser/entry.ts` is the entry of that bundle. + +## Development + +Node 22.18 or later runs the `.ts` files as they are. + +```bash +npm install # the TypeScript compiler, the Node types and esbuild, for development only +npm test # the tests +npm run typecheck # the type check of the source, tests, example and conformance runner +npm run build # JavaScript and type files in dist/ +node examples/pricing/main.ts +python ../../conformance/run.py typescript:run +``` + +The source uses only TypeScript syntax that can be removed without a +change to the code that runs: no `enum`, no parameter properties, no +`namespace`. One primitive is in each file, and the file name is the class +name in kebab-case. + +## License + +Apache License 2.0. diff --git a/packages/codeupipe-ts/examples/pricing/at-least-predicate.ts b/packages/codeupipe-ts/examples/pricing/at-least-predicate.ts new file mode 100644 index 0000000..3baa95d --- /dev/null +++ b/packages/codeupipe-ts/examples/pricing/at-least-predicate.ts @@ -0,0 +1,20 @@ +import type { Payload, Predicate } from "../../src/index.ts"; + +/** + * A Predicate: the question that a Valve asks. Its settings are on the + * instance, so the same class answers a different question in another + * pipeline. + */ +export class AtLeastPredicate implements Predicate { + readonly key: string; + readonly minimum: number; + + constructor(key: string, minimum: number) { + this.key = key; + this.minimum = minimum; + } + + test(payload: Payload): boolean { + return payload.get(this.key, 0) >= this.minimum; + } +} diff --git a/packages/codeupipe-ts/examples/pricing/audit-tap.ts b/packages/codeupipe-ts/examples/pricing/audit-tap.ts new file mode 100644 index 0000000..5ba2343 --- /dev/null +++ b/packages/codeupipe-ts/examples/pricing/audit-tap.ts @@ -0,0 +1,11 @@ +import type { Payload, Tap } from "../../src/index.ts"; + +/** + * A Tap: reads the Payload and returns nothing. This one fails every time, + * to show that a Tap failure is recorded and never stops the run. + */ +export class AuditTap implements Tap { + observe(_payload: Payload): void { + throw new Error("audit sink is down"); + } +} diff --git a/packages/codeupipe-ts/examples/pricing/browser/entry.ts b/packages/codeupipe-ts/examples/pricing/browser/entry.ts new file mode 100644 index 0000000..360d291 --- /dev/null +++ b/packages/codeupipe-ts/examples/pricing/browser/entry.ts @@ -0,0 +1,22 @@ +// The entry of the browser check. A bundler makes one script from this file. +// The script runs the pricing pipeline and puts the result on the page and +// on globalThis, where a test can read it. +import { Payload } from "../../../src/index.ts"; +import { PricingPipeline } from "../pricing-pipeline.ts"; + +// The page of a browser. Node has none. +declare const document: { body: { textContent: string | null } } | undefined; + +const pipeline = new PricingPipeline(); +const output = (await pipeline.run(new Payload({ price: 100.0, qty: 12 }))).toDict(); +const result = { + output, + labels: pipeline.labels, + executed: pipeline.state.executed, + errors: pipeline.state.errors.map(([label, error]) => [label, String(error)]), +}; + +(globalThis as { cupResult?: unknown }).cupResult = result; +if (typeof document !== "undefined") { + document.body.textContent = JSON.stringify(result); +} diff --git a/packages/codeupipe-ts/examples/pricing/discount-filter.ts b/packages/codeupipe-ts/examples/pricing/discount-filter.ts new file mode 100644 index 0000000..19f657e --- /dev/null +++ b/packages/codeupipe-ts/examples/pricing/discount-filter.ts @@ -0,0 +1,15 @@ +import type { Filter, Payload } from "../../src/index.ts"; + +/** A Filter: takes the Payload, writes to it, returns it. */ +export class DiscountFilter implements Filter { + readonly rate: number; + + constructor(rate: number) { + this.rate = rate; + } + + call(payload: Payload): Payload { + const price = payload.get("price", 0) * (1 - this.rate); + return payload.insert("price", Math.round(price * 100) / 100); + } +} diff --git a/packages/codeupipe-ts/examples/pricing/main.ts b/packages/codeupipe-ts/examples/pricing/main.ts new file mode 100644 index 0000000..9f59a54 --- /dev/null +++ b/packages/codeupipe-ts/examples/pricing/main.ts @@ -0,0 +1,15 @@ +// node examples/pricing/main.ts: run the pipeline with a Hook and show its State. +import { Payload } from "../../src/index.ts"; +import { PricingPipeline } from "./pricing-pipeline.ts"; +import { PrintStepsHook } from "./print-steps-hook.ts"; + +const pipeline = new PricingPipeline(); +pipeline.useHook(new PrintStepsHook()); + +console.log("steps:"); +const result = await pipeline.run(new Payload({ price: 100.0, qty: 12 })); + +console.log("result: ", result.toDict()); +console.log("executed:", pipeline.state.executed); +console.log("skipped: ", pipeline.state.skipped); +console.log("errors: ", pipeline.state.errors.map(([label, error]) => [label, String(error)])); diff --git a/packages/codeupipe-ts/examples/pricing/mark-bulk-filter.ts b/packages/codeupipe-ts/examples/pricing/mark-bulk-filter.ts new file mode 100644 index 0000000..8777283 --- /dev/null +++ b/packages/codeupipe-ts/examples/pricing/mark-bulk-filter.ts @@ -0,0 +1,8 @@ +import type { Filter, Payload } from "../../src/index.ts"; + +/** A Filter. */ +export class MarkBulkFilter implements Filter { + call(payload: Payload): Payload { + return payload.insert("bulk", true); + } +} diff --git a/packages/codeupipe-ts/examples/pricing/pricing-pipeline.ts b/packages/codeupipe-ts/examples/pricing/pricing-pipeline.ts new file mode 100644 index 0000000..6b8a617 --- /dev/null +++ b/packages/codeupipe-ts/examples/pricing/pricing-pipeline.ts @@ -0,0 +1,17 @@ +import { Pipeline } from "../../src/index.ts"; +import { AtLeastPredicate } from "./at-least-predicate.ts"; +import { AuditTap } from "./audit-tap.ts"; +import { DiscountFilter } from "./discount-filter.ts"; +import { MarkBulkFilter } from "./mark-bulk-filter.ts"; + +/** The pipeline file: the place where the steps come together. */ +export class PricingPipeline extends Pipeline { + constructor() { + super(); + this.addFilter(new DiscountFilter(0.1)); + // MarkBulkFilter runs only when the predicate is true. + this.addValve(new AtLeastPredicate("qty", 10), new MarkBulkFilter()); + // A Tap failure is recorded and does not stop the run. + this.addTap(new AuditTap()); + } +} diff --git a/packages/codeupipe-ts/examples/pricing/print-steps-hook.ts b/packages/codeupipe-ts/examples/pricing/print-steps-hook.ts new file mode 100644 index 0000000..bc5a933 --- /dev/null +++ b/packages/codeupipe-ts/examples/pricing/print-steps-hook.ts @@ -0,0 +1,13 @@ +import { Hook } from "../../src/index.ts"; + +/** + * A Hook: sees the lifecycle. `step` is the label of the step, which is the + * name of its class. It is null for the pipeline itself. + */ +export class PrintStepsHook extends Hook { + override before(step: string | null): void { + if (step !== null) { + console.log(" " + step); + } + } +} diff --git a/packages/codeupipe-ts/package-lock.json b/packages/codeupipe-ts/package-lock.json new file mode 100644 index 0000000..4e31871 --- /dev/null +++ b/packages/codeupipe-ts/package-lock.json @@ -0,0 +1,536 @@ +{ + "name": "codeupipe", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codeupipe", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^24.0.0", + "esbuild": "^0.28.2", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=22.18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/packages/codeupipe-ts/package.json b/packages/codeupipe-ts/package.json new file mode 100644 index 0000000..069bece --- /dev/null +++ b/packages/codeupipe-ts/package.json @@ -0,0 +1,41 @@ +{ + "name": "codeupipe", + "version": "0.1.0", + "private": true, + "description": "A TypeScript library for writing a program as a pipeline of small steps.", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/orchestrate-solutions/codeupipe.git", + "directory": "packages/codeupipe-ts" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "LICENSE", + "NOTICE", + "README.md" + ], + "sideEffects": false, + "engines": { + "node": ">=22.18" + }, + "scripts": { + "test": "node --test \"tests/*.test.ts\"", + "typecheck": "tsc --noEmit", + "build": "tsc -p tsconfig.build.json" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "esbuild": "^0.28.2", + "typescript": "^5.9.0" + } +} diff --git a/packages/codeupipe-ts/src/filter.ts b/packages/codeupipe-ts/src/filter.ts new file mode 100644 index 0000000..9b130b2 --- /dev/null +++ b/packages/codeupipe-ts/src/filter.ts @@ -0,0 +1,6 @@ +import type { Payload } from "./payload.ts"; + +/** Filter: changes the data. A failure throws. `call` can be async. */ +export interface Filter { + call(payload: Payload): Payload | Promise; +} diff --git a/packages/codeupipe-ts/src/hook.ts b/packages/codeupipe-ts/src/hook.ts new file mode 100644 index 0000000..6303679 --- /dev/null +++ b/packages/codeupipe-ts/src/hook.ts @@ -0,0 +1,18 @@ +import type { Payload } from "./payload.ts"; + +/** + * Hook: sees the lifecycle of a run. Extend it and override what you need. + * + * `step` is the label of the step, or null for the pipeline itself. A Hook + * reads the Payload and does not write to it. Its methods can be async. + */ +export class Hook { + /** Before a step, or before the pipeline starts (`step` is null). */ + before(step: string | null, payload: Payload): void | Promise {} + + /** After a step, or after the pipeline ends (`step` is null). */ + after(step: string | null, payload: Payload): void | Promise {} + + /** When a Filter fails: for the step, then for the pipeline (`step` is null). */ + onError(step: string | null, error: unknown, payload: Payload): void | Promise {} +} diff --git a/packages/codeupipe-ts/src/index.ts b/packages/codeupipe-ts/src/index.ts new file mode 100644 index 0000000..d1a5888 --- /dev/null +++ b/packages/codeupipe-ts/src/index.ts @@ -0,0 +1,14 @@ +/** + * codeupipe: the eight primitives. + * + * Payload, Filter, Predicate, Valve, Tap, Hook, State and Pipeline, as the + * contract (conformance/API.md) defines them. A Valve is added with + * `pipeline.addValve(predicate, filter)`; its class is not a public name. + */ +export type { Filter } from "./filter.ts"; +export { Hook } from "./hook.ts"; +export { Payload } from "./payload.ts"; +export { Pipeline } from "./pipeline.ts"; +export type { Predicate } from "./predicate.ts"; +export { State } from "./state.ts"; +export type { Tap } from "./tap.ts"; diff --git a/packages/codeupipe-ts/src/payload.ts b/packages/codeupipe-ts/src/payload.ts new file mode 100644 index 0000000..c77fbe1 --- /dev/null +++ b/packages/codeupipe-ts/src/payload.ts @@ -0,0 +1,87 @@ +/** + * Payload: the data that moves through a pipeline. + * + * One keyed container. Every write changes it in place and returns it, so a + * step can write and return in one line. `copy()` gives a separate Payload + * when you want to keep the one you have. + */ +export class Payload { + #data: Map; + #traceId: string | null = null; + #lineage: string[] = []; + + /** Keys are strings; values are JSON-shaped. The map that is given is copied. */ + constructor(data?: Record | null) { + this.#data = new Map(data ? Object.entries(data) : []); + } + + /** The value for `key`, or `fallback` when the key is absent. */ + get(key: string): T | null; + get(key: string, fallback: T): T; + get(key: string, fallback: unknown = null): unknown { + return this.#data.has(key) ? this.#data.get(key) : fallback; + } + + /** Whether `key` is present (also when its value is null). */ + has(key: string): boolean { + return this.#data.has(key); + } + + /** Set `key` to `value` in this Payload, and return it. */ + insert(key: string, value: unknown): this { + this.#data.set(key, value); + return this; + } + + /** Remove `key` from this Payload (nothing happens if it is absent), and return it. */ + remove(key: string): this { + this.#data.delete(key); + return this; + } + + /** Write every key of `other` into this Payload (`other` wins), and return it. */ + merge(other: Payload): this { + for (const [key, value] of other.#data) { + this.#data.set(key, value); + } + this.#traceId = this.#traceId ?? other.#traceId; + this.#lineage.push(...other.#lineage); + return this; + } + + /** A separate Payload with the same contents, trace id and lineage. Values are shared. */ + copy(): Payload { + const copy = new Payload(); + copy.#data = new Map(this.#data); + copy.#traceId = this.#traceId; + copy.#lineage = [...this.#lineage]; + return copy; + } + + /** The contents as a new map. */ + toDict(): Record { + return Object.fromEntries(this.#data); + } + + /** Set the trace id on this Payload, and return it. */ + withTrace(traceId: string): this { + this.#traceId = traceId; + return this; + } + + /** The trace id, for following one Payload across systems. */ + get traceId(): string | null { + return this.#traceId; + } + + /** The steps this Payload has passed through, when lineage is on. */ + get lineage(): string[] { + return [...this.#lineage]; + } + + /** Record a step in the lineage. For the Pipeline. */ + _stamp(label: string): this { + this.#lineage.push(label); + return this; + } +} diff --git a/packages/codeupipe-ts/src/pipeline.ts b/packages/codeupipe-ts/src/pipeline.ts new file mode 100644 index 0000000..1436487 --- /dev/null +++ b/packages/codeupipe-ts/src/pipeline.ts @@ -0,0 +1,211 @@ +import type { Filter } from "./filter.ts"; +import type { Hook } from "./hook.ts"; +import type { Payload } from "./payload.ts"; +import type { Predicate } from "./predicate.ts"; +import { State } from "./state.ts"; +import { labelOf } from "./step-label.ts"; +import type { Tap } from "./tap.ts"; +import { Valve } from "./valve.ts"; + +type Step = + | { kind: "filter"; label: string; filter: Filter } + | { kind: "valve"; label: string; valve: Valve } + | { kind: "tap"; label: string; tap: Tap } + | { kind: "pipeline"; label: string; pipeline: Pipeline }; + +// The clock of browsers and of Node. Declared here so that this file needs +// the types of neither. +declare const performance: { now(): number }; + +function isThenable(value: unknown): value is PromiseLike { + return typeof (value as { then?: unknown } | null | undefined)?.then === "function"; +} + +/** Whether `value` is an object with `method`. A function is not a step: every function has `call`. */ +function has(value: unknown, method: string): boolean { + return typeof value === "object" && value !== null + && typeof (value as Record)[method] === "function"; +} + +/** Refuse a step that does not have the method of its role. */ +function require(step: unknown, method: string, what: string): void { + if (!has(step, method)) { + const given = step === null || step === undefined ? String(step) + : typeof step === "function" ? "a function" : `a ${labelOf(step)}`; + throw new TypeError(`${what} has ${method}(payload). The value that was given (${given}) does not.`); + } +} + +/** + * Pipeline: runs the steps in order. + * + * Filters change the Payload, Valves gate a Filter, Taps read, Hooks see the + * lifecycle, and State records what ran. Add steps with `addFilter`, + * `addValve`, `addTap` and `addPipeline`, add Hooks with `useHook`, and run + * with `await pipeline.run(payload)`. + */ +export class Pipeline { + #steps: Step[] = []; + #uses: Map = new Map(); + #hooks: Hook[] = []; + #state: State = new State(); + #timing = false; + #lineage = false; + + /** What happened in the run that started last. */ + get state(): State { + return this.#state; + } + + /** The label of each step, in order. */ + get labels(): string[] { + return this.#steps.map((step) => step.label); + } + + /** The class name of the step, with a number from its second use in this pipeline. */ + #label(step: unknown): string { + const base = labelOf(step); + const count = (this.#uses.get(base) ?? 0) + 1; + this.#uses.set(base, count); + return count === 1 ? base : `${base}#${count}`; + } + + /** Add a Filter. */ + addFilter(filter: Filter): void { + require(filter, "call", "A Filter"); + this.#steps.push({ kind: "filter", label: this.#label(filter), filter }); + } + + /** + * Add a Valve: `filter` runs only when `predicate.test(payload)` is true. + * + * The condition comes first, then the Filter that it controls. When the + * test is false the Payload passes through unchanged and the step is + * recorded as skipped. The step has the label of its Filter. + */ + addValve(predicate: Predicate, filter: Filter): void { + if (!has(predicate, "test") && has(predicate, "call") && has(filter, "test")) { + throw new TypeError("The arguments are in the wrong order: addValve(predicate, filter). " + + "The condition comes first, then the Filter."); + } + require(predicate, "test", "A Predicate"); + require(filter, "call", "A Filter"); + this.#steps.push({ kind: "valve", label: this.#label(filter), valve: new Valve(predicate, filter) }); + } + + /** Add a Tap. */ + addTap(tap: Tap): void { + require(tap, "observe", "A Tap"); + this.#steps.push({ kind: "tap", label: this.#label(tap), tap }); + } + + /** Nest a Pipeline as one step of this Pipeline. */ + addPipeline(pipeline: Pipeline): void { + if (!(pipeline instanceof Pipeline)) { + throw new TypeError("addPipeline takes a Pipeline. The value that was given is not one."); + } + this.#steps.push({ kind: "pipeline", label: this.#label(pipeline), pipeline }); + } + + /** Add a Hook. */ + useHook(hook: Hook): void { + this.#hooks.push(hook); + } + + /** Turn on timing (seconds for each step, in State) and lineage (labels on the Payload). */ + observe(options: { timing?: boolean; lineage?: boolean } = {}): void { + this.#timing = options.timing ?? true; + this.#lineage = options.lineage ?? false; + } + + /** + * Run the steps in order on a copy of `initial`, and return the result. + * + * A step can be sync or async. A Filter failure stops the run and is + * thrown again after the Hooks have seen it. A Tap failure is recorded in + * State and the run continues. + */ + async run(initial: Payload): Promise { + // Each run writes to its own State, so two runs at the same time do not mix. + const state = new State(); + this.#state = state; + const hooks = this.#hooks; + const timed = this.#timing; + let payload = initial.copy(); // the caller's Payload is not changed + + for (const hook of hooks) { + const done = hook.before(null, payload); + if (isThenable(done)) await done; + } + + let failing: string | null = null; // the step that is running when an error escapes + let started: number | null = null; + try { + for (const step of this.#steps) { + const label = step.label; + failing = null; + started = null; + + if (step.kind === "tap") { + try { + const done = step.tap.observe(payload); + if (isThenable(done)) await done; + } catch (error) { + state.recordError(label, error); + } + state.markExecuted(label); + continue; + } + + if (timed) started = performance.now(); + failing = label; + for (const hook of hooks) { + const done = hook.before(label, payload); + if (isThenable(done)) await done; + } + + if (step.kind === "pipeline") { + payload = await step.pipeline.run(payload); + state.markExecuted(label); + } else if (step.kind === "valve" && !step.valve.predicate.test(payload)) { + state.markSkipped(label); + } else { + const filter = step.kind === "valve" ? step.valve.filter : step.filter; + const result = filter.call(payload); + payload = isThenable(result) ? await result : result; + state.markExecuted(label); + } + + for (const hook of hooks) { + const done = hook.after(label, payload); + if (isThenable(done)) await done; + } + if (timed && started !== null) state.recordTiming(label, (performance.now() - started) / 1000); + if (this.#lineage) payload._stamp(label); + } + } catch (error) { + if (timed && started !== null && failing !== null) { + state.recordTiming(failing, (performance.now() - started) / 1000); + } + if (failing !== null) { + // The failing step first, then the pipeline: onError fires at both + // levels, as before and after do. + for (const hook of hooks) { + const done = hook.onError(failing, error, payload); + if (isThenable(done)) await done; + } + } + for (const hook of hooks) { + const done = hook.onError(null, error, payload); + if (isThenable(done)) await done; + } + throw error; + } + + for (const hook of hooks) { + const done = hook.after(null, payload); + if (isThenable(done)) await done; + } + return payload; + } +} diff --git a/packages/codeupipe-ts/src/predicate.ts b/packages/codeupipe-ts/src/predicate.ts new file mode 100644 index 0000000..12336e8 --- /dev/null +++ b/packages/codeupipe-ts/src/predicate.ts @@ -0,0 +1,11 @@ +import type { Payload } from "./payload.ts"; + +/** + * Predicate: asks a yes-or-no question about the data. + * + * A Predicate is an object. Its settings (a threshold, a flag) are on the + * instance. It reads the Payload and does not write to it. + */ +export interface Predicate { + test(payload: Payload): boolean; +} diff --git a/packages/codeupipe-ts/src/state.ts b/packages/codeupipe-ts/src/state.ts new file mode 100644 index 0000000..b96dbee --- /dev/null +++ b/packages/codeupipe-ts/src/state.ts @@ -0,0 +1,32 @@ +/** + * State: what happened in the last run. + * + * Which steps executed, which a Valve skipped, the errors, and the timings + * when timing is on. Read it after a run through `pipeline.state`. + */ +export class State { + /** Step labels, in order. */ + readonly executed: string[] = []; + /** Steps that a Valve did not run. */ + readonly skipped: string[] = []; + /** Pairs of label and error. */ + readonly errors: Array<[string, unknown]> = []; + /** Label to seconds, when timing is on. */ + readonly timings: Map = new Map(); + + markExecuted(label: string): void { + this.executed.push(label); + } + + markSkipped(label: string): void { + this.skipped.push(label); + } + + recordTiming(label: string, seconds: number): void { + this.timings.set(label, seconds); + } + + recordError(label: string, error: unknown): void { + this.errors.push([label, error]); + } +} diff --git a/packages/codeupipe-ts/src/step-label.ts b/packages/codeupipe-ts/src/step-label.ts new file mode 100644 index 0000000..9b83e94 --- /dev/null +++ b/packages/codeupipe-ts/src/step-label.ts @@ -0,0 +1,9 @@ +/** + * The label of a step: the name of its class. State and Hooks report a step + * by this label. + */ +export function labelOf(step: unknown): string { + const type = (step as { constructor?: { name?: unknown } } | null | undefined)?.constructor; + const name = type?.name; + return typeof name === "string" && name !== "" ? name : "anonymous"; +} diff --git a/packages/codeupipe-ts/src/tap.ts b/packages/codeupipe-ts/src/tap.ts new file mode 100644 index 0000000..2ccbd56 --- /dev/null +++ b/packages/codeupipe-ts/src/tap.ts @@ -0,0 +1,9 @@ +import type { Payload } from "./payload.ts"; + +/** + * Tap: reads the data as it goes by. It does not write to the Payload. + * A Tap failure is recorded in State and never stops the run. + */ +export interface Tap { + observe(payload: Payload): void | Promise; +} diff --git a/packages/codeupipe-ts/src/valve.ts b/packages/codeupipe-ts/src/valve.ts new file mode 100644 index 0000000..27d3038 --- /dev/null +++ b/packages/codeupipe-ts/src/valve.ts @@ -0,0 +1,18 @@ +import type { Filter } from "./filter.ts"; +import type { Predicate } from "./predicate.ts"; + +/** + * Valve: joins a Predicate to a Filter. The Filter runs only when the + * Predicate is true. + * + * `Pipeline.addValve(predicate, filter)` makes one. It is not a public name. + */ +export class Valve { + readonly predicate: Predicate; + readonly filter: Filter; + + constructor(predicate: Predicate, filter: Filter) { + this.predicate = predicate; + this.filter = filter; + } +} diff --git a/packages/codeupipe-ts/tests/bundle.test.ts b/packages/codeupipe-ts/tests/bundle.test.ts new file mode 100644 index 0000000..9418b33 --- /dev/null +++ b/packages/codeupipe-ts/tests/bundle.test.ts @@ -0,0 +1,53 @@ +// The built package in a bundle, as a web app has it: plain, minified, and +// minified with class names kept. The README says what a minifier does to +// labels; this file checks it. +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { pathToFileURL } from "node:url"; + +import { build } from "esbuild"; + +const ENTRY = join(import.meta.dirname, "../examples/pricing/browser/entry.ts"); +const LABELS = ["DiscountFilter", "MarkBulkFilter", "AuditTap"]; + +type Result = { output: unknown; labels: string[]; executed: string[] }; + +async function bundle(options: { minify: boolean; keepNames: boolean }): Promise<{ code: string; result: Result }> { + const built = await build({ + entryPoints: [ENTRY], bundle: true, write: false, format: "esm", platform: "browser", + target: "es2022", ...options, + }); + const code = built.outputFiles[0]?.text ?? ""; + const file = join(mkdtempSync(join(tmpdir(), "cup-bundle-")), "bundle.mjs"); + writeFileSync(file, code); + await import(pathToFileURL(file).href); + return { code, result: (globalThis as unknown as { cupResult: Result }).cupResult }; +} + +test("the bundle for a browser has no import of Node", async () => { + const { code } = await bundle({ minify: false, keepNames: false }); + assert.doesNotMatch(code, /from\s*"node:|require\(/); +}); + +test("a plain bundle gives the same result and the same labels", async () => { + const { result } = await bundle({ minify: false, keepNames: false }); + assert.deepEqual(result.output, { price: 90, qty: 12, bulk: true }); + assert.deepEqual(result.labels, LABELS); + assert.deepEqual(result.executed, LABELS); +}); + +test("a minified bundle gives the same result, but the labels are changed", async () => { + const { result } = await bundle({ minify: true, keepNames: false }); + assert.deepEqual(result.output, { price: 90, qty: 12, bulk: true }); + assert.equal(result.labels.length, 3); + assert.notDeepEqual(result.labels, LABELS); +}); + +test("a minified bundle that keeps names has the same labels", async () => { + const { result } = await bundle({ minify: true, keepNames: true }); + assert.deepEqual(result.output, { price: 90, qty: 12, bulk: true }); + assert.deepEqual(result.labels, LABELS); +}); diff --git a/packages/codeupipe-ts/tests/payload.test.ts b/packages/codeupipe-ts/tests/payload.test.ts new file mode 100644 index 0000000..8170547 --- /dev/null +++ b/packages/codeupipe-ts/tests/payload.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { Payload } from "../src/index.ts"; + +test("the map that the caller gives is copied", () => { + const data = { a: 1 }; + const payload = new Payload(data); + payload.insert("b", 2); + assert.deepEqual(data, { a: 1 }); + assert.deepEqual(payload.toDict(), { a: 1, b: 2 }); +}); + +test("an empty Payload has no keys", () => { + assert.deepEqual(new Payload().toDict(), {}); +}); + +test("get gives the value, the fallback, or null", () => { + const payload = new Payload({ a: 1, n: null }); + assert.equal(payload.get("a"), 1); + assert.equal(payload.get("missing"), null); + assert.equal(payload.get("missing", 7), 7); + assert.equal(payload.get("n", 7), null); +}); + +test("has is true for a key with a null value", () => { + const payload = new Payload({ n: null }); + assert.equal(payload.has("n"), true); + assert.equal(payload.has("missing"), false); +}); + +test("insert, remove, merge and withTrace change the Payload and return it", () => { + const payload = new Payload({ a: 1 }); + assert.equal(payload.insert("b", 2), payload); + assert.equal(payload.remove("a"), payload); + assert.equal(payload.merge(new Payload({ c: 3 })), payload); + assert.equal(payload.withTrace("t-1"), payload); + assert.deepEqual(payload.toDict(), { b: 2, c: 3 }); + assert.equal(payload.traceId, "t-1"); +}); + +test("remove of a key that is not there does nothing", () => { + assert.deepEqual(new Payload({ a: 1 }).remove("zz").toDict(), { a: 1 }); +}); + +test("in a merge the other Payload wins", () => { + const payload = new Payload({ a: 1, b: 1 }).merge(new Payload({ b: 2 })); + assert.deepEqual(payload.toDict(), { a: 1, b: 2 }); +}); + +test("a merge keeps the trace id it has, and takes one when it has none", () => { + const other = new Payload({}).withTrace("other"); + assert.equal(new Payload({}).withTrace("mine").merge(other).traceId, "mine"); + assert.equal(new Payload({}).merge(other).traceId, "other"); +}); + +test("copy gives a separate Payload with the same contents", () => { + const payload = new Payload({ a: 1 }).withTrace("t-1"); + const copy = payload.copy(); + copy.insert("a", 2); + assert.equal(payload.get("a"), 1); + assert.equal(copy.traceId, "t-1"); +}); + +test("copy is shallow: the two Payloads share their values", () => { + const list = [1]; + const payload = new Payload({ list }); + assert.equal(payload.copy().get("list"), list); +}); + +test("toDict gives a new map each time", () => { + const payload = new Payload({ a: 1 }); + const out = payload.toDict(); + out.a = 2; + assert.equal(payload.get("a"), 1); +}); + +test("the trace id is null until it is set, and the lineage starts empty", () => { + const payload = new Payload({}); + assert.equal(payload.traceId, null); + assert.deepEqual(payload.lineage, []); +}); + +test("a key with the name of an Object property is only a key", () => { + const payload = new Payload({}); + assert.equal(payload.has("constructor"), false); + assert.equal(payload.get("toString"), null); + payload.insert("__proto__", 1); + assert.equal(payload.get("__proto__"), 1); + assert.deepEqual(Object.keys(payload.toDict()), ["__proto__"]); +}); diff --git a/packages/codeupipe-ts/tests/pipeline.test.ts b/packages/codeupipe-ts/tests/pipeline.test.ts new file mode 100644 index 0000000..9cef7d3 --- /dev/null +++ b/packages/codeupipe-ts/tests/pipeline.test.ts @@ -0,0 +1,254 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { Hook, Payload, Pipeline } from "../src/index.ts"; +import type { Filter, Predicate, Tap } from "../src/index.ts"; + +class AddFilter implements Filter { + call(payload: Payload): Payload { + return payload.insert("n", payload.get("n", 0) + 1); + } +} + +class AddLaterFilter implements Filter { + async call(payload: Payload): Promise { + await Promise.resolve(); + return payload.insert("n", payload.get("n", 0) + 1); + } +} + +class FailFilter implements Filter { + call(): Payload { + throw new Error("boom"); + } +} + +class IsOnPredicate implements Predicate { + test(payload: Payload): boolean { + return payload.get("on") === true; + } +} + +class SeeTap implements Tap { + seen: unknown[] = []; + observe(payload: Payload): void { + this.seen.push(payload.get("n")); + } +} + +class FailTap implements Tap { + observe(): void { + throw new Error("sink is down"); + } +} + +class FailLaterTap implements Tap { + async observe(): Promise { + await Promise.resolve(); + throw new Error("sink is down later"); + } +} + +class InnerPipeline extends Pipeline { + constructor() { + super(); + this.addFilter(new AddFilter()); + this.addFilter(new AddFilter()); + } +} + +class RecordHook extends Hook { + events: string[] = []; + override before(step: string | null): void { + this.events.push("before:" + (step ?? "")); + } + override after(step: string | null): void { + this.events.push("after:" + (step ?? "")); + } + override onError(step: string | null): void { + this.events.push("error:" + (step ?? "")); + } +} + +test("an empty pipeline returns the data it was given", async () => { + const out = await new Pipeline().run(new Payload({ a: 1 })); + assert.deepEqual(out.toDict(), { a: 1 }); +}); + +test("steps run in order, and State records them", async () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addFilter(new AddLaterFilter()); + const out = await pipeline.run(new Payload({})); + assert.equal(out.get("n"), 2); + assert.deepEqual(pipeline.state.executed, ["AddFilter", "AddLaterFilter"]); + assert.deepEqual(pipeline.state.skipped, []); + assert.deepEqual(pipeline.state.errors, []); +}); + +test("run works on a copy: the caller's Payload is not changed", async () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + const given = new Payload({ n: 1 }); + const out = await pipeline.run(given); + assert.equal(given.get("n"), 1); + assert.equal(out.get("n"), 2); +}); + +test("a Valve runs its Filter when the Predicate is true", async () => { + const pipeline = new Pipeline(); + pipeline.addValve(new IsOnPredicate(), new AddFilter()); + const out = await pipeline.run(new Payload({ on: true })); + assert.equal(out.get("n"), 1); + assert.deepEqual(pipeline.state.executed, ["AddFilter"]); + assert.deepEqual(pipeline.state.skipped, []); +}); + +test("a Valve skips its Filter when the Predicate is false", async () => { + const pipeline = new Pipeline(); + pipeline.addValve(new IsOnPredicate(), new AddFilter()); + const out = await pipeline.run(new Payload({ on: false })); + assert.equal(out.has("n"), false); + assert.deepEqual(pipeline.state.executed, []); + assert.deepEqual(pipeline.state.skipped, ["AddFilter"]); +}); + +test("a Filter failure stops the run, and State keeps the steps before it", async () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addFilter(new FailFilter()); + pipeline.addFilter(new AddFilter()); + await assert.rejects(pipeline.run(new Payload({})), /boom/); + assert.deepEqual(pipeline.state.executed, ["AddFilter"]); +}); + +test("a Tap reads the Payload and counts as executed", async () => { + const tap = new SeeTap(); + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addTap(tap); + await pipeline.run(new Payload({})); + assert.deepEqual(tap.seen, [1]); + assert.deepEqual(pipeline.state.executed, ["AddFilter", "SeeTap"]); +}); + +test("a Tap failure is recorded and does not stop the run", async () => { + const hook = new RecordHook(); + const pipeline = new Pipeline(); + pipeline.addTap(new FailTap()); + pipeline.addTap(new FailLaterTap()); + pipeline.addFilter(new AddFilter()); + pipeline.useHook(hook); + const out = await pipeline.run(new Payload({})); + assert.equal(out.get("n"), 1); + assert.deepEqual(pipeline.state.executed, ["FailTap", "FailLaterTap", "AddFilter"]); + assert.deepEqual(pipeline.state.errors.map(([label]) => label), ["FailTap", "FailLaterTap"]); + assert.match(String(pipeline.state.errors[0]?.[1]), /sink is down/); + assert.deepEqual(hook.events, ["before:", "before:AddFilter", "after:AddFilter", "after:"]); +}); + +test("a nested pipeline is one step", async () => { + const pipeline = new Pipeline(); + pipeline.addPipeline(new InnerPipeline()); + pipeline.addFilter(new AddFilter()); + const out = await pipeline.run(new Payload({})); + assert.equal(out.get("n"), 3); + assert.deepEqual(pipeline.state.executed, ["InnerPipeline", "AddFilter"]); +}); + +test("Hooks see the pipeline and each Filter or Valve, in order", async () => { + const hook = new RecordHook(); + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addValve(new IsOnPredicate(), new AddLaterFilter()); + pipeline.addTap(new SeeTap()); + pipeline.useHook(hook); + await pipeline.run(new Payload({})); + assert.deepEqual(hook.events, [ + "before:", "before:AddFilter", "after:AddFilter", + "before:AddLaterFilter", "after:AddLaterFilter", "after:", + ]); +}); + +test("on a Filter failure a Hook gets the step, then the pipeline, and no after", async () => { + const hook = new RecordHook(); + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addFilter(new FailFilter()); + pipeline.useHook(hook); + await assert.rejects(pipeline.run(new Payload({}))); + assert.deepEqual(hook.events, [ + "before:", "before:AddFilter", "after:AddFilter", + "before:FailFilter", "error:FailFilter", "error:", + ]); +}); + +test("a Hook gets the error itself", async () => { + const seen: unknown[] = []; + class KeepErrorHook extends Hook { + override onError(_step: string | null, error: unknown): void { + seen.push(error); + } + } + const pipeline = new Pipeline(); + pipeline.addFilter(new FailFilter()); + pipeline.useHook(new KeepErrorHook()); + await assert.rejects(pipeline.run(new Payload({}))); + assert.equal(seen.length, 2); + assert.ok(seen[0] instanceof Error); + assert.equal(seen[0], seen[1]); +}); + +test("a Hook method can be async", async () => { + const events: string[] = []; + class SlowHook extends Hook { + override async before(step: string | null): Promise { + await Promise.resolve(); + events.push("before:" + (step ?? "")); + } + } + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.useHook(new SlowHook()); + await pipeline.run(new Payload({})); + assert.deepEqual(events, ["before:", "before:AddFilter"]); +}); + +test("timing and lineage are off by default", async () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + const out = await pipeline.run(new Payload({})); + assert.equal(pipeline.state.timings.size, 0); + assert.deepEqual(out.lineage, []); +}); + +test("observe turns timing on, and lineage when asked", async () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addTap(new SeeTap()); + pipeline.addValve(new IsOnPredicate(), new AddLaterFilter()); + pipeline.observe({ lineage: true }); + const out = await pipeline.run(new Payload({})); + assert.deepEqual([...pipeline.state.timings.keys()], ["AddFilter", "AddLaterFilter"]); + assert.ok((pipeline.state.timings.get("AddFilter") ?? -1) >= 0); + assert.deepEqual(out.lineage, ["AddFilter", "AddLaterFilter"]); +}); + +test("each run starts with a new State", async () => { + const pipeline = new Pipeline(); + pipeline.addValve(new IsOnPredicate(), new AddFilter()); + await pipeline.run(new Payload({ on: false })); + await pipeline.run(new Payload({ on: true })); + assert.deepEqual(pipeline.state.executed, ["AddFilter"]); + assert.deepEqual(pipeline.state.skipped, []); +}); + +test("two runs at the same time do not write into the same State", async () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddLaterFilter()); + pipeline.addFilter(new AddLaterFilter()); + const [a, b] = await Promise.all([pipeline.run(new Payload({})), pipeline.run(new Payload({}))]); + assert.equal(a.get("n"), 2); + assert.equal(b.get("n"), 2); + assert.deepEqual(pipeline.state.executed, ["AddLaterFilter", "AddLaterFilter#2"]); +}); diff --git a/packages/codeupipe-ts/tests/pricing-example.test.ts b/packages/codeupipe-ts/tests/pricing-example.test.ts new file mode 100644 index 0000000..995372f --- /dev/null +++ b/packages/codeupipe-ts/tests/pricing-example.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { PricingPipeline } from "../examples/pricing/pricing-pipeline.ts"; +import { Payload } from "../src/index.ts"; + +test("a large order gets the discount and the bulk mark", async () => { + const pipeline = new PricingPipeline(); + const out = await pipeline.run(new Payload({ price: 100.0, qty: 12 })); + assert.deepEqual(out.toDict(), { price: 90, qty: 12, bulk: true }); + assert.deepEqual(pipeline.state.executed, ["DiscountFilter", "MarkBulkFilter", "AuditTap"]); + assert.deepEqual(pipeline.state.errors.map(([label]) => label), ["AuditTap"]); +}); + +test("a small order gets the discount only", async () => { + const pipeline = new PricingPipeline(); + const out = await pipeline.run(new Payload({ price: 100.0, qty: 2 })); + assert.deepEqual(out.toDict(), { price: 90, qty: 2 }); + assert.deepEqual(pipeline.state.skipped, ["MarkBulkFilter"]); +}); diff --git a/packages/codeupipe-ts/tests/step-label.test.ts b/packages/codeupipe-ts/tests/step-label.test.ts new file mode 100644 index 0000000..58de4d6 --- /dev/null +++ b/packages/codeupipe-ts/tests/step-label.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import * as codeupipe from "../src/index.ts"; +import { Payload, Pipeline } from "../src/index.ts"; + +class AddFilter { + call(payload: Payload): Payload { + return payload.insert("n", payload.get("n", 0) + 1); + } +} + +class SeeTap { + observe(): void {} +} + +class IsOnPredicate { + test(payload: Payload): boolean { + return payload.get("on") === true; + } +} + +class InnerPipeline extends Pipeline {} + +test("the label is the name of the class", () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addTap(new SeeTap()); + assert.deepEqual(pipeline.labels, ["AddFilter", "SeeTap"]); +}); + +test("the second use of a class gets a number", () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addFilter(new AddFilter()); + pipeline.addFilter(new AddFilter()); + assert.deepEqual(pipeline.labels, ["AddFilter", "AddFilter#2", "AddFilter#3"]); +}); + +test("a Valve has the label of its Filter", () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.addValve(new IsOnPredicate(), new AddFilter()); + assert.deepEqual(pipeline.labels, ["AddFilter", "AddFilter#2"]); +}); + +test("a nested pipeline has the label of its class", () => { + const pipeline = new Pipeline(); + pipeline.addPipeline(new InnerPipeline()); + pipeline.addPipeline(new Pipeline()); + assert.deepEqual(pipeline.labels, ["InnerPipeline", "Pipeline"]); +}); + +test("a class defined in a function works", () => { + function make() { + class LocalFilter { + call(payload: Payload): Payload { + return payload; + } + } + return new LocalFilter(); + } + const pipeline = new Pipeline(); + pipeline.addFilter(make()); + assert.deepEqual(pipeline.labels, ["LocalFilter"]); +}); + +test("a step from a class with no name has the label 'anonymous'", () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new (class { + call(payload: Payload): Payload { + return payload; + } + })()); + assert.deepEqual(pipeline.labels, ["anonymous"]); +}); + +test("labels gives a new list each time", () => { + const pipeline = new Pipeline(); + pipeline.addFilter(new AddFilter()); + pipeline.labels.push("x"); + assert.deepEqual(pipeline.labels, ["AddFilter"]); +}); + +const untyped = (value: unknown) => value as never; + +test("a step without the method of its role is refused when it is added", () => { + const pipeline = new Pipeline(); + assert.throws(() => pipeline.addFilter(untyped((p: Payload) => p)), { name: "TypeError", message: /A Filter has call\(payload\)/ }); + assert.throws(() => pipeline.addFilter(untyped(new SeeTap())), { name: "TypeError", message: /call\(payload\)/ }); + assert.throws(() => pipeline.addTap(untyped(new AddFilter())), { name: "TypeError", message: /A Tap has observe\(payload\)/ }); + assert.throws(() => pipeline.addValve(untyped(() => true), new AddFilter()), { name: "TypeError", message: /A Predicate has test\(payload\)/ }); + assert.throws(() => pipeline.addValve(new IsOnPredicate(), untyped(null)), { name: "TypeError", message: /A Filter has call\(payload\)/ }); + assert.throws(() => pipeline.addPipeline(untyped(new AddFilter())), { name: "TypeError", message: /addPipeline takes a Pipeline/ }); + assert.deepEqual(pipeline.labels, []); +}); + +test("addValve says so when the arguments are in the wrong order", () => { + const pipeline = new Pipeline(); + assert.throws(() => pipeline.addValve(untyped(new AddFilter()), untyped(new IsOnPredicate())), + { name: "TypeError", message: /wrong order/ }); +}); + +test("the package exports the primitives a user needs, and no Valve", () => { + assert.deepEqual(Object.keys(codeupipe).sort(), ["Hook", "Payload", "Pipeline", "State"]); +}); diff --git a/packages/codeupipe-ts/tsconfig.build.json b/packages/codeupipe-ts/tsconfig.build.json new file mode 100644 index 0000000..4842647 --- /dev/null +++ b/packages/codeupipe-ts/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "types": [] + }, + "include": ["src"] +} diff --git a/packages/codeupipe-ts/tsconfig.json b/packages/codeupipe-ts/tsconfig.json new file mode 100644 index 0000000..de9cc28 --- /dev/null +++ b/packages/codeupipe-ts/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "tests", "examples", "../../conformance/runners/typescript"] +} diff --git a/tests/conformance/test_typescript_target.py b/tests/conformance/test_typescript_target.py new file mode 100644 index 0000000..02f3021 --- /dev/null +++ b/tests/conformance/test_typescript_target.py @@ -0,0 +1,42 @@ +"""TypeScript must pass the same conformance cases as Python. + +The test needs a Node that runs a .ts file as it is (22.18 or later). On a +machine with no such Node it is skipped. +""" + +import json +import pathlib +import sys + +import pytest + +CONFORMANCE = pathlib.Path(__file__).resolve().parents[2] / "conformance" +sys.path.insert(0, str(CONFORMANCE)) + +import run as conformance_run # noqa: E402 + +pytestmark = pytest.mark.skipif(not conformance_run._node_runs_typescript(), + reason="needs Node 22.18 or later") + + +def test_typescript_passes_every_case(tmp_path): + cases = conformance_run.load_cases() + results, error = conformance_run.run_target( + "typescript:run", conformance_run.TARGETS["typescript:run"], cases) + assert error is None, error + verdicts = {case["id"]: conformance_run.judge(case, results.get(case["id"])) for case in cases} + not_passed = {case_id: verdict for case_id, verdict in verdicts.items() if verdict[0] != "pass"} + assert not_passed == {}, json.dumps(not_passed, indent=2) + + +def test_a_target_that_cannot_run_is_a_failure(monkeypatch, tmp_path): + monkeypatch.setattr(conformance_run, "RESULTS", tmp_path) + monkeypatch.setitem(conformance_run.TARGETS, "broken:run", + {"cmd": [sys.executable, "-c", "raise SystemExit(3)"], "env": {}, "cwd": tmp_path}) + assert conformance_run.main(["broken:run"]) == 1 + + +def test_node_is_not_needed_for_the_default_targets(monkeypatch): + monkeypatch.setattr(conformance_run, "_node_runs_typescript", lambda: False) + assert [name for name in conformance_run.default_targets() if name.startswith("typescript:")] == [] + assert len(conformance_run.default_targets()) == 5 # every Python target