@pond-ts/process 0.61.0 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +25 -25
- package/CHANGELOG.md +65 -1
- package/dist/errors.d.ts +36 -1
- package/dist/errors.js +37 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/plan/builder.d.ts +1 -0
- package/dist/plan/builder.js +1 -0
- package/dist/plan/graph.d.ts +10 -0
- package/dist/plan/graph.js +103 -0
- package/dist/plan/host.d.ts +1 -0
- package/dist/plan/host.js +1 -0
- package/dist/plan/identity.d.ts +48 -1
- package/dist/plan/identity.js +112 -11
- package/dist/plan/registry.d.ts +16 -2
- package/dist/plan/registry.js +23 -3
- package/dist/plan/run.d.ts +14 -0
- package/dist/plan/run.js +37 -19
- package/dist/plan/slots.d.ts +1 -0
- package/dist/plan/slots.js +1 -0
- package/dist/plan/source.d.ts +1 -0
- package/dist/plan/source.js +1 -0
- package/dist/source.d.ts +1 -0
- package/dist/source.js +1 -0
- package/package.json +2 -2
package/API.md
CHANGED
|
@@ -521,31 +521,31 @@ Typed dataflow graphs for pipelines whose **shape is data** (runtime-assembled,
|
|
|
521
521
|
user-edited, one computation fanned out to several consumers). Chaining stays
|
|
522
522
|
the default for pipelines known at authoring time — see the package README.
|
|
523
523
|
|
|
524
|
-
| Group | Exports
|
|
525
|
-
| ----------------------- |
|
|
526
|
-
| Worker pool (Node) | `HostPool` (`start`, `run`, `close`, `size`, `inFlight`); types `HostPoolOptions`, `PoolSetup`, `PoolSetupConfig`; `toWire` / `fromWire`, types `WireResult`, `WireColumn` — subpath `@pond-ts/process/pool`
|
|
527
|
-
| Ports | `Inlet`, `Outlet` (typed fields on `node.in` / `node.out`; `get()`, `peek()`, `version`, `connect`, `disconnect`)
|
|
528
|
-
| Nodes | `Node` (`in`, `out`, `dirty`, `error`, `invalidate()`), `defineNode` (reusable multi-output node type), `derive` (single-output, wired inline)
|
|
529
|
-
| Port declaration | `port<T>({ equals, defaultValue })`; types `PortSpec`, `PortSpecMap`, `PortValue`, `PortValues`
|
|
530
|
-
| Sources | `source<T>()` → `SourceNode` (`set()`), `fromLive(liveSource)` → `LiveSourceNode` (`dispose()`); `GraphSource` (bind contract — looser than core's `LiveSource`, accepts `LiveAggregation`), `SnapshotSource`, `NoInputs`
|
|
531
|
-
| Graph view | `Graph` (`Graph.from(...roots)`, `nodes`, `order()`, `edges()`, `toJSON()`); types `GraphEdge`, `GraphJson`, `GraphNodeJson`, `GraphEdgeJson`
|
|
532
|
-
| Range output buffers | `prepareRange(length, keep, prior)` → `RangeOutput` (`values`, `bits`, `set`, `clear`) carrying `[0, keep)` forward as blocks — values **and** validity; `sealRange(out, length)` → `Float64Column`; `validityByteCount`. Reached from an op as `ctx.out[n]`
|
|
533
|
-
| Ranged recompute | `graph.setSourceFrom(series, changedFrom)` — declares which row first changed; `graph.recomputes` → `{ ranged, full }`. An op opts in with `OpDef.runRange(ctx)`, receiving `{ from, to, previous, previousView, out }` (type `RangeContext`) — write into `out` and return nothing for the block path alongside the usual context. Requires `lookback`. Falls back to a full `run` whenever anything is missing
|
|
534
|
-
| Node budget | `bind(series, { registry, budgetBytes })` — engine-wide cap on retained node values, LRU, enforced after each `run`; `graph.retainedBytes` / `graph.evictions` / `graph.enforceBudget()`. Unbounded when omitted. Skips a node whose consumer still holds its outlet
|
|
535
|
-
| Plan history | `requiredHistory(registry, plan)` → `{ known, rows?, undeclared, byOp }` — the minimum safe tail in rows, folded from per-op `OpDef.lookback`. Sums along nesting, maxes across siblings. `known: false` names ops with no declared lookback rather than defaulting to zero (type `HistoryResult`)
|
|
536
|
-
| Column values | `packColumn` (values → packed `Float64Column`, NaN = missing), `columnBytes` (retained size, for a byte budget), `appendColumn` (column → series; boxing-free when gapless), `columnBuffers` / `columnFromBuffers` (the buffer pair a column is, for an isolate boundary; type `ColumnBuffers`), `columnView` (zero-copy borrowed read view for in-process folds; type `ColumnView`)
|
|
537
|
-
| Plan — registry | `createRegistry({ folds })` / `Registry` (`define`, `get`, `foldFor`, `outputsOf`, `resolveParams
|
|
538
|
-
| Plan — identity | `specId` (content-addressed, param-order invariant, defaults materialized), `refToId`, `explain`, `unitOf`, `columnsOf`, `dependsOn`, `outputKey`
|
|
539
|
-
| Plan — types | `Spec`, `Plan`, `Input` (column name \| `Spec` \| `PickedOutput`), `SpecRef`, `Def` (`OpDef` \| `FoldDef`), `OpContext`, `OpResult`, `FoldContext`, `FactBody`, `isFold`, `ParamDef`, `Params`, `Units`, `InputDef`, `OutputDef`
|
|
540
|
-
| Plan — bind / run | `bind(series, { registry, units })` → `BoundGraph` (`compile`, `setSource`, `ids`, `series`, `columnOf`), `run(graph, { plan, select, onError })` → `RunResult`, `UnitError`
|
|
541
|
-
| Plan — request/response | `RunRequest` (`PlanRequest` \| `SlotRequest`), `RunOptions`, `RunResult`, `Select` (`{ on, output?, name? }` — points at a node; what comes back is what that node produces), `ErrorPolicy`, `Fact` (carries `op`), `OutputInfo`, `Skipped`, `NodeTiming` (`slot`, `pulled`, `cached`, `ms`, `inputs`)
|
|
542
|
-
| Plan — host | `createHost({ registry, units, sources })` → `Host` (`add`, `has`, `datasets`, `graphFor`, `run`, `runAsync`), `toWire`, `UnknownDatasetError`; local-string `Envelope` (`PlanEnvelope` \| `SlotEnvelope`), remote-capable `AsyncEnvelope` (`AsyncPlanEnvelope` \| `AsyncSlotEnvelope` \| `Envelope`), `DatasetInfo`, `WireResult`
|
|
543
|
-
| Plan — slots | `expandSlots(slots, columns)` → `Map<slot, Spec>` (expands to the nested form, so ids match by construction; `slot#Output` picks one output), `SlotError`; types `SlotDef` (`{ op, params, in }`), `Slots`
|
|
544
|
-
| Plan — builder | `plan(from)` → low-level `PlanBuilder`; `process(registry, from)` → typed fluent `ProcessBuilder` (`column`, op methods, `outputs`), `BuilderError`; types `NodeHandle`, `OutputHandle`, `FluentColumnRef`, `SingleColumnNode`, `MultiColumnNode`, `ColumnSelection`, `FactRef`, `BuiltRequest`
|
|
545
|
-
| Plan — async sources | `defineSource({ name, load })`, `createSourceRegistry()` / `SourceRegistry`, `sourceId`, `UnknownSourceError`; types `SourceRef`, `SourceParams`, `LoadedSource` (value + revision), `SourceLoadContext`, `SourceDef`
|
|
546
|
-
| Plan — folds | `STANDARD_FOLDS` and the four it holds — `last`, `extremes`, `percentileRank`, `shape` — pre-registered by `createRegistry()`; each a plain `FoldDef`, so a consumer can `define` over one
|
|
547
|
-
| Errors | `ProcessError` (base), `CycleError`, `UnconnectedInputError`, `MissingOutputError`, `UnsetSourceError`
|
|
548
|
-
| Node type helpers | `NodeSpec`, `NodeFactory`, `InletsFor`, `OutletsFor`, `OutletValue`, `SpecsForOutlets`, `DerivedOutput`
|
|
524
|
+
| Group | Exports | Source |
|
|
525
|
+
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
|
|
526
|
+
| Worker pool (Node) | `HostPool` (`start`, `run`, `close`, `size`, `inFlight`); types `HostPoolOptions`, `PoolSetup`, `PoolSetupConfig`; `toWire` / `fromWire`, types `WireResult`, `WireColumn` — subpath `@pond-ts/process/pool` | `packages/process/src/pool/index.ts` |
|
|
527
|
+
| Ports | `Inlet`, `Outlet` (typed fields on `node.in` / `node.out`; `get()`, `peek()`, `version`, `connect`, `disconnect`) | `packages/process/src/port.ts` |
|
|
528
|
+
| Nodes | `Node` (`in`, `out`, `dirty`, `error`, `invalidate()`), `defineNode` (reusable multi-output node type), `derive` (single-output, wired inline) | `packages/process/src/node.ts` |
|
|
529
|
+
| Port declaration | `port<T>({ equals, defaultValue })`; types `PortSpec`, `PortSpecMap`, `PortValue`, `PortValues` | `packages/process/src/types.ts` |
|
|
530
|
+
| Sources | `source<T>()` → `SourceNode` (`set()`), `fromLive(liveSource)` → `LiveSourceNode` (`dispose()`); `GraphSource` (bind contract — looser than core's `LiveSource`, accepts `LiveAggregation`), `SnapshotSource`, `NoInputs` | `packages/process/src/source.ts` |
|
|
531
|
+
| Graph view | `Graph` (`Graph.from(...roots)`, `nodes`, `order()`, `edges()`, `toJSON()`); types `GraphEdge`, `GraphJson`, `GraphNodeJson`, `GraphEdgeJson` | `packages/process/src/graph.ts` |
|
|
532
|
+
| Range output buffers | `prepareRange(length, keep, prior)` → `RangeOutput` (`values`, `bits`, `set`, `clear`) carrying `[0, keep)` forward as blocks — values **and** validity; `sealRange(out, length)` → `Float64Column`; `validityByteCount`. Reached from an op as `ctx.out[n]` | `packages/process/src/column.ts` |
|
|
533
|
+
| Ranged recompute | `graph.setSourceFrom(series, changedFrom)` — declares which row first changed; `graph.recomputes` → `{ ranged, full }`. An op opts in with `OpDef.runRange(ctx)`, receiving `{ from, to, previous, previousView, out }` (type `RangeContext`) — write into `out` and return nothing for the block path alongside the usual context. Requires `lookback`. Falls back to a full `run` whenever anything is missing | `packages/process/src/plan/graph.ts` |
|
|
534
|
+
| Node budget | `bind(series, { registry, budgetBytes })` — engine-wide cap on retained node values, LRU, enforced after each `run`; `graph.retainedBytes` / `graph.evictions` / `graph.enforceBudget()`. Unbounded when omitted. Skips a node whose consumer still holds its outlet | `packages/process/src/plan/graph.ts` |
|
|
535
|
+
| Plan history | `requiredHistory(registry, plan)` → `{ known, rows?, undeclared, byOp }` — the minimum safe tail in rows, folded from per-op `OpDef.lookback`. Sums along nesting, maxes across siblings. `known: false` names ops with no declared lookback rather than defaulting to zero (type `HistoryResult`) | `packages/process/src/plan/history.ts` |
|
|
536
|
+
| Column values | `packColumn` (values → packed `Float64Column`, NaN = missing), `columnBytes` (retained size, for a byte budget), `appendColumn` (column → series; boxing-free when gapless), `columnBuffers` / `columnFromBuffers` (the buffer pair a column is, for an isolate boundary; type `ColumnBuffers`), `columnView` (zero-copy borrowed read view for in-process folds; type `ColumnView`) | `packages/process/src/column.ts` |
|
|
537
|
+
| Plan — registry | `createRegistry({ folds })` / `Registry` (`define`, `get`, `foldFor`, `outputsOf`, `resolveParams` (`{ validate: false }` applies defaults and skips every check), `byFamily`, `describe`, `toJsonSchema`), param builders `int` / `num` / `choice` / `flag`, `UnknownOpError`, `ParamError` | `packages/process/src/plan/registry.ts`, `params.ts` |
|
|
538
|
+
| Plan — identity | `specId(registry, spec, { validate })` (content-addressed, param-order invariant, defaults materialized; `validate: false` is **total** — names a spec that would not compile in a separate `p1?:` namespace that cannot collide with a valid id; a valid spec's id is identical either way), `SpecIdOptions`, `refToId`, `explain`, `unitOf`, `columnsOf`, `dependsOn`, `outputKey` | `packages/process/src/plan/identity.ts` |
|
|
539
|
+
| Plan — types | `Spec`, `Plan`, `Input` (column name \| `Spec` \| `PickedOutput`), `SpecRef`, `Def` (`OpDef` \| `FoldDef`), `OpContext`, `OpResult`, `FoldContext`, `FactBody`, `isFold`, `ParamDef`, `Params`, `Units`, `InputDef`, `OutputDef` | `packages/process/src/plan/types.ts` |
|
|
540
|
+
| Plan — bind / run | `bind(series, { registry, units })` → `BoundGraph` (`compile`, `setSource`, `ids`, `series`, `columnOf`), `run(graph, { plan, select, onError })` → `RunResult`, `UnitError`, `UnknownColumnError` (a raw string input naming no column of the bound series, checked over the whole closure and re-checked on the warm path) | `packages/process/src/plan/graph.ts`, `run.ts` |
|
|
541
|
+
| Plan — request/response | `RunRequest` (`PlanRequest` \| `SlotRequest`), `RunOptions`, `RunResult`, `Select` (`{ on, output?, name? }` — points at a node; what comes back is what that node produces), `ErrorPolicy`, `Fact` (carries `op`), `OutputInfo`, `Skipped` (`spec`, `select`, `reason`, `code` — the failure's kind, matching the error class a throw would have carried), `NodeTiming` (`slot`, `pulled`, `cached`, `ms`, `inputs`) | `packages/process/src/plan/run.ts` |
|
|
542
|
+
| Plan — host | `createHost({ registry, units, sources })` → `Host` (`add`, `has`, `datasets`, `graphFor`, `run`, `runAsync`), `toWire`, `UnknownDatasetError`; local-string `Envelope` (`PlanEnvelope` \| `SlotEnvelope`), remote-capable `AsyncEnvelope` (`AsyncPlanEnvelope` \| `AsyncSlotEnvelope` \| `Envelope`), `DatasetInfo`, `WireResult` | `packages/process/src/plan/host.ts` |
|
|
543
|
+
| Plan — slots | `expandSlots(slots, columns)` → `Map<slot, Spec>` (expands to the nested form, so ids match by construction; `slot#Output` picks one output), `SlotError`; types `SlotDef` (`{ op, params, in }`), `Slots` | `packages/process/src/plan/slots.ts` |
|
|
544
|
+
| Plan — builder | `plan(from)` → low-level `PlanBuilder`; `process(registry, from)` → typed fluent `ProcessBuilder` (`column`, op methods, `outputs`), `BuilderError`; types `NodeHandle`, `OutputHandle`, `FluentColumnRef`, `SingleColumnNode`, `MultiColumnNode`, `ColumnSelection`, `FactRef`, `BuiltRequest` | `packages/process/src/plan/builder.ts`, `fluent.ts` |
|
|
545
|
+
| Plan — async sources | `defineSource({ name, load })`, `createSourceRegistry()` / `SourceRegistry`, `sourceId`, `UnknownSourceError`; types `SourceRef`, `SourceParams`, `LoadedSource` (value + revision), `SourceLoadContext`, `SourceDef` | `packages/process/src/plan/source.ts` |
|
|
546
|
+
| Plan — folds | `STANDARD_FOLDS` and the four it holds — `last`, `extremes`, `percentileRank`, `shape` — pre-registered by `createRegistry()`; each a plain `FoldDef`, so a consumer can `define` over one | `packages/process/src/plan/folds.ts` |
|
|
547
|
+
| Errors | `ProcessError` (base; `code` — a stable per-class literal, minification-proof, also surfaced on `Skipped`), `CycleError`, `UnconnectedInputError`, `MissingOutputError`, `UnsetSourceError` | `packages/process/src/errors.ts` |
|
|
548
|
+
| Node type helpers | `NodeSpec`, `NodeFactory`, `InletsFor`, `OutletsFor`, `OutletValue`, `SpecsForOutlets`, `DerivedOutput` | `packages/process/src/node.ts` |
|
|
549
549
|
|
|
550
550
|
Note: this package's `npm test` includes a `test:dts` step that typechecks the
|
|
551
551
|
**emitted** `dist/*.d.ts` from a consumer's perspective (`test-dts/`,
|
package/CHANGELOG.md
CHANGED
|
@@ -8,7 +8,8 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
|
|
|
8
8
|
under a single `v*` tag, so this file covers them all. Pre-1.0: minor bumps may
|
|
9
9
|
include new features and type-level changes; patch bumps are strictly additive.
|
|
10
10
|
|
|
11
|
-
[Unreleased]: https://github.com/pond-ts/pond/compare/v0.
|
|
11
|
+
[Unreleased]: https://github.com/pond-ts/pond/compare/v0.62.0...HEAD
|
|
12
|
+
[0.62.0]: https://github.com/pond-ts/pond/compare/v0.61.0...v0.62.0
|
|
12
13
|
[0.61.0]: https://github.com/pond-ts/pond/compare/v0.60.0...v0.61.0
|
|
13
14
|
[0.60.0]: https://github.com/pond-ts/pond/compare/v0.59.0...v0.60.0
|
|
14
15
|
[0.59.0]: https://github.com/pond-ts/pond/compare/v0.58.0...v0.59.0
|
|
@@ -64,6 +65,69 @@ include new features and type-level changes; patch bumps are strictly additive.
|
|
|
64
65
|
|
|
65
66
|
## [Unreleased]
|
|
66
67
|
|
|
68
|
+
## [0.62.0] — 2026-08-16
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
|
|
72
|
+
- `@pond-ts/process`: **`specId` is total under `{ validate: false }`** — a
|
|
73
|
+
third options argument that names a spec which would not compile
|
|
74
|
+
(`specId(registry, spec, { validate: false })`). Identity used to be coupled
|
|
75
|
+
to validity, so the moments a consumer most needs an id — labelling the chip
|
|
76
|
+
it is skipping, keying "this persisted entry is broken", logging what was
|
|
77
|
+
rejected — were exactly the moments it threw, leaving the consumer to
|
|
78
|
+
re-implement canonicalization or carry a second key. Lenient mode still
|
|
79
|
+
applies defaults and sorts keys, carries an undeclared param through rather
|
|
80
|
+
than dropping it, and recurses into nested inputs; **a valid spec has the
|
|
81
|
+
same id under either mode**, so nothing needs a second cache line. Validity
|
|
82
|
+
stays `compile`'s job. `Registry.resolveParams` takes the same
|
|
83
|
+
`{ validate: false }`.
|
|
84
|
+
|
|
85
|
+
An id that did **not** validate is minted in a separate namespace — marked
|
|
86
|
+
`p1?:` instead of `p1:`, with type-preserving param encoding — so it can
|
|
87
|
+
never collide with a legal id. Both measures are confined to that branch:
|
|
88
|
+
a valid id is byte-identical to what shipped in 0.61.0. The mark rides up a
|
|
89
|
+
chain, so a spec over an unvalidated input is unvalidated too.
|
|
90
|
+
|
|
91
|
+
- `@pond-ts/process`: **`Skipped.code`** — every entry in `RunResult.skipped`
|
|
92
|
+
now carries the failure's kind (`'UnknownColumnError'`, `'ParamError'`,
|
|
93
|
+
`'UnitError'`, `'SlotError'`, …) beside its human `reason`. Under
|
|
94
|
+
`onError: 'skip' | 'collect'` nothing is thrown, so `instanceof` — the right
|
|
95
|
+
discriminator when a consumer catches — never reached a consumer reading
|
|
96
|
+
`skipped`, leaving it to match on prose whose wording is not a contract. The
|
|
97
|
+
value is `ProcessError.code`, a **literal declared per class** rather than
|
|
98
|
+
`constructor.name`, so a consumer's minifier cannot silently rename it. It is
|
|
99
|
+
absent when the throw did not come from this package, which is itself the
|
|
100
|
+
signal: op code failed, not the plan layer.
|
|
101
|
+
|
|
102
|
+
### Fixed
|
|
103
|
+
|
|
104
|
+
- `@pond-ts/process`: **`run` under `onError: 'throw'` — the default — now
|
|
105
|
+
raises the original error rather than a base `ProcessError` rebuilt from its
|
|
106
|
+
message.** A caught `UnknownColumnError`, `ParamError`, `UnitError` or
|
|
107
|
+
`SlotError` reached the caller as a bare `ProcessError`, so `instanceof`
|
|
108
|
+
could not discriminate on the throw path at all.
|
|
109
|
+
|
|
110
|
+
- `@pond-ts/process`: **a raw string input naming a column the bound series
|
|
111
|
+
does not carry is now rejected**, at `compile`, with a new
|
|
112
|
+
`UnknownColumnError`. Nothing checked it at compile or at pull: the op ran
|
|
113
|
+
against an un-widened series, and one that doesn't defend its own inputs
|
|
114
|
+
appended a plausible-looking column of garbage under the spec's id — with
|
|
115
|
+
`skipped` empty and `onError` never engaged. A persisted plan citing a column
|
|
116
|
+
the feed has since dropped now skips (or throws) instead of returning a
|
|
117
|
+
value. The check is the one `expandSlots` already made against the same
|
|
118
|
+
column list, so the two request forms no longer disagree; it runs before the
|
|
119
|
+
unit check, whose "is 'unitless'" answer for an absent column named the wrong
|
|
120
|
+
problem. The key/time column is not a value column and is rejected too.
|
|
121
|
+
|
|
122
|
+
The check covers the **whole spec closure** (a missing column under a typed
|
|
123
|
+
parent otherwise surfaced as `UnitError`) and runs on the **warm** path as
|
|
124
|
+
well as the cold one — `setSource` replaces the data under compiled nodes by
|
|
125
|
+
design, so a memoized node could outlive the column it reads and go on
|
|
126
|
+
emitting the garbage column this fix exists to prevent. A node that fails the
|
|
127
|
+
re-check is dropped from the graph.
|
|
128
|
+
(Both items reported by Tidal —
|
|
129
|
+
`docs/notes/tidal-process-adoption-friction-2026-08.md`.)
|
|
130
|
+
|
|
67
131
|
## [0.61.0] — 2026-08-16
|
|
68
132
|
|
|
69
133
|
### Added
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
/** Errors thrown by the graph engine. */
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Base class for every error this package throws.
|
|
4
|
+
*
|
|
5
|
+
* ## Why there is a `code` beside `name`
|
|
6
|
+
*
|
|
7
|
+
* `instanceof` is the right discriminator when a consumer **catches**.
|
|
8
|
+
* But `run` under `onError: 'skip' | 'collect'` does not hand the error
|
|
9
|
+
* back — it reports a `Skipped` record — and a consumer branching
|
|
10
|
+
* its UI on the failure kind (a dropped feed column is a dimmed chip; a
|
|
11
|
+
* bad persisted param is a broken one) then has only prose to go on
|
|
12
|
+
* (Tidal, `docs/notes/tidal-process-adoption-friction-2026-08.md`).
|
|
13
|
+
*
|
|
14
|
+
* Every subclass annotates `code` as `: string` rather than letting the
|
|
15
|
+
* literal type be inferred. Without it the static's type is the literal
|
|
16
|
+
* — `ParamError.code: 'ParamError'` — and a **consumer** subclassing to
|
|
17
|
+
* add its own code fails to compile with TS2417, which would make this
|
|
18
|
+
* additive field a source break for anyone already extending these
|
|
19
|
+
* classes (Codex, PR #667).
|
|
20
|
+
*
|
|
21
|
+
* `code` is that discriminator, and it is a **literal string per class**
|
|
22
|
+
* rather than `constructor.name` because a consumer's minifier may
|
|
23
|
+
* rename the class — leaving `name` as `'t'` in a production build,
|
|
24
|
+
* silently, which is exactly the shape of bug the string exists to let
|
|
25
|
+
* them avoid. Declared once as a `static`, read here through
|
|
26
|
+
* `new.target`, so a subclass states its code on one line.
|
|
27
|
+
*/
|
|
3
28
|
export declare class ProcessError extends Error {
|
|
29
|
+
/** The literal a subclass overrides. Read via `new.target`, not `this`. */
|
|
30
|
+
static readonly code: string;
|
|
31
|
+
/**
|
|
32
|
+
* Stable kind, safe to compare against a literal and to send over a
|
|
33
|
+
* wire. Rides on `Skipped.code` when a run reports rather than throws.
|
|
34
|
+
*/
|
|
35
|
+
readonly code: string;
|
|
4
36
|
constructor(message: string);
|
|
5
37
|
}
|
|
6
38
|
/**
|
|
@@ -9,14 +41,17 @@ export declare class ProcessError extends Error {
|
|
|
9
41
|
* so a cycle surfaces at the line that wires it, not at some later pull.
|
|
10
42
|
*/
|
|
11
43
|
export declare class CycleError extends ProcessError {
|
|
44
|
+
static readonly code: string;
|
|
12
45
|
}
|
|
13
46
|
/** Thrown when pulling through an inlet with no connection and no default. */
|
|
14
47
|
export declare class UnconnectedInputError extends ProcessError {
|
|
48
|
+
static readonly code: string;
|
|
15
49
|
}
|
|
16
50
|
/**
|
|
17
51
|
* Thrown when a node's `compute` omits a declared output, or when an
|
|
18
52
|
* outlet is read before anything has produced a value for it.
|
|
19
53
|
*/
|
|
20
54
|
export declare class MissingOutputError extends ProcessError {
|
|
55
|
+
static readonly code: string;
|
|
21
56
|
}
|
|
22
57
|
//# sourceMappingURL=errors.d.ts.map
|
package/dist/errors.js
CHANGED
|
@@ -1,9 +1,42 @@
|
|
|
1
1
|
/** Errors thrown by the graph engine. */
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Base class for every error this package throws.
|
|
4
|
+
*
|
|
5
|
+
* ## Why there is a `code` beside `name`
|
|
6
|
+
*
|
|
7
|
+
* `instanceof` is the right discriminator when a consumer **catches**.
|
|
8
|
+
* But `run` under `onError: 'skip' | 'collect'` does not hand the error
|
|
9
|
+
* back — it reports a `Skipped` record — and a consumer branching
|
|
10
|
+
* its UI on the failure kind (a dropped feed column is a dimmed chip; a
|
|
11
|
+
* bad persisted param is a broken one) then has only prose to go on
|
|
12
|
+
* (Tidal, `docs/notes/tidal-process-adoption-friction-2026-08.md`).
|
|
13
|
+
*
|
|
14
|
+
* Every subclass annotates `code` as `: string` rather than letting the
|
|
15
|
+
* literal type be inferred. Without it the static's type is the literal
|
|
16
|
+
* — `ParamError.code: 'ParamError'` — and a **consumer** subclassing to
|
|
17
|
+
* add its own code fails to compile with TS2417, which would make this
|
|
18
|
+
* additive field a source break for anyone already extending these
|
|
19
|
+
* classes (Codex, PR #667).
|
|
20
|
+
*
|
|
21
|
+
* `code` is that discriminator, and it is a **literal string per class**
|
|
22
|
+
* rather than `constructor.name` because a consumer's minifier may
|
|
23
|
+
* rename the class — leaving `name` as `'t'` in a production build,
|
|
24
|
+
* silently, which is exactly the shape of bug the string exists to let
|
|
25
|
+
* them avoid. Declared once as a `static`, read here through
|
|
26
|
+
* `new.target`, so a subclass states its code on one line.
|
|
27
|
+
*/
|
|
3
28
|
export class ProcessError extends Error {
|
|
29
|
+
/** The literal a subclass overrides. Read via `new.target`, not `this`. */
|
|
30
|
+
static code = 'ProcessError';
|
|
31
|
+
/**
|
|
32
|
+
* Stable kind, safe to compare against a literal and to send over a
|
|
33
|
+
* wire. Rides on `Skipped.code` when a run reports rather than throws.
|
|
34
|
+
*/
|
|
35
|
+
code;
|
|
4
36
|
constructor(message) {
|
|
5
37
|
super(message);
|
|
6
38
|
this.name = new.target.name;
|
|
39
|
+
this.code = new.target.code;
|
|
7
40
|
}
|
|
8
41
|
}
|
|
9
42
|
/**
|
|
@@ -12,14 +45,17 @@ export class ProcessError extends Error {
|
|
|
12
45
|
* so a cycle surfaces at the line that wires it, not at some later pull.
|
|
13
46
|
*/
|
|
14
47
|
export class CycleError extends ProcessError {
|
|
48
|
+
static code = 'CycleError';
|
|
15
49
|
}
|
|
16
50
|
/** Thrown when pulling through an inlet with no connection and no default. */
|
|
17
51
|
export class UnconnectedInputError extends ProcessError {
|
|
52
|
+
static code = 'UnconnectedInputError';
|
|
18
53
|
}
|
|
19
54
|
/**
|
|
20
55
|
* Thrown when a node's `compute` omits a declared output, or when an
|
|
21
56
|
* outlet is read before anything has produced a value for it.
|
|
22
57
|
*/
|
|
23
58
|
export class MissingOutputError extends ProcessError {
|
|
59
|
+
static code = 'MissingOutputError';
|
|
24
60
|
}
|
|
25
61
|
//# sourceMappingURL=errors.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -41,8 +41,9 @@ export type { DefMap } from './plan/registry.js';
|
|
|
41
41
|
export { isFold } from './plan/types.js';
|
|
42
42
|
export { STANDARD_FOLDS, last, extremes, percentileRank, shape, } from './plan/folds.js';
|
|
43
43
|
export type { OpDescriptor } from './plan/registry.js';
|
|
44
|
+
export type { SpecIdOptions } from './plan/identity.js';
|
|
44
45
|
export { specId, refToId, explain, unitOf, columnsOf, dependsOn, outputKey, } from './plan/identity.js';
|
|
45
|
-
export { bind, BoundGraph, UnitError } from './plan/graph.js';
|
|
46
|
+
export { bind, BoundGraph, UnitError, UnknownColumnError, } from './plan/graph.js';
|
|
46
47
|
export { expandSlots, SlotError } from './plan/slots.js';
|
|
47
48
|
export { plan, PlanBuilder, BuilderError } from './plan/builder.js';
|
|
48
49
|
export type { NodeHandle, OutputHandle, InputRef, BuiltRequest, } from './plan/builder.js';
|
package/dist/index.js
CHANGED
|
@@ -35,7 +35,7 @@ export { UnknownOpError, ParamError } from './plan/registry.js';
|
|
|
35
35
|
export { isFold } from './plan/types.js';
|
|
36
36
|
export { STANDARD_FOLDS, last, extremes, percentileRank, shape, } from './plan/folds.js';
|
|
37
37
|
export { specId, refToId, explain, unitOf, columnsOf, dependsOn, outputKey, } from './plan/identity.js';
|
|
38
|
-
export { bind, BoundGraph, UnitError } from './plan/graph.js';
|
|
38
|
+
export { bind, BoundGraph, UnitError, UnknownColumnError, } from './plan/graph.js';
|
|
39
39
|
export { expandSlots, SlotError } from './plan/slots.js';
|
|
40
40
|
export { plan, PlanBuilder, BuilderError } from './plan/builder.js';
|
|
41
41
|
export { process, ProcessBuilder } from './plan/fluent.js';
|
package/dist/plan/builder.d.ts
CHANGED
|
@@ -43,6 +43,7 @@ import type { Slots } from './slots.js';
|
|
|
43
43
|
import type { ParamValue } from './types.js';
|
|
44
44
|
/** Thrown when a graph is built wrong — before it is ever sent. */
|
|
45
45
|
export declare class BuilderError extends ProcessError {
|
|
46
|
+
static readonly code: string;
|
|
46
47
|
}
|
|
47
48
|
/**
|
|
48
49
|
* The derived slot for a fold over `on` — a function of the node, the
|
package/dist/plan/builder.js
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
import { ProcessError } from '../errors.js';
|
|
40
40
|
/** Thrown when a graph is built wrong — before it is ever sent. */
|
|
41
41
|
export class BuilderError extends ProcessError {
|
|
42
|
+
static code = 'BuilderError';
|
|
42
43
|
}
|
|
43
44
|
/**
|
|
44
45
|
* The derived slot for a fold over `on` — a function of the node, the
|
package/dist/plan/graph.d.ts
CHANGED
|
@@ -15,6 +15,16 @@ import type { Registry } from './registry.js';
|
|
|
15
15
|
import { type FactBody, type Params, type Spec, type Units } from './types.js';
|
|
16
16
|
/** Thrown when an op demands an input unit its source does not carry. */
|
|
17
17
|
export declare class UnitError extends ProcessError {
|
|
18
|
+
static readonly code: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Thrown when a spec's raw input names a column the bound series does
|
|
22
|
+
* not carry — its own class, because a persisted plan outliving a column
|
|
23
|
+
* is the one compile failure a consumer routinely expects and wants to
|
|
24
|
+
* handle rather than report as a bug.
|
|
25
|
+
*/
|
|
26
|
+
export declare class UnknownColumnError extends ProcessError {
|
|
27
|
+
static readonly code: string;
|
|
18
28
|
}
|
|
19
29
|
/** One node per spec, plus the spec and params it was compiled from. */
|
|
20
30
|
interface Compiled {
|
package/dist/plan/graph.js
CHANGED
|
@@ -18,6 +18,20 @@ import { columnsOf, specId, unitOf } from './identity.js';
|
|
|
18
18
|
import { isFold, isPicked, specOf, } from './types.js';
|
|
19
19
|
/** Thrown when an op demands an input unit its source does not carry. */
|
|
20
20
|
export class UnitError extends ProcessError {
|
|
21
|
+
static code = 'UnitError';
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Thrown when a spec's raw input names a column the bound series does
|
|
25
|
+
* not carry — its own class, because a persisted plan outliving a column
|
|
26
|
+
* is the one compile failure a consumer routinely expects and wants to
|
|
27
|
+
* handle rather than report as a bug.
|
|
28
|
+
*/
|
|
29
|
+
export class UnknownColumnError extends ProcessError {
|
|
30
|
+
static code = 'UnknownColumnError';
|
|
31
|
+
}
|
|
32
|
+
/** The bound series' value columns — the key column is not readable as one. */
|
|
33
|
+
function valueColumns(series) {
|
|
34
|
+
return series.schema.slice(1).map((c) => c.name);
|
|
21
35
|
}
|
|
22
36
|
/** The per-output key a node's outlets are addressed by. */
|
|
23
37
|
function outletKey(output) {
|
|
@@ -221,6 +235,29 @@ export class BoundGraph {
|
|
|
221
235
|
}
|
|
222
236
|
}
|
|
223
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Drops a node's lookup AND its wiring — the same two halves eviction
|
|
240
|
+
* needs, without the budget's accounting.
|
|
241
|
+
*
|
|
242
|
+
* Deleting from `#nodes` alone frees nothing and leaves the node
|
|
243
|
+
* reachable from the source: `Outlet.#downstream` holds a strong
|
|
244
|
+
* `Set<Inlet>` and `Inlet.node` points back (learned on PR #571).
|
|
245
|
+
*/
|
|
246
|
+
#forget(id) {
|
|
247
|
+
const compiled = this.#nodes.get(id);
|
|
248
|
+
if (compiled === undefined)
|
|
249
|
+
return;
|
|
250
|
+
for (const inlet of Object.values(compiled.node.in)) {
|
|
251
|
+
inlet.disconnect();
|
|
252
|
+
}
|
|
253
|
+
this.#nodes.delete(id);
|
|
254
|
+
this.#lru.delete(id);
|
|
255
|
+
this.#pendingFrom.delete(id);
|
|
256
|
+
this.#fullDirty.delete(id);
|
|
257
|
+
for (const upstream of compiled.upstream) {
|
|
258
|
+
this.#nodes.get(upstream)?.dependents.delete(id);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
224
261
|
#touch(id) {
|
|
225
262
|
this.#lru.delete(id);
|
|
226
263
|
this.#lru.add(id);
|
|
@@ -289,6 +326,48 @@ export class BoundGraph {
|
|
|
289
326
|
this.#touch(id);
|
|
290
327
|
return hit;
|
|
291
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* Every raw input in a spec's whole closure names a column the bound
|
|
331
|
+
* series carries — [PND-PROCTOTAL].
|
|
332
|
+
*
|
|
333
|
+
* Nothing used to check this, at compile OR at pull: the op simply ran
|
|
334
|
+
* with `ctx.series` un-widened, and whatever a non-defensive op
|
|
335
|
+
* returned was appended under the spec's id. `skipped` stayed empty
|
|
336
|
+
* and `onError` never engaged, so a persisted plan citing a column the
|
|
337
|
+
* feed had dropped produced a plausible-looking column of garbage
|
|
338
|
+
* (Tidal, `docs/notes/tidal-process-adoption-friction-2026-08.md`).
|
|
339
|
+
* The honest outcomes are a skip or a throw, never a value.
|
|
340
|
+
*
|
|
341
|
+
* The check is not new so much as **completed**: `expandSlots` already
|
|
342
|
+
* rejects exactly this against exactly this column list, so one of the
|
|
343
|
+
* two request forms caught it and the other did not.
|
|
344
|
+
*
|
|
345
|
+
* **The whole closure, not just this spec's own inputs**, and that
|
|
346
|
+
* matters for the error a consumer sees rather than for whether one is
|
|
347
|
+
* raised: the typed-unit pass below resolves a nested input's unit
|
|
348
|
+
* before recursion ever reaches the nested spec, so a missing column
|
|
349
|
+
* under a *typed* parent surfaced as `UnitError` — "is 'unitless'",
|
|
350
|
+
* which is true and names the wrong problem, and misses the one class
|
|
351
|
+
* a consumer branches on (Codex, PR #667).
|
|
352
|
+
*/
|
|
353
|
+
#checkRawInputs(spec) {
|
|
354
|
+
const columns = valueColumns(this.series);
|
|
355
|
+
const walk = (at) => {
|
|
356
|
+
const def = this.registry.get(at.op);
|
|
357
|
+
at.inputs.forEach((raw, i) => {
|
|
358
|
+
if (typeof raw !== 'string') {
|
|
359
|
+
walk(specOf(raw));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (columns.includes(raw))
|
|
363
|
+
return;
|
|
364
|
+
throw new UnknownColumnError(`'${at.op}' names '${raw}' for input '${def.inputs[i]?.role ?? i}', which is not a column of the bound series — columns are ${columns.length === 0
|
|
365
|
+
? 'none'
|
|
366
|
+
: columns.map((c) => `'${c}'`).join(', ')}`);
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
walk(spec);
|
|
370
|
+
}
|
|
292
371
|
/**
|
|
293
372
|
* Compiles a spec (and its inputs) into nodes, memoized by `specId`.
|
|
294
373
|
*
|
|
@@ -309,6 +388,26 @@ export class BoundGraph {
|
|
|
309
388
|
const id = specId(this.registry, spec);
|
|
310
389
|
const existing = this.#nodes.get(id);
|
|
311
390
|
if (existing) {
|
|
391
|
+
// Re-checked on the WARM path too, not just on the way in.
|
|
392
|
+
// `setSource` replaces the data under compiled nodes by design
|
|
393
|
+
// (`Host.add`, an async source refresh), so a node can outlive the
|
|
394
|
+
// column it reads — and it then recomputes happily against an
|
|
395
|
+
// un-widened series, which is the original garbage-column bug
|
|
396
|
+
// surviving through the memo. Found by the Codex review of
|
|
397
|
+
// PR #667, reproduced: swap `px` for `other`, re-run the same
|
|
398
|
+
// spec, get a column of 42s with `skipped: []`.
|
|
399
|
+
try {
|
|
400
|
+
this.#checkRawInputs(spec);
|
|
401
|
+
}
|
|
402
|
+
catch (e) {
|
|
403
|
+
// And FORGET it, rather than only refusing to hand it back here.
|
|
404
|
+
// A node left in place is still found by `graph.get`, which is
|
|
405
|
+
// how `run`'s selector pass reaches a node — so the plan pass
|
|
406
|
+
// reported the skip and the selector pulled the stale node
|
|
407
|
+
// anyway, producing the very column the check exists to prevent.
|
|
408
|
+
this.#forget(id);
|
|
409
|
+
throw e;
|
|
410
|
+
}
|
|
312
411
|
this.#touch(id);
|
|
313
412
|
return existing;
|
|
314
413
|
}
|
|
@@ -317,6 +416,10 @@ export class BoundGraph {
|
|
|
317
416
|
if (spec.inputs.length !== op.inputs.length) {
|
|
318
417
|
throw new ProcessError(`${spec.op} takes ${op.inputs.length} input(s), got ${spec.inputs.length}`);
|
|
319
418
|
}
|
|
419
|
+
// After arity — an input index past the declared list is an arity
|
|
420
|
+
// problem, not a column one — and before the typed-unit pass, whose
|
|
421
|
+
// answer for an absent column is a misleading 'unitless'.
|
|
422
|
+
this.#checkRawInputs(spec);
|
|
320
423
|
// Typed inputs: an op may demand a unit its source must already
|
|
321
424
|
// carry. Checked before compiling so the reason names both sides.
|
|
322
425
|
op.inputs.forEach((def, i) => {
|
package/dist/plan/host.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { type SourceRef, type SourceRegistry } from './source.js';
|
|
|
21
21
|
import type { Plan, Units } from './types.js';
|
|
22
22
|
/** Thrown when a request names a dataset the host has not been given. */
|
|
23
23
|
export declare class UnknownDatasetError extends ProcessError {
|
|
24
|
+
static readonly code: string;
|
|
24
25
|
}
|
|
25
26
|
/**
|
|
26
27
|
* A request as it arrives over a wire.
|
package/dist/plan/host.js
CHANGED
|
@@ -17,6 +17,7 @@ import { run } from './run.js';
|
|
|
17
17
|
import { sourceId, } from './source.js';
|
|
18
18
|
/** Thrown when a request names a dataset the host has not been given. */
|
|
19
19
|
export class UnknownDatasetError extends ProcessError {
|
|
20
|
+
static code = 'UnknownDatasetError';
|
|
20
21
|
}
|
|
21
22
|
function isLocalEnvelope(envelope) {
|
|
22
23
|
return typeof envelope.from === 'string';
|
package/dist/plan/identity.d.ts
CHANGED
|
@@ -36,14 +36,61 @@
|
|
|
36
36
|
*/
|
|
37
37
|
import type { Registry } from './registry.js';
|
|
38
38
|
import type { Params, Spec, SpecRef, Units } from './types.js';
|
|
39
|
+
/** Options for {@link specId}. */
|
|
40
|
+
export interface SpecIdOptions {
|
|
41
|
+
/**
|
|
42
|
+
* Whether the op must exist and its params must be legal — default
|
|
43
|
+
* `true`.
|
|
44
|
+
*
|
|
45
|
+
* Pass `false` to name a spec that would not compile. See
|
|
46
|
+
* {@link specId} for why identity is separable from validity.
|
|
47
|
+
*/
|
|
48
|
+
readonly validate?: boolean;
|
|
49
|
+
}
|
|
39
50
|
/**
|
|
40
51
|
* Canonical, versioned id for a spec — simultaneously the **column
|
|
41
52
|
* name**, the **cache key**, and the **provenance citation**.
|
|
42
53
|
*
|
|
43
54
|
* Params are sorted by key and materialized post-defaults, so two
|
|
44
55
|
* spellings of one computation collide deliberately.
|
|
56
|
+
*
|
|
57
|
+
* ## Identity is separable from validity — [PND-PROCTOTAL]
|
|
58
|
+
*
|
|
59
|
+
* By default this validates as it goes, because it resolves params to
|
|
60
|
+
* canonicalize them and an id built from a rejected param would be a
|
|
61
|
+
* cache key for a node that cannot exist.
|
|
62
|
+
*
|
|
63
|
+
* But the moments a consumer most needs an id for an **invalid** spec
|
|
64
|
+
* are exactly the failure paths: labelling the chip it is skipping,
|
|
65
|
+
* keying the "this one is broken" UI state, logging which persisted
|
|
66
|
+
* entry was rejected. Coupling the two left the consumer
|
|
67
|
+
* re-implementing canonicalization — the one thing this function exists
|
|
68
|
+
* to own — or carrying a second key beside a correct one (Tidal,
|
|
69
|
+
* `docs/notes/tidal-process-adoption-friction-2026-08.md`).
|
|
70
|
+
*
|
|
71
|
+
* So `specId(registry, spec, { validate: false })` is **total**: an
|
|
72
|
+
* unknown op keeps its given params verbatim, a known one still gets
|
|
73
|
+
* its defaults applied and its keys sorted, and nothing throws.
|
|
74
|
+
* Validity stays `compile`'s job.
|
|
75
|
+
*
|
|
76
|
+
* **A valid spec has one id under either mode.** Canonicalization is
|
|
77
|
+
* the same code path and `checkParam` never coerces, so the lenient id
|
|
78
|
+
* of a legal spec is the strict one — a consumer may key on it without
|
|
79
|
+
* a second cache line.
|
|
80
|
+
*
|
|
81
|
+
* **An unvalidated id cannot collide with a valid one**, and that takes
|
|
82
|
+
* more than putting the params in: it is marked `p1?:` rather than
|
|
83
|
+
* `p1:`, and its params are encoded type-preservingly. Without both, a
|
|
84
|
+
* spec whose param arrived as `"20"` instead of `20` — a JSON round
|
|
85
|
+
* trip, the very case this mode is for — named the *working* node, since
|
|
86
|
+
* `String(v)` erases the difference `checkParam` would have caught. A
|
|
87
|
+
* valid id is unaffected by either measure, which is why they are
|
|
88
|
+
* confined to this branch.
|
|
89
|
+
*
|
|
90
|
+
* The mark rides **up** a chain: a spec whose nested input did not
|
|
91
|
+
* validate cannot compile either, so it is unvalidated too.
|
|
45
92
|
*/
|
|
46
|
-
export declare function specId(registry: Registry, spec: Spec): string;
|
|
93
|
+
export declare function specId(registry: Registry, spec: Spec, options?: SpecIdOptions): string;
|
|
47
94
|
/** Resolves a reference that may be an inline spec or an id string. */
|
|
48
95
|
export declare function refToId(registry: Registry, ref: SpecRef): string;
|
|
49
96
|
/**
|
package/dist/plan/identity.js
CHANGED
|
@@ -44,32 +44,133 @@ const VERSION = 'p1';
|
|
|
44
44
|
function esc(v) {
|
|
45
45
|
return String(v).replace(/[\\;,()=+]/g, (c) => `\\${c}`);
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Marks an id whose spec did **not** validate — see {@link specId}.
|
|
49
|
+
*
|
|
50
|
+
* A valid id is `p1:…`, so an unvalidated one differs at the character
|
|
51
|
+
* after the version and can never equal one, whatever its params say.
|
|
52
|
+
*/
|
|
53
|
+
const UNVALIDATED = '?';
|
|
54
|
+
/**
|
|
55
|
+
* Type-preserving encoding, used **only** inside an unvalidated id.
|
|
56
|
+
*
|
|
57
|
+
* `esc` is `String(v)`, which erases type — so `{period: '20'}` and
|
|
58
|
+
* `{period: 20}` encode identically. Strict mode is safe from that
|
|
59
|
+
* because `checkParam` rejects the string before it is ever encoded;
|
|
60
|
+
* leniency removes that guard, and a JSON or form round-trip turning a
|
|
61
|
+
* number into a string is precisely the broken persisted spec this whole
|
|
62
|
+
* mode exists to name (found by the Layer 2 and Codex reviews of
|
|
63
|
+
* PR #667, which reproduced `'20'` colliding with `20`).
|
|
64
|
+
*
|
|
65
|
+
* Applied to keys too, because leniency carries an **undeclared** param
|
|
66
|
+
* through: a key spelled `a=1,b` would otherwise forge the encoding of
|
|
67
|
+
* two params. Neither change touches a valid id, which is the property
|
|
68
|
+
* that must not move.
|
|
69
|
+
*/
|
|
70
|
+
function typedEsc(v) {
|
|
71
|
+
return esc(`${typeof v}:${String(v)}`);
|
|
72
|
+
}
|
|
47
73
|
/**
|
|
48
74
|
* Canonical, versioned id for a spec — simultaneously the **column
|
|
49
75
|
* name**, the **cache key**, and the **provenance citation**.
|
|
50
76
|
*
|
|
51
77
|
* Params are sorted by key and materialized post-defaults, so two
|
|
52
78
|
* spellings of one computation collide deliberately.
|
|
79
|
+
*
|
|
80
|
+
* ## Identity is separable from validity — [PND-PROCTOTAL]
|
|
81
|
+
*
|
|
82
|
+
* By default this validates as it goes, because it resolves params to
|
|
83
|
+
* canonicalize them and an id built from a rejected param would be a
|
|
84
|
+
* cache key for a node that cannot exist.
|
|
85
|
+
*
|
|
86
|
+
* But the moments a consumer most needs an id for an **invalid** spec
|
|
87
|
+
* are exactly the failure paths: labelling the chip it is skipping,
|
|
88
|
+
* keying the "this one is broken" UI state, logging which persisted
|
|
89
|
+
* entry was rejected. Coupling the two left the consumer
|
|
90
|
+
* re-implementing canonicalization — the one thing this function exists
|
|
91
|
+
* to own — or carrying a second key beside a correct one (Tidal,
|
|
92
|
+
* `docs/notes/tidal-process-adoption-friction-2026-08.md`).
|
|
93
|
+
*
|
|
94
|
+
* So `specId(registry, spec, { validate: false })` is **total**: an
|
|
95
|
+
* unknown op keeps its given params verbatim, a known one still gets
|
|
96
|
+
* its defaults applied and its keys sorted, and nothing throws.
|
|
97
|
+
* Validity stays `compile`'s job.
|
|
98
|
+
*
|
|
99
|
+
* **A valid spec has one id under either mode.** Canonicalization is
|
|
100
|
+
* the same code path and `checkParam` never coerces, so the lenient id
|
|
101
|
+
* of a legal spec is the strict one — a consumer may key on it without
|
|
102
|
+
* a second cache line.
|
|
103
|
+
*
|
|
104
|
+
* **An unvalidated id cannot collide with a valid one**, and that takes
|
|
105
|
+
* more than putting the params in: it is marked `p1?:` rather than
|
|
106
|
+
* `p1:`, and its params are encoded type-preservingly. Without both, a
|
|
107
|
+
* spec whose param arrived as `"20"` instead of `20` — a JSON round
|
|
108
|
+
* trip, the very case this mode is for — named the *working* node, since
|
|
109
|
+
* `String(v)` erases the difference `checkParam` would have caught. A
|
|
110
|
+
* valid id is unaffected by either measure, which is why they are
|
|
111
|
+
* confined to this branch.
|
|
112
|
+
*
|
|
113
|
+
* The mark rides **up** a chain: a spec whose nested input did not
|
|
114
|
+
* validate cannot compile either, so it is unvalidated too.
|
|
53
115
|
*/
|
|
54
|
-
export function specId(registry, spec) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
116
|
+
export function specId(registry, spec, options = {}) {
|
|
117
|
+
return build(registry, spec, options.validate === false).id;
|
|
118
|
+
}
|
|
119
|
+
/** An id, and whether anything in its closure failed validation. */
|
|
120
|
+
function build(registry, spec, lenient) {
|
|
121
|
+
let unvalidated = false;
|
|
122
|
+
// Inputs first: a nested spec that did not validate marks this one,
|
|
123
|
+
// and the mark has to be known before the params are encoded.
|
|
124
|
+
//
|
|
125
|
+
// `?? []` because a spec arriving from persistence may be missing the
|
|
126
|
+
// field entirely, and a mode whose promise is totality cannot answer a
|
|
127
|
+
// dropped key with a TypeError. Strict mode reaches `compile`'s arity
|
|
128
|
+
// check instead, which says what is actually wrong.
|
|
129
|
+
const inputs = (spec.inputs ?? [])
|
|
62
130
|
.map((i) => {
|
|
63
131
|
if (typeof i === 'string')
|
|
64
132
|
return esc(i);
|
|
65
133
|
// `#Lower` rather than a separate field: an input picking a
|
|
66
134
|
// different output is a different computation, and the id is what
|
|
67
135
|
// says so.
|
|
68
|
-
const base =
|
|
69
|
-
|
|
136
|
+
const base = build(registry, specOf(i), lenient);
|
|
137
|
+
if (base.unvalidated)
|
|
138
|
+
unvalidated = true;
|
|
139
|
+
return isPicked(i) ? `${base.id}#${esc(i.output)}` : base.id;
|
|
70
140
|
})
|
|
71
141
|
.join('+');
|
|
72
|
-
|
|
142
|
+
let params;
|
|
143
|
+
if (lenient && !registry.has(spec.op)) {
|
|
144
|
+
unvalidated = true;
|
|
145
|
+
params = spec.params ?? {};
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
const op = registry.get(spec.op);
|
|
149
|
+
try {
|
|
150
|
+
// The strict resolve first even under leniency: when it succeeds
|
|
151
|
+
// the id is byte-identical to the validating one, which is the
|
|
152
|
+
// whole contract. Only its failure moves this spec into the
|
|
153
|
+
// unvalidated namespace.
|
|
154
|
+
params = registry.resolveParams(op, spec.params);
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
if (!lenient)
|
|
158
|
+
throw e;
|
|
159
|
+
unvalidated = true;
|
|
160
|
+
params = registry.resolveParams(op, spec.params, { validate: false });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const encodeKey = unvalidated ? esc : (k) => k;
|
|
164
|
+
const encodeValue = unvalidated ? typedEsc : esc;
|
|
165
|
+
const p = Object.entries(params)
|
|
166
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
167
|
+
.map(([k, v]) => `${encodeKey(k)}=${encodeValue(v)}`)
|
|
168
|
+
.join(',');
|
|
169
|
+
const mark = unvalidated ? UNVALIDATED : '';
|
|
170
|
+
return {
|
|
171
|
+
id: `${VERSION}${mark}:${spec.op}(${inputs};${p})`,
|
|
172
|
+
unvalidated,
|
|
173
|
+
};
|
|
73
174
|
}
|
|
74
175
|
/** Resolves a reference that may be an inline spec or an id string. */
|
|
75
176
|
export function refToId(registry, ref) {
|
package/dist/plan/registry.d.ts
CHANGED
|
@@ -26,9 +26,11 @@ type OutputShape = {
|
|
|
26
26
|
};
|
|
27
27
|
/** Thrown when a plan names an op the registry does not have. */
|
|
28
28
|
export declare class UnknownOpError extends ProcessError {
|
|
29
|
+
static readonly code: string;
|
|
29
30
|
}
|
|
30
31
|
/** Thrown when a param is missing, mistyped, or out of range. */
|
|
31
32
|
export declare class ParamError extends ProcessError {
|
|
33
|
+
static readonly code: string;
|
|
32
34
|
}
|
|
33
35
|
/** Op metadata as a picker or a tool catalog wants it. */
|
|
34
36
|
export interface OpDescriptor {
|
|
@@ -87,8 +89,20 @@ export declare class Registry<Defs extends DefMap = {}> {
|
|
|
87
89
|
* an optional-chain at each of the nine call sites.
|
|
88
90
|
*/
|
|
89
91
|
outputsOf(def: Def): readonly OutputShape[];
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Applies defaults, then validates every declared param.
|
|
94
|
+
*
|
|
95
|
+
* `validate: false` applies the defaults and skips every check,
|
|
96
|
+
* including the unknown-key rejection — an undeclared param is carried
|
|
97
|
+
* through rather than refused. Total by construction, which is what
|
|
98
|
+
* lets `specId` name a spec it would refuse to compile
|
|
99
|
+
* ([PND-PROCTOTAL]). A **valid** spec resolves identically either way:
|
|
100
|
+
* `checkParam` returns its input unchanged and never coerces, so
|
|
101
|
+
* leniency cannot move an id.
|
|
102
|
+
*/
|
|
103
|
+
resolveParams(op: Def, given?: Readonly<Record<string, ParamValue>>, options?: {
|
|
104
|
+
validate?: boolean;
|
|
105
|
+
}): Params;
|
|
92
106
|
/** Grouped for a picker. */
|
|
93
107
|
byFamily(): Map<string, OpDescriptor[]>;
|
|
94
108
|
describe(): OpDescriptor[];
|
package/dist/plan/registry.js
CHANGED
|
@@ -18,9 +18,11 @@ export { int, num, choice, flag } from './params.js';
|
|
|
18
18
|
import { isFold } from './types.js';
|
|
19
19
|
/** Thrown when a plan names an op the registry does not have. */
|
|
20
20
|
export class UnknownOpError extends ProcessError {
|
|
21
|
+
static code = 'UnknownOpError';
|
|
21
22
|
}
|
|
22
23
|
/** Thrown when a param is missing, mistyped, or out of range. */
|
|
23
24
|
export class ParamError extends ProcessError {
|
|
25
|
+
static code = 'ParamError';
|
|
24
26
|
}
|
|
25
27
|
/**
|
|
26
28
|
* Validates one param and returns it.
|
|
@@ -170,15 +172,33 @@ export class Registry {
|
|
|
170
172
|
outputsOf(def) {
|
|
171
173
|
return isFold(def) ? [] : def.outputs;
|
|
172
174
|
}
|
|
173
|
-
/**
|
|
174
|
-
|
|
175
|
+
/**
|
|
176
|
+
* Applies defaults, then validates every declared param.
|
|
177
|
+
*
|
|
178
|
+
* `validate: false` applies the defaults and skips every check,
|
|
179
|
+
* including the unknown-key rejection — an undeclared param is carried
|
|
180
|
+
* through rather than refused. Total by construction, which is what
|
|
181
|
+
* lets `specId` name a spec it would refuse to compile
|
|
182
|
+
* ([PND-PROCTOTAL]). A **valid** spec resolves identically either way:
|
|
183
|
+
* `checkParam` returns its input unchanged and never coerces, so
|
|
184
|
+
* leniency cannot move an id.
|
|
185
|
+
*/
|
|
186
|
+
resolveParams(op, given = {}, options = {}) {
|
|
187
|
+
const validate = options.validate !== false;
|
|
175
188
|
const out = {};
|
|
176
189
|
for (const [key, def] of Object.entries(op.params)) {
|
|
177
190
|
const raw = Object.hasOwn(given, key) ? given[key] : def.default;
|
|
178
|
-
out[key] = checkParam(op.name, key, def, raw);
|
|
191
|
+
out[key] = validate ? checkParam(op.name, key, def, raw) : raw;
|
|
179
192
|
}
|
|
180
193
|
for (const key of Object.keys(given)) {
|
|
181
194
|
if (!Object.hasOwn(op.params, key)) {
|
|
195
|
+
// Carried, not dropped: two specs differing only in a bogus param
|
|
196
|
+
// are two different broken specs, and an id that collapsed them
|
|
197
|
+
// would key one UI state onto both.
|
|
198
|
+
if (!validate) {
|
|
199
|
+
out[key] = given[key];
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
182
202
|
throw new ParamError(`${op.name} has no param '${key}' — takes ${Object.keys(op.params)
|
|
183
203
|
.map((k) => `'${k}'`)
|
|
184
204
|
.join(', ') || 'none'}`);
|
package/dist/plan/run.d.ts
CHANGED
|
@@ -171,6 +171,20 @@ export interface Skipped {
|
|
|
171
171
|
};
|
|
172
172
|
readonly select?: Select;
|
|
173
173
|
readonly reason: string;
|
|
174
|
+
/**
|
|
175
|
+
* The failure's kind — {@link ProcessError.code}, e.g.
|
|
176
|
+
* `'UnknownColumnError'`. Absent when the throw did not come from this
|
|
177
|
+
* package, which is itself the useful signal: op code failed, not the
|
|
178
|
+
* plan layer.
|
|
179
|
+
*
|
|
180
|
+
* `reason` is prose for a human and its wording is not a contract.
|
|
181
|
+
* Under `onError: 'skip' | 'collect'` nothing is thrown, so without
|
|
182
|
+
* this a consumer branching on the kind — a dropped feed column is a
|
|
183
|
+
* dimmed, removable chip; a bad persisted param is a broken one — was
|
|
184
|
+
* left matching on that prose (Tidal,
|
|
185
|
+
* `docs/notes/tidal-process-adoption-friction-2026-08.md`).
|
|
186
|
+
*/
|
|
187
|
+
readonly code?: string;
|
|
174
188
|
}
|
|
175
189
|
export interface RunResult {
|
|
176
190
|
/** Present only when a `columns` selector asked for it, and `assemble`. */
|
package/dist/plan/run.js
CHANGED
|
@@ -64,6 +64,19 @@ function normalize(graph, request) {
|
|
|
64
64
|
});
|
|
65
65
|
return { plan: [...expanded.values()], select, slotOf };
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The `reason` and `code` a caught throw contributes to a {@link Skipped}.
|
|
69
|
+
*
|
|
70
|
+
* One place, so every failure path reports its kind the same way — the
|
|
71
|
+
* column loop and the fact loop already diverged once on `onError`
|
|
72
|
+
* itself, which is the argument for not writing this twice.
|
|
73
|
+
*/
|
|
74
|
+
function describe(e) {
|
|
75
|
+
return {
|
|
76
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
77
|
+
...(e instanceof ProcessError && { code: e.code }),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
67
80
|
export function run(graph, request) {
|
|
68
81
|
const { onError = 'throw', assemble = true } = request;
|
|
69
82
|
const registry = graph.registry;
|
|
@@ -85,15 +98,26 @@ export function run(graph, request) {
|
|
|
85
98
|
outputs: {},
|
|
86
99
|
facts: [],
|
|
87
100
|
explain: {},
|
|
88
|
-
skipped: [
|
|
101
|
+
skipped: [describe(e)],
|
|
89
102
|
nodes: [],
|
|
90
103
|
};
|
|
91
104
|
}
|
|
92
105
|
const { plan, select, slotOf } = normalized;
|
|
93
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Reports a failure, or rethrows it — the ORIGINAL error, not a
|
|
108
|
+
* reconstruction.
|
|
109
|
+
*
|
|
110
|
+
* Rebuilding it as a base `ProcessError` from `entry.reason` erased the
|
|
111
|
+
* class on the default policy: `graph.compile` threw
|
|
112
|
+
* `UnknownColumnError` and `run` turned it into a `ProcessError`, so a
|
|
113
|
+
* consumer catching could not branch on the very class this PR added,
|
|
114
|
+
* and the documented "`code` matches what a throw would have carried"
|
|
115
|
+
* was false in one direction (Codex, PR #667).
|
|
116
|
+
*/
|
|
117
|
+
const fail = (error, entry) => {
|
|
94
118
|
if (onError === 'throw')
|
|
95
|
-
throw
|
|
96
|
-
skipped.push(entry);
|
|
119
|
+
throw error;
|
|
120
|
+
skipped.push({ ...entry, ...describe(error) });
|
|
97
121
|
};
|
|
98
122
|
// ── resolve the plan ───────────────────────────────────────
|
|
99
123
|
for (const spec of plan) {
|
|
@@ -102,13 +126,12 @@ export function run(graph, request) {
|
|
|
102
126
|
resolved.push({ id: compiled.id, spec });
|
|
103
127
|
}
|
|
104
128
|
catch (e) {
|
|
105
|
-
fail({
|
|
129
|
+
fail(e, {
|
|
106
130
|
spec: {
|
|
107
131
|
op: spec.op,
|
|
108
132
|
params: { ...(spec.params ?? {}) },
|
|
109
133
|
inputs: spec.inputs,
|
|
110
134
|
},
|
|
111
|
-
reason: e instanceof Error ? e.message : String(e),
|
|
112
135
|
});
|
|
113
136
|
}
|
|
114
137
|
}
|
|
@@ -136,7 +159,7 @@ export function run(graph, request) {
|
|
|
136
159
|
}
|
|
137
160
|
}
|
|
138
161
|
catch (e) {
|
|
139
|
-
fail({ select: sel
|
|
162
|
+
fail(e, { select: sel });
|
|
140
163
|
continue;
|
|
141
164
|
}
|
|
142
165
|
selectors.push({ sel, id });
|
|
@@ -265,10 +288,10 @@ export function run(graph, request) {
|
|
|
265
288
|
if (sel.output !== undefined &&
|
|
266
289
|
!declared.some((o) => o.id === sel.output)) {
|
|
267
290
|
const have = declared.map((o) => `'${o.id}'`).join(', ');
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
});
|
|
291
|
+
// Built here rather than caught, so the kind is stated rather
|
|
292
|
+
// than derived — a `Skipped` from the plan layer always
|
|
293
|
+
// carries one, and the throw policy raises the same object.
|
|
294
|
+
fail(new ProcessError(`'${compiled.spec.op}' has no output '${sel.output}' (has ${have})`), { select: sel });
|
|
272
295
|
continue;
|
|
273
296
|
}
|
|
274
297
|
// Pulling a column runs op code, which can throw like anything
|
|
@@ -299,12 +322,7 @@ export function run(graph, request) {
|
|
|
299
322
|
});
|
|
300
323
|
}
|
|
301
324
|
catch (e) {
|
|
302
|
-
|
|
303
|
-
throw e;
|
|
304
|
-
fail({
|
|
305
|
-
select: sel,
|
|
306
|
-
reason: e instanceof Error ? e.message : String(e),
|
|
307
|
-
});
|
|
325
|
+
fail(e, { select: sel });
|
|
308
326
|
}
|
|
309
327
|
}
|
|
310
328
|
}
|
|
@@ -314,7 +332,7 @@ export function run(graph, request) {
|
|
|
314
332
|
for (const { sel, id } of selectors) {
|
|
315
333
|
const compiled = graph.get(id);
|
|
316
334
|
if (compiled === undefined) {
|
|
317
|
-
fail(
|
|
335
|
+
fail(new ProcessError(`'${id}' is not in this plan`), { select: sel });
|
|
318
336
|
continue;
|
|
319
337
|
}
|
|
320
338
|
if (!compiled.fold)
|
|
@@ -337,7 +355,7 @@ export function run(graph, request) {
|
|
|
337
355
|
});
|
|
338
356
|
}
|
|
339
357
|
catch (e) {
|
|
340
|
-
fail({ select: sel
|
|
358
|
+
fail(e, { select: sel });
|
|
341
359
|
}
|
|
342
360
|
}
|
|
343
361
|
// Anything the plan resolved but nothing selected. After the pulls, so
|
package/dist/plan/slots.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { ProcessError } from '../errors.js';
|
|
|
32
32
|
import type { ParamValue, Spec } from './types.js';
|
|
33
33
|
/** Thrown when a slot graph cannot be expanded. */
|
|
34
34
|
export declare class SlotError extends ProcessError {
|
|
35
|
+
static readonly code: string;
|
|
35
36
|
}
|
|
36
37
|
/** One node in a slot graph. `in` names source columns or other slots. */
|
|
37
38
|
export interface SlotDef {
|
package/dist/plan/slots.js
CHANGED
package/dist/plan/source.d.ts
CHANGED
|
@@ -37,6 +37,7 @@ export declare function defineSource<const Name extends string, const Params ext
|
|
|
37
37
|
readonly load: SourceDef<Name, Params>['load'];
|
|
38
38
|
}): SourceDef<Name, Params>;
|
|
39
39
|
export declare class UnknownSourceError extends ProcessError {
|
|
40
|
+
static readonly code: string;
|
|
40
41
|
}
|
|
41
42
|
export declare class SourceRegistry {
|
|
42
43
|
#private;
|
package/dist/plan/source.js
CHANGED
package/dist/source.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { PortSpec } from './types.js';
|
|
|
17
17
|
export type NoInputs = Readonly<Record<string, never>>;
|
|
18
18
|
/** Thrown when a source is pulled before a value has been set. */
|
|
19
19
|
export declare class UnsetSourceError extends ProcessError {
|
|
20
|
+
static readonly code: string;
|
|
20
21
|
}
|
|
21
22
|
/**
|
|
22
23
|
* A graph input whose value is pushed in from outside via {@link set}.
|
package/dist/source.js
CHANGED
|
@@ -7,6 +7,7 @@ import { ProcessError } from './errors.js';
|
|
|
7
7
|
import { Node } from './node.js';
|
|
8
8
|
/** Thrown when a source is pulled before a value has been set. */
|
|
9
9
|
export class UnsetSourceError extends ProcessError {
|
|
10
|
+
static code = 'UnsetSourceError';
|
|
10
11
|
}
|
|
11
12
|
/**
|
|
12
13
|
* A graph input whose value is pushed in from outside via {@link set}.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pond-ts/process",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.0",
|
|
4
4
|
"description": "Computations as data over pond-ts: processing graphs authored fluently or composed as JSON, resolved against a declared op vocabulary with content-addressed caching, provenance, and per-node timings. Experimental, pre-1.0.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"verify": "npm run format:check && npm run build && npm test"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"pond-ts": "^0.
|
|
45
|
+
"pond-ts": "^0.62.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"typescript": "^5.6.3",
|