Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/hooks-add-pipe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@logosdx/hooks": minor
"@logosdx/fetch": patch
---

`HookEngine.addPipe()` registers pipe middleware (#147)

`@logosdx/hooks`:

- New `addPipe(name, callback, options?)` method, typed against the lifecycle's `(next, ...args, ctx)` shape. Pipe middleware — retry, dedupe, caching execution — now registers with full type inference instead of requiring an `as any` cast on `add()`.
- `addPipe` shares the same registry, `AddOptions` semantics (`priority`, `once`, `times`, `ignoreOnFail`), cleanup-function return, and `register()` strict-mode enforcement as `add()`. Runtime behavior of `add`, `pipe`, `pipeSync` is unchanged.

`@logosdx/fetch`:

- `retryPlugin` and `dedupePlugin` register their `execute` middleware via `addPipe` instead of `add(... as any)`. No behavior change.
25 changes: 17 additions & 8 deletions docs/packages/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ new HookEngine<Lifecycle, FailArgs>(options?)
|--------|-------------|
| `register(...names)` | Enable strict mode. Returns `this` for chaining. |
| `add(name, callback, options?)` | Subscribe. Returns cleanup function. |
| `addPipe(name, callback, options?)` | Subscribe pipe middleware. Returns cleanup function. |
| `run(name, ...args)` | Run hook async. Returns `Promise<RunResult>`. |
| `runSync(name, ...args)` | Run hook sync. Returns `RunResult`. |
| `pipe(name, coreFn, ...args)` | Pipe hook async (onion middleware). Returns result. |
Expand Down Expand Up @@ -195,6 +196,8 @@ const actualResult = await doWork(...args);

### AddOptions

Same options for `add()` and `addPipe()`:

```typescript
hooks.add('name', callback, {
once: true, // Remove after first run (sugar for times: 1)
Expand Down Expand Up @@ -262,9 +265,9 @@ const wrappedValidate = hooks.wrapSync(
// Post: receives (result, ...args, ctx) — can transform result
```

### pipe() / pipeSync()
### addPipe()

Onion/middleware composition where each callback wraps the next. Used for cross-cutting concerns like retry, deduplication, and caching execution.
Subscribe pipe middleware to a lifecycle hook, typed against the lifecycle's `(next, ...args, ctx)` shape — middleware registers with full inference, no `as any` cast required.

```typescript
interface PipeLifecycle {
Expand All @@ -274,18 +277,24 @@ interface PipeLifecycle {
const hooks = new HookEngine<PipeLifecycle>()
.register('execute');

// Add middleware — receives (next, ...args, ctx)
hooks.add('execute', async (next, opts, ctx) => {
// addPipe infers (next, opts, ctx) from the lifecycle signature
hooks.addPipe('execute', async (next, opts, ctx) => {
console.log('before core');
const result = await next(); // call next middleware or core function
console.log('after core');
return result;
}, { priority: -10 });
```

// Run the pipe — core function is the innermost call
### pipe() / pipeSync()

Run middleware registered via `addPipe()` as an onion/middleware composition — each callback wraps the next. Used for cross-cutting concerns like retry, deduplication, and caching execution.

```typescript
// Run the pipe — core function is the innermost call, closes over opts
const result = await hooks.pipe('execute',
async (opts) => fetch(opts.url, opts), // core function
opts // spread args
() => fetch(opts.url, opts), // core function
opts // passed to each middleware call
);
```

Expand All @@ -304,7 +313,7 @@ const result = await hooks.pipe('execute',

```typescript
const result = hooks.pipeSync('validate',
(data) => validate(data),
() => validate(data),
data
);
```
Expand Down
97 changes: 97 additions & 0 deletions docs/spec/hooks-add-pipe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Spec: `HookEngine.addPipe()` — typed registration for pipe middleware

Resolves [#147](https://github.com/logosdx/monorepo/issues/147). The issue body is the design
analysis: three options were weighed there (an `add` overload, a branded `Pipe<>` lifecycle
marker, a sibling method) and the sibling method was chosen. This spec is the implementation
contract for that choice.

## Problem

`HookEngine.add()` types every callback as `HookCallback` (the `run()` shape: `(...args, ctx)`
returning `void | EarlyReturnSignal`). Pipe middleware is `(next, ...args, ctx)` and must return
the response, so it cannot be registered without `as any` or `@ts-expect-error`. The exported
`PipeCallback` type describes the correct shape but is accepted nowhere. In this repo,
`packages/fetch/src/plugins/retry.ts:86` and `packages/fetch/src/plugins/dedupe.ts:131` carry
the cast today.

## Decision

Add one public method, `addPipe`, typed with `PipeCallback` against the lifecycle signature.
It stores into the **same registry** `add()` uses — `pipe()`/`pipeSync()` already invoke
whatever was stored, so the fix is type-level; runtime behavior of `add`, `pipe`, `pipeSync`
is unchanged.

## API contract

```ts
addPipe<K extends HookName<Lifecycle>>(
name: K,
callback: PipeCallback<
Parameters<FuncOrNever<Lifecycle[K]>>,
Awaited<ReturnType<FuncOrNever<Lifecycle[K]>>>,
FailArgs
>,
options?: HookEngine.AddOptions,
): () => void;
```

- Same runtime validation as `add`: assert `name` is a string, assert `callback` is a function,
enforce `register()` strict mode (error message names `addPipe`).
- Same `AddOptions` semantics: `priority` (lower = outermost layer), `once`, `times`,
`ignoreOnFail` — all already honored by `pipe()`'s chain builder.
- Returns the same cleanup-function shape as `add`.
- Implementation shares the insertion logic with `add` (extract a private helper both call);
do not duplicate the priority-insert loop. No public-facing casts; the internal registry is
already `HookEntry<any, FailArgs>`.
- JSDoc with a WHY-bearing example mirroring `pipe()`'s retry/dedupe example, and the
`add` JSDoc's pipe-shaped example moves to `addPipe` (that example currently does not
compile against `add` — it is the bug).

## Checkpoints

| # | Deliverable | Where | Done when |
|---|-------------|-------|-----------|
| 1 | `addPipe` method + shared insert helper + JSDoc | `packages/hooks/src/index.ts` | Issue #147's repro compiles cast-free using `addPipe`; `pnpm build` green |
| 2 | Runtime + type coverage | `tests/src/hooks.ts` | New `engine.addPipe()`, `engine.pipe()`, `engine.pipeSync()` describes pass (see Verification) |
| 3 | Migrate fetch plugins to `addPipe` | `packages/fetch/src/plugins/retry.ts`, `packages/fetch/src/plugins/dedupe.ts` | The outer `(... ) as any` on both registrations is gone; fetch test suite green |
| 4 | Docs + changeset | `docs/packages/hooks.md`, `skills/logosdx/references/hooks.md`, `.changeset/` | Both doc surfaces show `addPipe`; changeset: minor `@logosdx/hooks`, patch `@logosdx/fetch` |

## Verification

- Checkpoint 2 must cover, at minimum:
- `addPipe`-registered middleware executes via `pipe()` and `pipeSync()` (onion order
follows `priority`, lower = outermost).
- Short-circuit by not calling `next()`; result propagation from `next()`.
- `ctx.args()` replacement reaching inner layers; `ctx.fail()`; `ctx.removeHook()`.
- `once`, `times`, `ignoreOnFail` options through the pipe path.
- Cleanup function removes the middleware.
- Strict-mode `register()` enforcement for `addPipe`.
- Type-level: the issue's repro (a `PipeCallback`-typed const and an inline callback with
inferred params) compiles when passed to `addPipe`; test file compiles under the suite's
type checking with relative imports to `packages/hooks/src/index.ts`.
- **Build before test**: test-suite package imports resolve `@logosdx/*` to `dist/`. Run
`pnpm build` after touching `packages/hooks/src` or the fetch plugins, or new exports throw
"not a function" at test time.
- Full gate: `pnpm build` then `pnpm test` from repo root, all green.

## Non-goals

- Tightening `pipe()`/`pipeSync()` argument typing against the lifecycle (`...args: unknown[]`,
unbound `R`) — potentially breaking for existing consumers; the issue lists it as Related,
not as the fix.
- The branded `Pipe<>` lifecycle-marker design — rejected in the issue (requires every
consumer lifecycle to annotate).
- `PipeOptions.append` typing — Related-listed, out of scope.
- Any behavior change to `add`, `run`, `runSync`, `pipe`, `pipeSync`.
- De-`any`-ing the interiors of the fetch retry/dedupe plugins beyond removing the outer
registration cast.

## Change log

- 2026-08-08: Initial spec from issue #147 (autopilot).
- 2026-08-09: Implemented as specified, no contract deviations; shipped as a single
squashed commit (PR #148). Checkpoints 1–2: `addPipe` + `#insertEntry` extraction +
26 tests — first direct `pipe()`/`pipeSync()` coverage in the suite. Checkpoint 3:
retry/dedupe migration, 6-line diff. Checkpoint 4: docs + changeset; also fixed
three pre-existing doc examples that passed args to the zero-arg `coreFn`. Full
gate green: build, 2434 tests, tsc clean.
2 changes: 1 addition & 1 deletion docs/wiki/fetch.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ description: HTTP client (`FetchEngine`) with resilience policies, plugins, cook

- Depends on `@logosdx/utils` for `attempt`, flow control, and validation helpers.
- Depends on `@logosdx/observer` for event emission; `FetchEngine` emits typed lifecycle events.
- Depends on `@logosdx/hooks` for the entire plugin architecture: `FetchEngine.hooks` is a `HookEngine<FetchLifecycle>` ([`packages/fetch/src/engine/index.ts`](../../packages/fetch/src/engine/index.ts)); every request runs the three-phase pipeline `beforeRequest` (run) → `execute` (pipe, onion-wrapped: retry wraps dedupe wraps the network call) → `afterRequest` (run) in [`packages/fetch/src/engine/executor.ts`](../../packages/fetch/src/engine/executor.ts); built-in policies install hooks at negative priorities so user hooks run after them; per-call `CallConfig.hooks` appends one-request hooks; a `HookScope` carries per-request state between phases. See [`docs/wiki/hooks.md`](hooks.md).
- Depends on `@logosdx/hooks` for the entire plugin architecture: `FetchEngine.hooks` is a `HookEngine<FetchLifecycle>` ([`packages/fetch/src/engine/index.ts`](../../packages/fetch/src/engine/index.ts)); every request runs the three-phase pipeline `beforeRequest` (run) → `execute` (pipe, onion-wrapped: retry wraps dedupe wraps the network call) → `afterRequest` (run) in [`packages/fetch/src/engine/executor.ts`](../../packages/fetch/src/engine/executor.ts); built-in policies install hooks at negative priorities so user hooks run after them; per-call `CallConfig.hooks` appends one-request hooks; a `HookScope` carries per-request state between phases; `retryPlugin` and `dedupePlugin` register their `execute`-phase middleware via `engine.hooks.addPipe('execute', ...)` — the typed, cast-free pipe registration, replacing a prior `engine.hooks.add((... ) as any, ...)` call. See [`docs/wiki/hooks.md`](hooks.md).
- `@logosdx/react` wraps `FetchEngine` via `createFetchContext` in [`packages/react/src/fetch.ts`](../../packages/react/src/fetch.ts); also exports `useQuery`, `useMutation`, `useAsync`, `createApiHooks`. A change to `FetchResponse`/`FetchError` here forces a matching change to `FetchFailure` in [`packages/react/src/types.ts`](../../packages/react/src/types.ts).
- Tests in [`tests/src/fetch/`](../../tests/src/fetch) cover engine, cookies, policies, serializers, state, adapters; [`tests/src/fetch/engine/plugin-resolution.test.ts`](../../tests/src/fetch/engine/plugin-resolution.test.ts) (17 tests) covers config-key/`plugins` array composition, the construction-time conflict throws, runtime `config.set()` ownership-conflict rejection (single-key and multi-key merge), convenience-method (`cacheStats`/`clearCache`/inflight count) parity across install paths, and the falsy-key-plus-plugin warn-vs-throw behavior. [`tests/src/fetch/executor/per-call-overrides.test.ts`](../../tests/src/fetch/executor/per-call-overrides.test.ts) (4 tests) covers per-call `retry: false` overriding an engine configured with retries, per-call `retry` config overriding an engine configured with `retry: false`, `res.config.retry` reflecting the engine default when no per-call override is given, and per-call `skipCache` bypassing both cache lookup and store.
- [`tests/src/fetch/engine/configuration.test.ts`](../../tests/src/fetch/engine/configuration.test.ts) (13 tests) covers serializer/config edge cases plus runtime `config.set()` reconfigure for each policy: `retry.maxAttempts` taking effect on the next request, rate-limit token buckets rebuilding with a new capacity, cache TTL/rules reconfiguring while existing cached entries survive, the cache-adapter `reconfigureGuard` throwing and leaving the store unchanged (single-key and multi-key merge), the dedupe rule cache rebuilding, and a cookie `exclude` update applying without clearing the jar. [`tests/src/fetch/executor/retry.test.ts`](../../tests/src/fetch/executor/retry.test.ts) (42 tests) covers retry resolution including `attemptTimeout` firing under `retry: false`/`maxAttempts: 0`.
Expand Down
14 changes: 8 additions & 6 deletions docs/wiki/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,25 @@ type: Domain

## Artifacts

- [`skills/logosdx/references/hooks.md`](../../skills/logosdx/references/hooks.md) — skill reference (326 LOC) covering lifecycle hooks, middleware, plugins, priority chains
- [`skills/logosdx/references/hooks.md`](../../skills/logosdx/references/hooks.md) — skill reference (335 LOC) covering lifecycle hooks, middleware, plugins, priority chains; includes an `## addPipe() vs add()` section distinguishing run-shaped `(...args, ctx)` callbacks from onion-style `(next, ...args, ctx)` middleware

## CLI code

- [`packages/hooks/src/index.ts`](../../packages/hooks/src/index.ts) — all hook engine implementation in a single file (1252 LOC, 36k chars); exports `HookError`, `isHookError`, and the `HookEngine` class
- [`packages/hooks/src/index.ts`](../../packages/hooks/src/index.ts) — all hook engine implementation in a single file (1316 LOC, 39k chars); exports `HookError`, `isHookError`, `PipeCallback`, and the `HookEngine` class
- `HookEngine.addPipe<K>(name, callback, options?)` registers onion-style middleware typed against `PipeCallback` (the `(next, ...args, ctx)` shape) without an `as any` cast; it runs the same validation as `add()` (string `name`, function `callback`, `register()` strict-mode via `#assertRegistered`, which names `addPipe` in its thrown error when called from that path) and writes into the same internal registry `add()` uses, sharing `AddOptions` semantics (`priority`, `once`, `times`, `ignoreOnFail`) and the cleanup-function return. The priority-ordered splice-and-cleanup logic formerly inline in `add()` is now the private `#insertEntry(name, callback, options)`, shared by both `add()` and `addPipe()`.
- [`packages/hooks/notes.md`](../../packages/hooks/notes.md) — internal design notes (700 LOC)

## Docs

- [`docs/packages/hooks.md`](../packages/hooks.md) — combined hooks reference (523 LOC)
- [`docs/packages/hooks.md`](../packages/hooks.md) — combined hooks reference (532 LOC)
- [`docs/spec/hooks-add-pipe.md`](../spec/hooks-add-pipe.md) — implementation spec for the `addPipe()` decision (issue #147); sibling-method approach chosen over an `add()` overload or a branded `Pipe<>` lifecycle marker

## Coupling

- Depends on `@logosdx/utils` for `attempt`, `attemptSync`, `assert`, `isFunction`, `isObject`, and `FunctionProps`.
- No dependency on `@logosdx/observer`.
- `@logosdx/fetch` is the largest in-repo consumer: `FetchEngine` composes a `HookEngine<FetchLifecycle>` ([`packages/fetch/src/engine/index.ts`](../../packages/fetch/src/engine/index.ts)) and runs every request through a three-phase pipeline — `hooks.run('beforeRequest')` → `hooks.pipe('execute')` → `hooks.run('afterRequest')` ([`packages/fetch/src/engine/executor.ts`](../../packages/fetch/src/engine/executor.ts)). Every fetch policy plugin (retry, dedupe, cache, rate-limit, cookies) is a hook installation whose `install()` returns the hook cleanup; a `HookScope` carries per-request state between phases (e.g. the cache key set in `beforeRequest` and read in `afterRequest`). A behavior change to `run`/`pipe`/`HookScope` semantics is a behavior change to the entire fetch pipeline.
- Tests in [`tests/src/hooks.ts`](../../tests/src/hooks.ts) (1462 LOC, 43k chars).
- `@logosdx/fetch` is the largest in-repo consumer: `FetchEngine` composes a `HookEngine<FetchLifecycle>` ([`packages/fetch/src/engine/index.ts`](../../packages/fetch/src/engine/index.ts)) and runs every request through a three-phase pipeline — `hooks.run('beforeRequest')` → `hooks.pipe('execute')` → `hooks.run('afterRequest')` ([`packages/fetch/src/engine/executor.ts`](../../packages/fetch/src/engine/executor.ts)). Every fetch policy plugin (retry, dedupe, cache, rate-limit, cookies) is a hook installation whose `install()` returns the hook cleanup; a `HookScope` carries per-request state between phases (e.g. the cache key set in `beforeRequest` and read in `afterRequest`). [`packages/fetch/src/plugins/retry.ts`](../../packages/fetch/src/plugins/retry.ts) and [`packages/fetch/src/plugins/dedupe.ts`](../../packages/fetch/src/plugins/dedupe.ts) register their `execute` middleware via `engine.hooks.addPipe(...)`, replacing the prior `engine.hooks.add((...) as any, ...)` cast pattern. A behavior change to `run`/`pipe`/`HookScope` semantics is a behavior change to the entire fetch pipeline.
- Tests in [`tests/src/hooks.ts`](../../tests/src/hooks.ts) (1923 LOC, 58k chars), including `describe('engine.addPipe()', ...)` (unsubscribe behavior, invalid name/callback rejection, `register()` strict-mode error naming `addPipe`, type-level `PipeCallback` registration with no casts) and `describe('engine.pipe()', ...)` (onion execution order by priority).
- [`tests/src/smoke/hooks.test.ts`](../../tests/src/smoke/hooks.test.ts) runs browser smoke tests.

## Conventions worth knowing
Expand All @@ -35,4 +37,4 @@ type: Domain
- `run` executes a hook chain where a hook may modify args or short-circuit with a result; `pipe` onion-wraps a core function middleware-style (in fetch: retry wraps dedupe wraps the network call).
- `ctx.fail(message)` within a hook handler halts the pipeline and throws `HookError` (or a custom error type if `handleFail` is overridden).
- `isHookError(err)` type guard identifies hook pipeline failures.
- The entire implementation lives in one 1252-line file rather than being split across modules.
- The entire implementation lives in one 1316-line file rather than being split across modules.
2 changes: 1 addition & 1 deletion docs/wiki/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The [`tests/`](../../tests) workspace runs the full validation suite for all pac
- [`tests/src/observable/`](../../tests/src/observable) — unit tests for `@logosdx/observer` (engine, queue, relay)
- [`tests/src/react/`](../../tests/src/react) — unit tests for `@logosdx/react` (10 files)
- [`tests/src/storage/`](../../tests/src/storage) — unit tests for `@logosdx/storage`
- [`tests/src/hooks.ts`](../../tests/src/hooks.ts) — unit tests for `@logosdx/hooks` (1462 LOC)
- [`tests/src/hooks.ts`](../../tests/src/hooks.ts) — unit tests for `@logosdx/hooks` (1923 LOC), including direct coverage for `addPipe()`/`pipe()`/`pipeSync()` (onion middleware, priority ordering, cast-free typed registration)
- [`tests/src/localize.ts`](../../tests/src/localize.ts) — unit tests for `@logosdx/localize` (835 LOC)
- [`tests/src/localize-extractor.ts`](../../tests/src/localize-extractor.ts) — unit tests for the localize type extractor (426 LOC)
- [`tests/src/state-machine.ts`](../../tests/src/state-machine.ts) — unit tests for `@logosdx/state-machine` (1499 LOC)
Expand Down
Loading
Loading