repos / starfx

supercharged async flow control library.
git clone https://github.com/neurosnap/starfx.git

commit
2e93eae
parent
a84cf61
author
Eric Bower
date
2023-11-22 17:09:20 +0000 UTC
refactor(query): middleware naming (#25)

23 files changed,  +708, -473
A log.ts
M mod.ts
M api-type-template.ts
+4, -3
 1@@ -215,7 +215,7 @@ ${method}<P, ApiSuccess, ApiError = unknown>(
 2 * This is an auto-generated file, do not edit directly!
 3 * Run "yarn template" to generate this file.
 4 */
 5-import type { SagaApi } from "./pipe.ts";
 6+import type { SagaApi } from "./thunk.ts";
 7 import type {
 8   ApiCtx,
 9   CreateAction,
10@@ -226,14 +226,15 @@ import type {
11   Supervisor,
12 } from "./types.ts";
13 import type { Payload } from "../types.ts";
14+import type { Operation } from "../deps.ts";
15 
16 export type ApiName = string | string[];
17 
18 export interface SagaQueryApi<Ctx extends ApiCtx = ApiCtx> extends SagaApi<Ctx> {
19   request: (
20     r: Partial<RequestInit>,
21-  ) => (ctx: Ctx, next: Next) => Iterator<unknown>;
22-  cache: () => (ctx: Ctx, next: Next) => Iterator<unknown>;
23+  ) => (ctx: Ctx, next: Next) => Operation<unknown>;
24+  cache: () => (ctx: Ctx, next: Next) => Operation<unknown>;
25 
26   uri: (uri: string) => {
27     ${uriMethods}
M fx/mod.ts
+1, -1
1@@ -2,5 +2,5 @@ export * from "./parallel.ts";
2 export * from "./call.ts";
3 export * from "./race.ts";
4 export * from "./request.ts";
5-export * from "./watch.ts";
6+export * from "./supervisor.ts";
7 export * from "./defer.ts";
A fx/supervisor.test.ts
+72, -0
 1@@ -0,0 +1,72 @@
 2+import { describe, expect, it } from "../test.ts";
 3+import { call, each, Operation, run, spawn } from "../deps.ts";
 4+import { supervise, superviseBackoff } from "./supervisor.ts";
 5+import { LogAction, LogContext } from "../log.ts";
 6+
 7+const test = describe("supervise()");
 8+
 9+describe("superviseBackoff", () => {
10+  it("should increase number exponentially", () => {
11+    const actual: number[] = [];
12+    for (let i = 1; i < 15; i += 1) {
13+      actual.push(superviseBackoff(i));
14+    }
15+    expect(actual).toEqual([
16+      20,
17+      40,
18+      80,
19+      160,
20+      320,
21+      640,
22+      1280,
23+      2560,
24+      5120,
25+      10240,
26+      -1,
27+      -1,
28+      -1,
29+      -1,
30+    ]);
31+  });
32+});
33+
34+it(test, "should recover with backoff pressure", async () => {
35+  const err = console.error;
36+  console.error = () => {};
37+
38+  const actions: LogAction[] = [];
39+  const backoff = (attempt: number) => {
40+    if (attempt === 4) return -1;
41+    return attempt;
42+  };
43+
44+  await run(function* () {
45+    function* op(): Operation<void> {
46+      throw new Error("boom!");
47+    }
48+    yield* spawn(function* () {
49+      const chan = yield* LogContext;
50+      for (const action of yield* each(chan)) {
51+        actions.push(action);
52+        yield* each.next();
53+      }
54+    });
55+    yield* call(supervise(op, backoff));
56+  });
57+
58+  expect(actions.length).toEqual(3);
59+  expect(actions[0].type).toEqual("fx:supervise");
60+  expect(actions[0].payload.message).toEqual(
61+    "Exception caught, waiting 1ms before restarting operation",
62+  );
63+  expect(actions[1].type).toEqual("fx:supervise");
64+  expect(actions[1].payload.message).toEqual(
65+    "Exception caught, waiting 2ms before restarting operation",
66+  );
67+  expect(actions[2].type).toEqual("fx:supervise");
68+  expect(actions[2].payload.message).toEqual(
69+    "Exception caught, waiting 3ms before restarting operation",
70+  );
71+
72+  console.error = err;
73+});
A fx/supervisor.ts
+61, -0
 1@@ -0,0 +1,61 @@
 2+import { Operation, Result, sleep } from "../deps.ts";
 3+import type { Operator } from "../types.ts";
 4+import { safe } from "./call.ts";
 5+import { parallel } from "./parallel.ts";
 6+import { log } from "../log.ts";
 7+
 8+export function superviseBackoff(attempt: number, max = 10): number {
 9+  if (attempt > max) return -1;
10+  // 20ms, 40ms, 80ms, 160ms, 320ms, 640ms, 1280ms, 2560ms, 5120ms, 10240ms
11+  return 2 ** attempt * 10;
12+}
13+
14+/**
15+ * {@link supvervise} will watch whatever {@link Operation} is provided
16+ * and it will automatically try to restart it when it exists.  By
17+ * default it uses a backoff pressure mechanism so if there is an
18+ * error simply calling the {@link Operation} then it will exponentially
19+ * wait longer until attempting to restart and eventually give up.
20+ */
21+export function supervise<T>(
22+  op: Operator<T>,
23+  backoff: (attemp: number) => number = superviseBackoff,
24+) {
25+  return function* () {
26+    let attempt = 1;
27+    let waitFor = backoff(attempt);
28+
29+    while (waitFor >= 0) {
30+      const res = yield* safe(op);
31+
32+      if (res.ok) {
33+        attempt = 0;
34+      } else {
35+        yield* log({
36+          type: "fx:supervise",
37+          payload: {
38+            message:
39+              `Exception caught, waiting ${waitFor}ms before restarting operation`,
40+            op,
41+            error: res.error,
42+          },
43+        });
44+        yield* sleep(waitFor);
45+      }
46+
47+      attempt += 1;
48+      waitFor = backoff(attempt);
49+    }
50+  };
51+}
52+
53+export function* keepAlive(
54+  ops: Operator<unknown>[],
55+  backoff?: (attempt: number) => number,
56+): Operation<Result<void>[]> {
57+  const group = yield* parallel(
58+    ops.map((op) => supervise(op, backoff)),
59+  );
60+  const results = yield* group;
61+  return results;
62+}
D fx/watch.ts
+0, -16
 1@@ -1,16 +0,0 @@
 2-import type { Operator } from "../types.ts";
 3-import { safe } from "./call.ts";
 4-import { parallel } from "./parallel.ts";
 5-
 6-export function supervise<T>(op: Operator<T>) {
 7-  return function* () {
 8-    while (true) {
 9-      yield* safe(op);
10-    }
11-  };
12-}
13-
14-export function* keepAlive(ops: Operator<unknown>[]) {
15-  const results = yield* parallel(ops.map(supervise));
16-  return yield* results;
17-}
A log.ts
+26, -0
 1@@ -0,0 +1,26 @@
 2+import { createChannel, createContext } from "./deps.ts";
 3+import type { ActionWPayload } from "./types.ts";
 4+
 5+export interface LogMessage {
 6+  message: string;
 7+  [key: string]: any;
 8+}
 9+export type LogAction = ActionWPayload<LogMessage>;
10+
11+export function createLogger(type: string) {
12+  return function (payload: LogMessage) {
13+    return log({ type, payload });
14+  };
15+}
16+
17+export function* log(action: LogAction) {
18+  const chan = yield* LogContext;
19+  yield* chan.send(action);
20+  // TODO: only for dev mode?
21+  console.error(action);
22+}
23+
24+export const LogContext = createContext(
25+  "starfx:logger",
26+  createChannel<LogAction>(),
27+);
M mod.ts
+1, -0
1@@ -3,6 +3,7 @@ export * from "./query/mod.ts";
2 export * from "./types.ts";
3 export * from "./compose.ts";
4 export * from "./action.ts";
5+export * from "./log.ts";
6 export {
7   action,
8   createChannel,
M query/api-types.ts
+1, -1
1@@ -2,7 +2,7 @@
2  * This is an auto-generated file, do not edit directly!
3  * Run "yarn template" to generate this file.
4  */
5-import type { SagaApi } from "./pipe.ts";
6+import type { SagaApi } from "./thunk.ts";
7 import type {
8   ApiCtx,
9   CreateAction,
M query/api.test.ts
+32, -25
  1@@ -1,5 +1,4 @@
  2 import { describe, expect, it } from "../test.ts";
  3-
  4 import { call, keepAlive } from "../fx/mod.ts";
  5 import {
  6   configureStore,
  7@@ -11,8 +10,7 @@ import {
  8 } from "../store/mod.ts";
  9 import { sleep } from "../test.ts";
 10 import { safe } from "../mod.ts";
 11-
 12-import { queryCtx, requestMonitor, urlParser } from "./middleware.ts";
 13+import * as mdw from "./mdw.ts";
 14 import { createApi } from "./api.ts";
 15 import { createKey } from "./create-key.ts";
 16 import type { ApiCtx } from "./types.ts";
 17@@ -45,8 +43,8 @@ const tests = describe("createApi()");
 18 it(tests, "createApi - POST", async () => {
 19   const query = createApi();
 20 
 21-  query.use(queryCtx);
 22-  query.use(urlParser);
 23+  query.use(mdw.queryCtx);
 24+  query.use(mdw.nameParser);
 25   query.use(query.routes());
 26   query.use(function* fetchApi(ctx, next) {
 27     expect(ctx.req()).toEqual({
 28@@ -108,8 +106,8 @@ it(tests, "createApi - POST", async () => {
 29 
 30 it(tests, "POST with uri", () => {
 31   const query = createApi();
 32-  query.use(queryCtx);
 33-  query.use(urlParser);
 34+  query.use(mdw.queryCtx);
 35+  query.use(mdw.nameParser);
 36   query.use(query.routes());
 37   query.use(function* fetchApi(ctx, next) {
 38     expect(ctx.req()).toEqual({
 39@@ -139,7 +137,7 @@ it(tests, "POST with uri", () => {
 40 
 41       yield* next();
 42       if (!ctx.json.ok) return;
 43-      const { users } = ctx.json.data;
 44+      const { users } = ctx.json.value;
 45       yield* updateStore<{ users: { [key: string]: User } }>((state) => {
 46         users.forEach((u) => {
 47           state.users[u.id] = u;
 48@@ -155,8 +153,8 @@ it(tests, "POST with uri", () => {
 49 
 50 it(tests, "middleware - with request fn", () => {
 51   const query = createApi();
 52-  query.use(queryCtx);
 53-  query.use(urlParser);
 54+  query.use(mdw.queryCtx);
 55+  query.use(mdw.nameParser);
 56   query.use(query.routes());
 57   query.use(function* (ctx, next) {
 58     expect(ctx.req().method).toEqual("POST");
 59@@ -185,12 +183,16 @@ it(tests, "run() on endpoint action - should run the effect", () => {
 60       acc += "a";
 61     },
 62   );
 63-  const action2 = api.get("/users2", function* (_, next) {
 64-    yield* next();
 65-    yield* call(() => action1.run(action1({ id: "1" })));
 66-    acc += "b";
 67-    expect(acc).toEqual("ab");
 68-  });
 69+  const action2 = api.get(
 70+    "/users2",
 71+    { supervisor: takeEvery },
 72+    function* (_, next) {
 73+      yield* next();
 74+      yield* call(() => action1.run(action1({ id: "1" })));
 75+      acc += "b";
 76+      expect(acc).toEqual("ab");
 77+    },
 78+  );
 79 
 80   const store = configureStore({ initialState: { users: {} } });
 81   store.run(api.bootup);
 82@@ -235,7 +237,7 @@ it(tests, "run() from a normal saga", () => {
 83 it(tests, "createApi with hash key on a large post", async () => {
 84   const { store, schema } = testStore();
 85   const query = createApi();
 86-  query.use(requestMonitor());
 87+  query.use(mdw.api());
 88   query.use(storeMdw(schema.db));
 89   query.use(query.routes());
 90   query.use(function* fetchApi(ctx, next) {
 91@@ -275,6 +277,7 @@ it(tests, "createApi with hash key on a large post", async () => {
 92       ctx.json = {
 93         ok: true,
 94         data: curUsers,
 95+        value: curUsers,
 96       };
 97     },
 98   );
 99@@ -303,8 +306,9 @@ it(tests, "createApi - two identical endpoints", async () => {
100   const actual: string[] = [];
101   const { store, schema } = testStore();
102   const api = createApi();
103-  api.use(requestMonitor());
104+  api.use(mdw.api());
105   api.use(storeMdw(schema.db));
106+  api.use(mdw.nameParser);
107   api.use(api.routes());
108 
109   const first = api.get(
110@@ -344,7 +348,8 @@ it(tests, "ensure types for get() endpoint", () => {
111   api.use(api.routes());
112   api.use(function* (ctx, next) {
113     yield* next();
114-    ctx.json = { ok: true, data: { result: "wow" } };
115+    const data = { result: "wow" };
116+    ctx.json = { ok: true, data, value: data };
117   });
118 
119   const acc: string[] = [];
120@@ -358,7 +363,7 @@ it(tests, "ensure types for get() endpoint", () => {
121       yield* next();
122 
123       if (ctx.json.ok) {
124-        acc.push(ctx.json.data.result);
125+        acc.push(ctx.json.value.result);
126       }
127     },
128   );
129@@ -381,7 +386,8 @@ it(tests, "ensure ability to cast `ctx` in function definition", () => {
130   api.use(api.routes());
131   api.use(function* (ctx, next) {
132     yield* next();
133-    ctx.json = { ok: true, data: { result: "wow" } };
134+    const data = { result: "wow" };
135+    ctx.json = { ok: true, data, value: data };
136   });
137 
138   const acc: string[] = [];
139@@ -395,7 +401,7 @@ it(tests, "ensure ability to cast `ctx` in function definition", () => {
140       yield* next();
141 
142       if (ctx.json.ok) {
143-        acc.push(ctx.json.data.result);
144+        acc.push(ctx.json.value.result);
145       }
146     },
147   );
148@@ -417,7 +423,8 @@ it(
149     api.use(api.routes());
150     api.use(function* (ctx, next) {
151       yield* next();
152-      ctx.json = { ok: true, data: { result: "wow" } };
153+      const data = { result: "wow" };
154+      ctx.json = { ok: true, data, value: data };
155     });
156 
157     const acc: string[] = [];
158@@ -430,7 +437,7 @@ it(
159         yield* next();
160 
161         if (ctx.json.ok) {
162-          acc.push(ctx.json.data.result);
163+          acc.push(ctx.json.value.result);
164         }
165       },
166     );
167@@ -453,7 +460,7 @@ it(tests, "should bubble up error", () => {
168       error = err;
169     }
170   });
171-  api.use(queryCtx);
172+  api.use(mdw.queryCtx);
173   api.use(storeMdw(schema.db));
174   api.use(api.routes());
175 
M query/api.ts
+8, -8
 1@@ -1,23 +1,23 @@
 2 // deno-lint-ignore-file no-explicit-any
 3 import type { ApiCtx, ApiRequest, Next } from "./types.ts";
 4-import { createPipe } from "./pipe.ts";
 5-import type { SagaApi } from "./pipe.ts";
 6+import { createThunks } from "./thunk.ts";
 7+import type { SagaApi } from "./thunk.ts";
 8 import type { ApiName, SagaQueryApi } from "./api-types.ts";
 9 
10 /**
11  * Creates a middleware pipeline for HTTP requests.
12  *
13  * @remarks
14- * It uses {@link createPipe} under the hood.
15+ * It uses {@link createThunks} under the hood.
16  *
17  * @example
18  * ```ts
19- * import { createApi, requestMonitor, fetcher } from 'starfx';
20+ * import { createApi, mdw } from 'starfx';
21  *
22  * const api = createApi();
23- * api.use(requestMonitor());
24+ * api.use(mdw.api());
25  * api.use(api.routes());
26- * api.use(fetcher({ baseUrl: 'https://api.com' }));
27+ * api.use(mdw.fetch({ baseUrl: 'https://api.com' }));
28  *
29  * const fetchUsers = api.get('/users', function*(ctx, next) {
30  *   yield next();
31@@ -27,9 +27,9 @@ import type { ApiName, SagaQueryApi } from "./api-types.ts";
32  * ```
33  */
34 export function createApi<Ctx extends ApiCtx = ApiCtx>(
35-  basePipe?: SagaApi<Ctx>,
36+  baseThunk?: SagaApi<Ctx>,
37 ): SagaQueryApi<Ctx> {
38-  const pipe = basePipe || createPipe<Ctx>();
39+  const pipe = baseThunk || createThunks<Ctx>();
40   const uri = (prename: ApiName) => {
41     const create = pipe.create as any;
42 
M query/fetch.test.ts
+52, -47
  1@@ -6,10 +6,9 @@ import {
  2   storeMdw,
  3   takeEvery,
  4 } from "../store/mod.ts";
  5-
  6-import { fetcher, fetchRetry, headersMdw } from "./fetch.ts";
  7+import * as fetchMdw from "./fetch.ts";
  8 import { createApi } from "./api.ts";
  9-import { requestMonitor } from "./middleware.ts";
 10+import * as mdw from "./mdw.ts";
 11 
 12 install();
 13 
 14@@ -30,7 +29,7 @@ const testStore = () => {
 15   return { schema, store };
 16 };
 17 
 18-const tests = describe("fetcher()");
 19+const tests = describe("mdw.fetch()");
 20 
 21 it(
 22   tests,
 23@@ -42,11 +41,11 @@ it(
 24 
 25     const { store, schema } = testStore();
 26     const api = createApi();
 27-    api.use(requestMonitor());
 28+    api.use(mdw.api());
 29     api.use(storeMdw(schema.db));
 30     api.use(api.routes());
 31-    api.use(headersMdw);
 32-    api.use(fetcher({ baseUrl }));
 33+    api.use(fetchMdw.headers);
 34+    api.use(mdw.fetch({ baseUrl }));
 35 
 36     const actual: any[] = [];
 37     const fetchUsers = api.get(
 38@@ -77,7 +76,7 @@ it(
 39       headers: {
 40         "Content-Type": "application/json",
 41       },
 42-    }, { ok: true, data: mockUser }]);
 43+    }, { ok: true, data: mockUser, value: mockUser }]);
 44   },
 45 );
 46 
 47@@ -91,10 +90,10 @@ it(
 48 
 49     const { store, schema } = testStore();
 50     const api = createApi();
 51-    api.use(requestMonitor());
 52+    api.use(mdw.api());
 53     api.use(storeMdw(schema.db));
 54     api.use(api.routes());
 55-    api.use(fetcher({ baseUrl }));
 56+    api.use(mdw.fetch({ baseUrl }));
 57 
 58     let actual = null;
 59     const fetchUsers = api.get(
 60@@ -114,7 +113,8 @@ it(
 61     store.dispatch(action);
 62 
 63     await delay();
 64-    expect(actual).toEqual({ ok: true, data: "this is some text" });
 65+    const data = "this is some text";
 66+    expect(actual).toEqual({ ok: true, data, value: data });
 67   },
 68 );
 69 
 70@@ -126,15 +126,10 @@ it(tests, "fetch - error handling", async () => {
 71 
 72   const { schema, store } = testStore();
 73   const api = createApi();
 74-  api.use(requestMonitor());
 75+  api.use(mdw.api());
 76   api.use(storeMdw(schema.db));
 77   api.use(api.routes());
 78-  api.use(function* (ctx, next) {
 79-    const url = ctx.req().url;
 80-    ctx.request = ctx.req({ url: `${baseUrl}${url}` });
 81-    yield* next();
 82-  });
 83-  api.use(fetcher());
 84+  api.use(mdw.fetch({ baseUrl }));
 85 
 86   let actual = null;
 87   const fetchUsers = api.get(
 88@@ -157,7 +152,7 @@ it(tests, "fetch - error handling", async () => {
 89 
 90   const state = store.getState();
 91   expect(state.data[action.payload.key]).toEqual(errMsg);
 92-  expect(actual).toEqual({ ok: false, data: errMsg });
 93+  expect(actual).toEqual({ ok: false, data: errMsg, error: errMsg });
 94 });
 95 
 96 it(tests, "fetch - status 204", async () => {
 97@@ -167,7 +162,7 @@ it(tests, "fetch - status 204", async () => {
 98 
 99   const { schema, store } = testStore();
100   const api = createApi();
101-  api.use(requestMonitor());
102+  api.use(mdw.api());
103   api.use(storeMdw(schema.db));
104   api.use(api.routes());
105   api.use(function* (ctx, next) {
106@@ -175,7 +170,7 @@ it(tests, "fetch - status 204", async () => {
107     ctx.request = ctx.req({ url: `${baseUrl}${url}` });
108     yield* next();
109   });
110-  api.use(fetcher());
111+  api.use(mdw.fetch());
112 
113   let actual = null;
114   const fetchUsers = api.get(
115@@ -197,7 +192,7 @@ it(tests, "fetch - status 204", async () => {
116 
117   const state = store.getState();
118   expect(state.data[action.payload.key]).toEqual({});
119-  expect(actual).toEqual({ ok: true, data: {} });
120+  expect(actual).toEqual({ ok: true, data: {}, value: {} });
121 });
122 
123 it(tests, "fetch - malformed json", async () => {
124@@ -207,7 +202,7 @@ it(tests, "fetch - malformed json", async () => {
125 
126   const { schema, store } = testStore();
127   const api = createApi();
128-  api.use(requestMonitor());
129+  api.use(mdw.api());
130   api.use(storeMdw(schema.db));
131   api.use(api.routes());
132   api.use(function* (ctx, next) {
133@@ -215,7 +210,7 @@ it(tests, "fetch - malformed json", async () => {
134     ctx.request = ctx.req({ url: `${baseUrl}${url}` });
135     yield* next();
136   });
137-  api.use(fetcher());
138+  api.use(mdw.fetch());
139 
140   let actual = null;
141   const fetchUsers = api.get(
142@@ -235,11 +230,13 @@ it(tests, "fetch - malformed json", async () => {
143 
144   await delay();
145 
146+  const data = {
147+    message: "Unexpected token 'o', \"not json\" is not valid JSON",
148+  };
149   expect(actual).toEqual({
150     ok: false,
151-    data: {
152-      message: "Unexpected token 'o', \"not json\" is not valid JSON",
153-    },
154+    data,
155+    error: data,
156   });
157 });
158 
159@@ -250,17 +247,20 @@ it(tests, "fetch - POST", async () => {
160 
161   const { schema, store } = testStore();
162   const api = createApi();
163-  api.use(requestMonitor());
164+  api.use(mdw.api());
165   api.use(storeMdw(schema.db));
166   api.use(api.routes());
167-  api.use(fetcher({ baseUrl }));
168+  api.use(fetchMdw.headers);
169+  api.use(mdw.fetch({ baseUrl }));
170 
171   const fetchUsers = api.post(
172     "/users",
173     { supervisor: takeEvery },
174     function* (ctx, next) {
175       ctx.cache = true;
176-      ctx.request = ctx.req({ body: JSON.stringify(mockUser) });
177+      ctx.request = ctx.req({
178+        body: JSON.stringify(mockUser),
179+      });
180       yield* next();
181 
182       expect(ctx.req()).toEqual({
183@@ -272,7 +272,7 @@ it(tests, "fetch - POST", async () => {
184         body: JSON.stringify(mockUser),
185       });
186 
187-      expect(ctx.json).toEqual({ ok: true, data: mockUser });
188+      expect(ctx.json).toEqual({ ok: true, data: mockUser, value: mockUser });
189     },
190   );
191 
192@@ -290,10 +290,11 @@ it(tests, "fetch - POST multiple endpoints with same uri", async () => {
193 
194   const { store, schema } = testStore();
195   const api = createApi();
196-  api.use(requestMonitor());
197+  api.use(mdw.api());
198   api.use(storeMdw(schema.db));
199   api.use(api.routes());
200-  api.use(fetcher({ baseUrl }));
201+  api.use(fetchMdw.headers);
202+  api.use(mdw.fetch({ baseUrl }));
203 
204   const fetchUsers = api.post<{ id: string }>(
205     "/users/:id/something",
206@@ -312,12 +313,13 @@ it(tests, "fetch - POST multiple endpoints with same uri", async () => {
207         body: JSON.stringify(mockUser),
208       });
209 
210-      expect(ctx.json).toEqual({ ok: true, data: mockUser });
211+      expect(ctx.json).toEqual({ ok: true, data: mockUser, value: mockUser });
212     },
213   );
214 
215   const fetchUsersSecond = api.post<{ id: string }>(
216     ["/users/:id/something", "next"],
217+    { supervisor: takeEvery },
218     function* (ctx, next) {
219       ctx.cache = true;
220       ctx.request = ctx.req({ body: JSON.stringify(mockUser) });
221@@ -332,7 +334,7 @@ it(tests, "fetch - POST multiple endpoints with same uri", async () => {
222         body: JSON.stringify(mockUser),
223       });
224 
225-      expect(ctx.json).toEqual({ ok: true, data: mockUser });
226+      expect(ctx.json).toEqual({ ok: true, data: mockUser, value: mockUser });
227     },
228   );
229 
230@@ -350,10 +352,10 @@ it(
231   async () => {
232     const { store, schema } = testStore();
233     const api = createApi();
234-    api.use(requestMonitor());
235+    api.use(mdw.api());
236     api.use(storeMdw(schema.db));
237     api.use(api.routes());
238-    api.use(fetcher({ baseUrl }));
239+    api.use(mdw.fetch({ baseUrl }));
240 
241     const fetchUsers = api.post<{ id: string }>(
242       "/users/:id",
243@@ -364,10 +366,12 @@ it(
244 
245         yield* next();
246 
247+        const data =
248+          "found :id in endpoint name (/users/:id [POST]) but payload has falsy value ()";
249         expect(ctx.json).toEqual({
250           ok: false,
251-          data:
252-            "found :id in endpoint name (/users/:id [POST]) but payload has falsy value ()",
253+          data,
254+          error: data,
255         });
256       },
257     );
258@@ -397,10 +401,10 @@ it(
259 
260     const { schema, store } = testStore();
261     const api = createApi();
262-    api.use(requestMonitor());
263+    api.use(mdw.api());
264     api.use(storeMdw(schema.db));
265     api.use(api.routes());
266-    api.use(fetcher({ baseUrl }));
267+    api.use(mdw.fetch({ baseUrl }));
268 
269     let actual = null;
270     const fetchUsers = api.get("/users", { supervisor: takeEvery }, [
271@@ -414,7 +418,7 @@ it(
272 
273         actual = ctx.json;
274       },
275-      fetchRetry((n) => (n > 4 ? -1 : 10)),
276+      mdw.fetchRetry((n) => (n > 4 ? -1 : 10)),
277     ]);
278 
279     store.run(api.bootup);
280@@ -426,7 +430,7 @@ it(
281 
282     const state = store.getState();
283     expect(state.data[action.payload.key]).toEqual(mockUser);
284-    expect(actual).toEqual({ ok: true, data: mockUser });
285+    expect(actual).toEqual({ ok: true, data: mockUser, value: mockUser });
286   },
287 );
288 
289@@ -443,10 +447,10 @@ it(
290     const { schema, store } = testStore();
291     let actual = null;
292     const api = createApi();
293-    api.use(requestMonitor());
294+    api.use(mdw.api());
295     api.use(storeMdw(schema.db));
296     api.use(api.routes());
297-    api.use(fetcher({ baseUrl }));
298+    api.use(mdw.fetch({ baseUrl }));
299 
300     const fetchUsers = api.get("/users", { supervisor: takeEvery }, [
301       function* (ctx, next) {
302@@ -454,7 +458,7 @@ it(
303         yield* next();
304         actual = ctx.json;
305       },
306-      fetchRetry((n) => (n > 2 ? -1 : 10)),
307+      mdw.fetchRetry((n) => (n > 2 ? -1 : 10)),
308     ]);
309 
310     store.run(api.bootup);
311@@ -462,6 +466,7 @@ it(
312     store.dispatch(action);
313 
314     await delay();
315-    expect(actual).toEqual({ ok: false, data: { message: "error" } });
316+    const data = { message: "error" };
317+    expect(actual).toEqual({ ok: false, data, error: data });
318   },
319 );
M query/fetch.ts
+82, -109
  1@@ -1,15 +1,63 @@
  2-import { sleep } from "../deps.ts";
  3-import { compose } from "../compose.ts";
  4-import { safe } from "../mod.ts";
  5-
  6-import { noop } from "./util.ts";
  7+import { safe } from "../fx/mod.ts";
  8 import type { FetchCtx, FetchJsonCtx, Next } from "./types.ts";
  9+import { isObject } from "./util.ts";
 10+
 11+/**
 12+ * This middleware converts the name provided to {@link createApi}
 13+ * into `url` and `method` for the fetch request.
 14+ */
 15+export function* nameParser<Ctx extends FetchJsonCtx = FetchJsonCtx>(
 16+  ctx: Ctx,
 17+  next: Next,
 18+) {
 19+  const httpMethods = [
 20+    "get",
 21+    "head",
 22+    "post",
 23+    "put",
 24+    "delete",
 25+    "connect",
 26+    "options",
 27+    "trace",
 28+    "patch",
 29+  ];
 30+
 31+  const options = ctx.payload || {};
 32+  if (!isObject(options)) {
 33+    yield* next();
 34+    return;
 35+  }
 36+
 37+  let url = Object.keys(options).reduce((acc, key) => {
 38+    return acc.replace(`:${key}`, options[key]);
 39+  }, ctx.name);
 40+
 41+  let method = "";
 42+  httpMethods.forEach((curMethod) => {
 43+    const pattern = new RegExp(`\\s*\\[` + curMethod + `\\]\\s*\\w*`, "i");
 44+    const tmpUrl = url.replace(pattern, "");
 45+    if (tmpUrl.length !== url.length) {
 46+      method = curMethod.toLocaleUpperCase();
 47+    }
 48+    url = tmpUrl;
 49+  }, url);
 50+
 51+  if (ctx.req().url === "") {
 52+    ctx.request = ctx.req({ url });
 53+  }
 54+
 55+  if (method) {
 56+    ctx.request = ctx.req({ method });
 57+  }
 58+
 59+  yield* next();
 60+}
 61 
 62 /**
 63  * Automatically sets `content-type` to `application/json` when
 64  * that header is not already present.
 65  */
 66-export function* headersMdw<CurCtx extends FetchCtx = FetchCtx>(
 67+export function* headers<CurCtx extends FetchCtx = FetchCtx>(
 68   ctx: CurCtx,
 69   next: Next,
 70 ) {
 71@@ -41,7 +89,7 @@ export function* headersMdw<CurCtx extends FetchCtx = FetchCtx>(
 72  * })
 73  * ```
 74  */
 75-export function* jsonMdw<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
 76+export function* json<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
 77   ctx: CurCtx,
 78   next: Next,
 79 ) {
 80@@ -52,8 +100,9 @@ export function* jsonMdw<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
 81 
 82   if (ctx.response.status === 204) {
 83     ctx.json = {
 84-      ok: ctx.response.ok,
 85+      ok: true,
 86       data: {},
 87+      value: {},
 88     };
 89     yield* next();
 90     return;
 91@@ -66,14 +115,25 @@ export function* jsonMdw<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
 92   });
 93 
 94   if (data.ok) {
 95-    ctx.json = {
 96-      ok: ctx.response.ok,
 97-      data: data.value,
 98-    };
 99+    if (ctx.response.ok) {
100+      ctx.json = {
101+        ok: true,
102+        data: data.value,
103+        value: data.value,
104+      };
105+    } else {
106+      ctx.json = {
107+        ok: false,
108+        data: data.value,
109+        error: data.value,
110+      };
111+    }
112   } else {
113+    const dta = { message: data.error.message };
114     ctx.json = {
115       ok: false,
116-      data: { message: data.error.message },
117+      data: dta,
118+      error: dta,
119     };
120   }
121 
122@@ -81,10 +141,10 @@ export function* jsonMdw<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
123 }
124 
125 /*
126- * This middleware takes the `baseUrl` provided to `fetcher()` and combines it
127+ * This middleware takes the `baseUrl` provided to {@link mdw.fetch} and combines it
128  * with the url from `ctx.request.url`.
129  */
130-export function apiUrlMdw<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
131+export function composeUrl<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
132   baseUrl = "",
133 ) {
134   return function* (ctx: CurCtx, next: Next) {
135@@ -105,7 +165,7 @@ export function apiUrlMdw<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
136  * Ideally the action wouldn't have been dispatched at all but that is *not* a
137  * gaurantee we can make here.
138  */
139-export function* payloadMdw<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
140+export function* payload<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
141   ctx: CurCtx,
142   next: Next,
143 ) {
144@@ -124,10 +184,12 @@ export function* payloadMdw<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
145 
146     const val = payload[key];
147     if (!val) {
148+      const data =
149+        `found :${key} in endpoint name (${ctx.name}) but payload has falsy value (${val})`;
150       ctx.json = {
151         ok: false,
152-        data:
153-          `found :${key} in endpoint name (${ctx.name}) but payload has falsy value (${val})`,
154+        data,
155+        error: data,
156       };
157       return;
158     }
159@@ -140,13 +202,13 @@ export function* payloadMdw<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
160  * This middleware makes the `fetch` http request using `ctx.request` and
161  * assigns the response to `ctx.response`.
162  */
163-export function* fetchMdw<CurCtx extends FetchCtx = FetchCtx>(
164+export function* request<CurCtx extends FetchCtx = FetchCtx>(
165   ctx: CurCtx,
166   next: Next,
167 ) {
168   const { url, ...req } = ctx.req();
169   const request = new Request(url, req);
170-  const result = yield* safe(() => fetch(request));
171+  const result = yield* safe(fetch(request));
172   if (result.ok) {
173     ctx.response = result.value;
174   } else {
175@@ -154,92 +216,3 @@ export function* fetchMdw<CurCtx extends FetchCtx = FetchCtx>(
176   }
177   yield* next();
178 }
179-
180-function backoffExp(attempt: number): number {
181-  if (attempt > 5) return -1;
182-  // 1s, 1s, 1s, 2s, 4s
183-  return Math.max(2 ** attempt * 125, 1000);
184-}
185-
186-/**
187- * This middleware will retry failed `Fetch` request if `response.ok` is `false`.
188- * It accepts a backoff function to determine how long to continue retrying.
189- * The default is an exponential backoff {@link backoffExp} where the minimum is
190- * 1sec between attempts and it'll reach 4s between attempts at the end with a
191- * max of 5 attempts.
192- *
193- * An example backoff:
194- * @example
195- * ```ts
196- *  // Any value less than 0 will stop the retry middleware.
197- *  // Each attempt will wait 1s
198- *  const backoff = (attempt: number) => {
199- *    if (attempt > 5) return -1;
200- *    return 1000;
201- *  }
202- *
203- * const api = createApi();
204- * api.use(requestMonitor());
205- * api.use(api.routes());
206- * api.use(fetcher());
207- *
208- * const fetchUsers = api.get('/users', [
209- *  function*(ctx, next) {
210- *    // ...
211- *    yield next();
212- *  },
213- *  // fetchRetry should be after your endpoint function because
214- *  // the retry middleware will update `ctx.json` before it reaches your middleware
215- *  fetchRetry(backoff),
216- * ])
217- * ```
218- */
219-export function fetchRetry<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
220-  backoff: (attempt: number) => number = backoffExp,
221-) {
222-  return function* (ctx: CurCtx, next: Next) {
223-    yield* next();
224-
225-    if (!ctx.response) {
226-      return;
227-    }
228-
229-    if (ctx.response.ok) {
230-      return;
231-    }
232-
233-    let attempt = 1;
234-    let waitFor = backoff(attempt);
235-    while (waitFor >= 1) {
236-      yield* sleep(waitFor);
237-      yield* safe(() => fetchMdw(ctx, noop));
238-      yield* safe(() => jsonMdw(ctx, noop));
239-
240-      if (ctx.response.ok) {
241-        return;
242-      }
243-
244-      attempt += 1;
245-      waitFor = backoff(attempt);
246-    }
247-  };
248-}
249-
250-/**
251- * This middleware is a composition of other middleware required to use `window.fetch`
252- * {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API} with {@link createApi}
253- */
254-export function fetcher<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
255-  {
256-    baseUrl = "",
257-  }: {
258-    baseUrl?: string;
259-  } = { baseUrl: "" },
260-) {
261-  return compose<CurCtx>([
262-    apiUrlMdw(baseUrl),
263-    payloadMdw,
264-    fetchMdw,
265-    jsonMdw,
266-  ]);
267-}
R query/middleware.test.ts => query/mdw.test.ts
+43, -47
  1@@ -1,12 +1,5 @@
  2 import { assertLike, asserts, describe, expect, it } from "../test.ts";
  3-import {
  4-  createApi,
  5-  createKey,
  6-  customKey,
  7-  queryCtx,
  8-  requestMonitor,
  9-  urlParser,
 10-} from "../query/mod.ts";
 11+import { createApi, createKey, mdw } from "../query/mod.ts";
 12 import type { ApiCtx, Next, PipeCtx } from "../query/mod.ts";
 13 import { createQueryState } from "../action.ts";
 14 import { sleep } from "../test.ts";
 15@@ -52,19 +45,19 @@ const tests = describe("middleware");
 16 it(tests, "basic", () => {
 17   const { store } = testStore();
 18   const query = createApi<ApiCtx>();
 19-  query.use(queryCtx);
 20-  query.use(urlParser);
 21+  query.use(mdw.queryCtx);
 22+  query.use(mdw.api());
 23   query.use(query.routes());
 24   query.use(function* fetchApi(ctx, next) {
 25     if (`${ctx.req().url}`.startsWith("/users/")) {
 26-      ctx.json = { ok: true, data: mockUser2 };
 27+      ctx.json = { ok: true, data: mockUser2, value: mockUser2 };
 28       yield* next();
 29       return;
 30     }
 31     const data = {
 32       users: [mockUser],
 33     };
 34-    ctx.json = { ok: true, data };
 35+    ctx.json = { ok: true, data, value: data };
 36     yield* next();
 37   });
 38 
 39@@ -74,7 +67,7 @@ it(tests, "basic", () => {
 40     function* processUsers(ctx: ApiCtx<unknown, { users: User[] }>, next) {
 41       yield* next();
 42       if (!ctx.json.ok) return;
 43-      const { users } = ctx.json.data;
 44+      const { users } = ctx.json.value;
 45 
 46       yield* updateStore((state) => {
 47         users.forEach((u) => {
 48@@ -93,7 +86,7 @@ it(tests, "basic", () => {
 49       ctx.request = ctx.req({ method: "POST" });
 50       yield* next();
 51       if (!ctx.json.ok) return;
 52-      const curUser = ctx.json.data;
 53+      const curUser = ctx.json.value;
 54       yield* updateStore((state) => {
 55         state.users[curUser.id] = curUser;
 56       });
 57@@ -116,12 +109,13 @@ it(tests, "basic", () => {
 58 it(tests, "with loader", () => {
 59   const { schema, store } = testStore();
 60   const api = createApi<ApiCtx>();
 61-  api.use(requestMonitor());
 62+  api.use(mdw.api());
 63   api.use(storeMdw(schema.db));
 64   api.use(api.routes());
 65   api.use(function* fetchApi(ctx, next) {
 66     ctx.response = new Response(jsonBlob(mockUser), { status: 200 });
 67-    ctx.json = { ok: true, data: { users: [mockUser] } };
 68+    const data = { users: [mockUser] };
 69+    ctx.json = { ok: true, data, value: data };
 70     yield* next();
 71   });
 72 
 73@@ -132,10 +126,10 @@ it(tests, "with loader", () => {
 74       yield* next();
 75       if (!ctx.json.ok) return;
 76 
 77-      const { data } = ctx.json;
 78+      const { value } = ctx.json;
 79 
 80       yield* updateStore((state) => {
 81-        data.users.forEach((u) => {
 82+        value.users.forEach((u) => {
 83           state.users[u.id] = u;
 84         });
 85       });
 86@@ -158,12 +152,13 @@ it(tests, "with loader", () => {
 87 it(tests, "with item loader", () => {
 88   const { store, schema } = testStore();
 89   const api = createApi<ApiCtx>();
 90-  api.use(requestMonitor());
 91+  api.use(mdw.api());
 92   api.use(storeMdw(schema.db));
 93   api.use(api.routes());
 94   api.use(function* fetchApi(ctx, next) {
 95     ctx.response = new Response(jsonBlob(mockUser), { status: 200 });
 96-    ctx.json = { ok: true, data: { users: [mockUser] } };
 97+    const data = { users: [mockUser] };
 98+    ctx.json = { ok: true, data, value: data };
 99     yield* next();
100   });
101 
102@@ -174,9 +169,9 @@ it(tests, "with item loader", () => {
103       yield* next();
104       if (!ctx.json.ok) return;
105 
106-      const { data } = ctx.json;
107+      const { value } = ctx.json;
108       yield* updateStore((state) => {
109-        data.users.forEach((u) => {
110+        value.users.forEach((u) => {
111           state.users[u.id] = u;
112         });
113       });
114@@ -202,8 +197,8 @@ it(tests, "with item loader", () => {
115 
116 it(tests, "with POST", () => {
117   const query = createApi();
118-  query.use(queryCtx);
119-  query.use(urlParser);
120+  query.use(mdw.queryCtx);
121+  query.use(mdw.api());
122   query.use(query.routes());
123   query.use(function* fetchApi(ctx, next) {
124     const request = ctx.req();
125@@ -237,7 +232,7 @@ it(tests, "with POST", () => {
126 
127       if (!ctx.json.ok) return;
128 
129-      const { users } = ctx.json.data;
130+      const { users } = ctx.json.value;
131       yield* updateStore((state) => {
132         users.forEach((u) => {
133           state.users[u.id] = u;
134@@ -254,13 +249,13 @@ it(tests, "with POST", () => {
135 it(tests, "simpleCache", () => {
136   const { store, schema } = testStore();
137   const api = createApi<ApiCtx>();
138-  api.use(requestMonitor());
139+  api.use(mdw.api());
140   api.use(storeMdw(schema.db));
141   api.use(api.routes());
142   api.use(function* fetchApi(ctx, next) {
143     const data = { users: [mockUser] };
144     ctx.response = new Response(jsonBlob(data));
145-    ctx.json = { ok: true, data };
146+    ctx.json = { ok: true, data, value: data };
147     yield* next();
148   });
149 
150@@ -284,13 +279,13 @@ it(tests, "simpleCache", () => {
151 it(tests, "overriding default loader behavior", () => {
152   const { store, schema } = testStore();
153   const api = createApi<ApiCtx>();
154-  api.use(requestMonitor());
155+  api.use(mdw.api());
156   api.use(storeMdw(schema.db));
157   api.use(api.routes());
158   api.use(function* fetchApi(ctx, next) {
159     const data = { users: [mockUser] };
160     ctx.response = new Response(jsonBlob(data));
161-    ctx.json = { ok: true, data };
162+    ctx.json = { ok: true, data, value: data };
163     yield* next();
164   });
165 
166@@ -303,10 +298,10 @@ it(tests, "overriding default loader behavior", () => {
167       if (!ctx.json.ok) {
168         return;
169       }
170-      const { data } = ctx.json;
171+      const { value } = ctx.json;
172       ctx.loader = { message: "yes", meta: { wow: true } };
173       yield* updateStore((state) => {
174-        data.users.forEach((u) => {
175+        value.users.forEach((u) => {
176           state.users[u.id] = u;
177         });
178       });
179@@ -328,7 +323,7 @@ it(tests, "overriding default loader behavior", () => {
180   });
181 });
182 
183-it(tests, "requestMonitor - error handler", () => {
184+it(tests, "mdw.api() - error handler", () => {
185   let err = false;
186   console.error = (msg: string) => {
187     if (err) return;
188@@ -341,7 +336,7 @@ it(tests, "requestMonitor - error handler", () => {
189 
190   const { schema, store } = testStore();
191   const query = createApi<ApiCtx>();
192-  query.use(requestMonitor());
193+  query.use(mdw.api());
194   query.use(storeMdw(schema.db));
195   query.use(query.routes());
196   query.use(function* () {
197@@ -357,10 +352,10 @@ it(tests, "requestMonitor - error handler", () => {
198 it(tests, "createApi with own key", async () => {
199   const { schema, store } = testStore();
200   const query = createApi();
201-  query.use(requestMonitor());
202+  query.use(mdw.api());
203   query.use(storeMdw(schema.db));
204   query.use(query.routes());
205-  query.use(customKey);
206+  query.use(mdw.customKey);
207   query.use(function* fetchApi(ctx, next) {
208     const data = {
209       users: [{ ...mockUser, ...ctx.action.payload.options }],
210@@ -400,6 +395,7 @@ it(tests, "createApi with own key", async () => {
211       ctx.json = {
212         ok: true,
213         data: curUsers,
214+        value: curUsers,
215       };
216     },
217   );
218@@ -427,10 +423,10 @@ it(tests, "createApi with own key", async () => {
219 it(tests, "createApi with custom key but no payload", async () => {
220   const { store, schema } = testStore();
221   const query = createApi();
222-  query.use(requestMonitor());
223+  query.use(mdw.api());
224   query.use(storeMdw(schema.db));
225   query.use(query.routes());
226-  query.use(customKey);
227+  query.use(mdw.customKey);
228   query.use(function* fetchApi(ctx, next) {
229     const data = {
230       users: [mockUser],
231@@ -470,6 +466,7 @@ it(tests, "createApi with custom key but no payload", async () => {
232       ctx.json = {
233         ok: true,
234         data: curUsers,
235+        value: curUsers,
236       };
237     },
238   );
239@@ -500,30 +497,29 @@ it(tests, "errorHandler", () => {
240     ctx: Ctx,
241     next: Next,
242   ) {
243-    a = 1;
244-    yield* next();
245-    a = 2;
246-    if (!ctx.result.ok) {
247+    try {
248+      a = 1;
249+      yield* next();
250+      a = 2;
251+    } catch (err) {
252       console.error(
253-        `Error: ${ctx.result.error.message}.  Check the endpoint [${ctx.name}]`,
254+        `Error: ${err.message}.  Check the endpoint [${ctx.name}]`,
255         ctx,
256       );
257-      console.error(ctx.result.error);
258     }
259   });
260-  query.use(queryCtx);
261-  query.use(urlParser);
262+  query.use(mdw.queryCtx);
263   query.use(query.routes());
264   query.use(function* fetchApi(ctx, next) {
265     if (`${ctx.req().url}`.startsWith("/users/")) {
266-      ctx.json = { ok: true, data: mockUser2 };
267+      ctx.json = { ok: true, data: mockUser2, value: mockUser2 };
268       yield* next();
269       return;
270     }
271     const data = {
272       users: [mockUser],
273     };
274-    ctx.json = { ok: true, data };
275+    ctx.json = { ok: true, data, value: data };
276     yield* next();
277   });
278 
A query/mdw.ts
+214, -0
  1@@ -0,0 +1,214 @@
  2+import { safe } from "../fx/mod.ts";
  3+import { compose } from "../compose.ts";
  4+import type {
  5+  ApiCtx,
  6+  ApiRequest,
  7+  FetchJsonCtx,
  8+  Next,
  9+  PerfCtx,
 10+  PipeCtx,
 11+  RequiredApiRequest,
 12+} from "./types.ts";
 13+import { mergeRequest } from "./util.ts";
 14+import { sleep } from "../deps.ts";
 15+import { noop } from "./util.ts";
 16+import * as fetchMdw from "./fetch.ts";
 17+import { log } from "../log.ts";
 18+export * from "./fetch.ts";
 19+
 20+/**
 21+ * This middleware will catch any errors in the pipeline
 22+ * and `console.error` the context object.
 23+ *
 24+ * You are highly encouraged to ditch this middleware if you need something
 25+ * more custom.
 26+ *
 27+ * It also sets `ctx.result` which informs us whether the entire
 28+ * middleware pipeline succeeded or not. Think the `.catch()` case for
 29+ * `window.fetch`.
 30+ */
 31+export function* err<Ctx extends PipeCtx = PipeCtx>(
 32+  ctx: Ctx,
 33+  next: Next,
 34+) {
 35+  ctx.result = yield* safe(next);
 36+  if (!ctx.result.ok) {
 37+    yield* log({
 38+      type: "query:err",
 39+      payload: {
 40+        message:
 41+          `Error: ${ctx.result.error.message}.  Check the endpoint [${ctx.name}]`,
 42+        ctx,
 43+      },
 44+    });
 45+  }
 46+}
 47+
 48+/**
 49+ * This middleware allows the user to override the default key provided
 50+ * to every pipeline function and instead use whatever they want.
 51+ *
 52+ * @example
 53+ * ```ts
 54+ * import { createPipe } from 'starfx';
 55+ *
 56+ * const thunk = createPipe();
 57+ * thunk.use(customKey);
 58+ *
 59+ * const doit = thunk.create('some-action', function*(ctx, next) {
 60+ *   ctx.key = 'something-i-want';
 61+ * })
 62+ * ```
 63+ */
 64+export function* customKey<Ctx extends PipeCtx = PipeCtx>(
 65+  ctx: Ctx,
 66+  next: Next,
 67+) {
 68+  if (
 69+    ctx?.key &&
 70+    ctx?.action?.payload?.key &&
 71+    ctx.key !== ctx.action.payload.key
 72+  ) {
 73+    const newKey = ctx.name.split("|")[0] + "|" + ctx.key;
 74+    ctx.key = newKey;
 75+    ctx.action.payload.key = newKey;
 76+  }
 77+  yield* next();
 78+}
 79+
 80+/**
 81+ * This middleware sets up the context object with some values that are
 82+ * necessary for {@link createApi} to work properly.
 83+ */
 84+export function* queryCtx<Ctx extends ApiCtx = ApiCtx>(ctx: Ctx, next: Next) {
 85+  if (!ctx.req) {
 86+    ctx.req = (r?: ApiRequest): RequiredApiRequest =>
 87+      mergeRequest(ctx.request, r);
 88+  }
 89+  if (!ctx.request) ctx.request = ctx.req();
 90+  if (!ctx.response) ctx.response = null;
 91+  if (!ctx.json) ctx.json = { ok: false, data: {}, error: {} };
 92+  if (!ctx.actions) ctx.actions = [];
 93+  if (!ctx.bodyType) ctx.bodyType = "json";
 94+  yield* next();
 95+}
 96+
 97+/**
 98+ * This middleware is a composition of many middleware used to faciliate
 99+ * the {@link createApi}.
100+ *
101+ * It is not required, however,
102+ */
103+export function api<Ctx extends ApiCtx = ApiCtx>() {
104+  return compose<Ctx>([
105+    err,
106+    queryCtx,
107+    customKey,
108+    fetchMdw.nameParser,
109+  ]);
110+}
111+
112+/**
113+ * This middleware will add `performance.now()` before and after your
114+ * middleware pipeline.
115+ */
116+export function* perf<Ctx extends PerfCtx = PerfCtx>(
117+  ctx: Ctx,
118+  next: Next,
119+) {
120+  const t0 = performance.now();
121+  yield* next();
122+  const t1 = performance.now();
123+  ctx.performance = t1 - t0;
124+}
125+
126+function backoffExp(attempt: number): number {
127+  if (attempt > 5) return -1;
128+  // 1s, 1s, 1s, 2s, 4s
129+  return Math.max(2 ** attempt * 125, 1000);
130+}
131+
132+/**
133+ * This middleware will retry failed `Fetch` request if `response.ok` is `false`.
134+ * It accepts a backoff function to determine how long to continue retrying.
135+ * The default is an exponential backoff {@link backoffExp} where the minimum is
136+ * 1sec between attempts and it'll reach 4s between attempts at the end with a
137+ * max of 5 attempts.
138+ *
139+ * An example backoff:
140+ * @example
141+ * ```ts
142+ *  // Any value less than 0 will stop the retry middleware.
143+ *  // Each attempt will wait 1s
144+ *  const backoff = (attempt: number) => {
145+ *    if (attempt > 5) return -1;
146+ *    return 1000;
147+ *  }
148+ *
149+ * const api = createApi();
150+ * api.use(mdw.api());
151+ * api.use(api.routes());
152+ * api.use(mdw.fetch());
153+ *
154+ * const fetchUsers = api.get('/users', [
155+ *  function*(ctx, next) {
156+ *    // ...
157+ *    yield next();
158+ *  },
159+ *  // fetchRetry should be after your endpoint function because
160+ *  // the retry middleware will update `ctx.json` before it reaches
161+ *  // your middleware
162+ *  fetchRetry(backoff),
163+ * ])
164+ * ```
165+ */
166+export function fetchRetry<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
167+  backoff: (attempt: number) => number = backoffExp,
168+) {
169+  return function* (ctx: CurCtx, next: Next) {
170+    yield* next();
171+
172+    if (!ctx.response) {
173+      return;
174+    }
175+
176+    if (ctx.response.ok) {
177+      return;
178+    }
179+
180+    let attempt = 1;
181+    let waitFor = backoff(attempt);
182+    while (waitFor >= 1) {
183+      yield* sleep(waitFor);
184+      yield* safe(() => fetchMdw.request(ctx, noop));
185+      yield* safe(() => fetchMdw.json(ctx, noop));
186+
187+      if (ctx.response.ok) {
188+        return;
189+      }
190+
191+      attempt += 1;
192+      waitFor = backoff(attempt);
193+    }
194+  };
195+}
196+
197+/**
198+ * This middleware is a composition of other middleware required to
199+ * use `window.fetch` {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API}
200+ * with {@link createApi}
201+ */
202+export function fetch<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
203+  {
204+    baseUrl = "",
205+  }: {
206+    baseUrl?: string;
207+  } = { baseUrl: "" },
208+) {
209+  return compose<CurCtx>([
210+    fetchMdw.composeUrl(baseUrl),
211+    fetchMdw.payload,
212+    fetchMdw.request,
213+    fetchMdw.json,
214+  ]);
215+}
D query/middleware.ts
+0, -165
  1@@ -1,165 +0,0 @@
  2-import { call } from "../fx/mod.ts";
  3-import { compose } from "../compose.ts";
  4-import type { Operator } from "../types.ts";
  5-
  6-import type {
  7-  Action,
  8-  ApiCtx,
  9-  ApiRequest,
 10-  Next,
 11-  PipeCtx,
 12-  RequiredApiRequest,
 13-} from "./types.ts";
 14-import { isObject, mergeRequest } from "./util.ts";
 15-
 16-/**
 17- * This middleware will catch any errors in the pipeline
 18- * and return the context object.
 19- */
 20-export function* errorHandler<Ctx extends PipeCtx = PipeCtx>(
 21-  ctx: Ctx,
 22-  next: Next,
 23-) {
 24-  yield* next();
 25-
 26-  if (!ctx.result.ok) {
 27-    console.error(
 28-      `Error: ${ctx.result.error.message}.  Check the endpoint [${ctx.name}]`,
 29-      ctx,
 30-    );
 31-    console.error(ctx.result.error);
 32-  }
 33-}
 34-
 35-/**
 36- * This middleware sets up the context object with some values that are
 37- * necessary for {@link createApi} to work properly.
 38- */
 39-export function* queryCtx<Ctx extends ApiCtx = ApiCtx>(ctx: Ctx, next: Next) {
 40-  if (!ctx.req) {
 41-    ctx.req = (r?: ApiRequest): RequiredApiRequest =>
 42-      mergeRequest(ctx.request, r);
 43-  }
 44-  if (!ctx.request) ctx.request = ctx.req();
 45-  if (!ctx.response) ctx.response = null;
 46-  if (!ctx.json) ctx.json = { ok: false, data: {} };
 47-  if (!ctx.actions) ctx.actions = [];
 48-  if (!ctx.bodyType) ctx.bodyType = "json";
 49-  yield* next();
 50-}
 51-
 52-/**
 53- * This middleware converts the name provided to {@link createApi} into data for the fetch request.
 54- */
 55-export function* urlParser<Ctx extends ApiCtx = ApiCtx>(ctx: Ctx, next: Next) {
 56-  const httpMethods = [
 57-    "get",
 58-    "head",
 59-    "post",
 60-    "put",
 61-    "delete",
 62-    "connect",
 63-    "options",
 64-    "trace",
 65-    "patch",
 66-  ];
 67-
 68-  const options = ctx.payload || {};
 69-  if (!isObject(options)) {
 70-    yield* next();
 71-    return;
 72-  }
 73-
 74-  let url = Object.keys(options).reduce((acc, key) => {
 75-    return acc.replace(`:${key}`, options[key]);
 76-  }, ctx.name);
 77-
 78-  let method = "";
 79-  httpMethods.forEach((curMethod) => {
 80-    const pattern = new RegExp(`\\s*\\[` + curMethod + `\\]\\s*\\w*`, "i");
 81-    const tmpUrl = url.replace(pattern, "");
 82-    if (tmpUrl.length !== url.length) {
 83-      method = curMethod.toLocaleUpperCase();
 84-    }
 85-    url = tmpUrl;
 86-  }, url);
 87-
 88-  ctx.request = ctx.req({ url });
 89-  if (method) {
 90-    ctx.request = ctx.req({ method });
 91-  }
 92-
 93-  yield* next();
 94-}
 95-
 96-/**
 97- * This middleware allows the user to override the default key provided to every pipeline function
 98- * and instead use whatever they want.
 99- *
100- * @example
101- * ```ts
102- * import { createPipe } from 'starfx';
103- *
104- * const thunk = createPipe();
105- * thunk.use(customKey);
106- *
107- * const doit = thunk.create('some-action', function*(ctx, next) {
108- *   ctx.key = 'something-i-want';
109- * })
110- * ```
111- */
112-export function* customKey<Ctx extends ApiCtx = ApiCtx>(ctx: Ctx, next: Next) {
113-  if (
114-    ctx?.key &&
115-    ctx?.action?.payload?.key &&
116-    ctx.key !== ctx.action.payload.key
117-  ) {
118-    const newKey = ctx.name.split("|")[0] + "|" + ctx.key;
119-    ctx.key = newKey;
120-    ctx.action.payload.key = newKey;
121-  }
122-  yield* next();
123-}
124-
125-/**
126- * This middleware is a composition of many middleware used to faciliate the {@link createApi}
127- */
128-export function requestMonitor<Ctx extends ApiCtx = ApiCtx>() {
129-  return compose<Ctx>([
130-    errorHandler,
131-    queryCtx,
132-    urlParser,
133-    customKey,
134-  ]);
135-}
136-
137-export interface PerfCtx<P = unknown> extends PipeCtx<P> {
138-  performance: number;
139-}
140-
141-/**
142- * This middleware will add `performance.now()` before and after your middleware pipeline.
143- */
144-export function* performanceMonitor<Ctx extends PerfCtx = PerfCtx>(
145-  ctx: Ctx,
146-  next: Next,
147-) {
148-  const t0 = performance.now();
149-  yield* next();
150-  const t1 = performance.now();
151-  ctx.performance = t1 - t0;
152-}
153-
154-/**
155- * This middleware will call the `saga` provided with the action sent to the middleware pipeline.
156- */
157-export function wrap<Ctx extends PipeCtx = PipeCtx, T = any>(
158-  op: (a: Action) => Operator<T>,
159-) {
160-  return function* (ctx: Ctx, next: Next) {
161-    yield* call(function* () {
162-      return op(ctx.action);
163-    });
164-    yield* next();
165-  };
166-}
M query/mod.ts
+34, -3
 1@@ -1,6 +1,37 @@
 2-export * from "./pipe.ts";
 3+import { createThunks } from "./thunk.ts";
 4+export * from "./thunk.ts";
 5 export * from "./api.ts";
 6 export * from "./types.ts";
 7-export * from "./fetch.ts";
 8-export * from "./middleware.ts";
 9 export * from "./create-key.ts";
10+import * as mdw from "./mdw.ts";
11+
12+export { mdw };
13+
14+/**
15+ * @deprecated Use {@link createThunks} instead;
16+ */
17+export const createPipe = createThunks;
18+/**
19+ * @deprecated Use {@link mdw.err} instead;
20+ */
21+export const errorHandler = mdw.err;
22+/**
23+ * @deprecated Use {@link mdw.query} instead;
24+ */
25+export const queryCtx = mdw.queryCtx;
26+/**
27+ * @deprecated Use {@link fetchMdw.composeUrl} instead;
28+ */
29+export const urlParser = mdw.composeUrl;
30+/**
31+ * @deprecated Use {@link mdw.customKey} instead;
32+ */
33+export const customKey = mdw.customKey;
34+/**
35+ * @deprecated Use {@link mdw.api} instead;
36+ */
37+export const requestMonitor = mdw.api;
38+/**
39+ * @deprecated Use {@link mdw.fetch} instead;
40+ */
41+export const fetcher = mdw.fetch;
M query/react.test.ts
+2, -2
 1@@ -11,7 +11,7 @@ import { Provider, sleep as delay, useSelector } from "../deps.ts";
 2 import { configureStore, updateStore } from "../store/mod.ts";
 3 
 4 import { createApi } from "./api.ts";
 5-import { requestMonitor } from "./middleware.ts";
 6+import * as mdw from "./middleware.ts";
 7 import { useApi } from "./react.ts";
 8 import { selectDataById } from "./slice.ts";
 9 import { createKey } from "./create-key.ts";
10@@ -32,7 +32,7 @@ interface User {
11 
12 const setupTest = async () => {
13   const api = createApi();
14-  api.use(requestMonitor());
15+  api.use(mdw.api());
16   api.use(api.routes());
17   api.use(function* (ctx, next) {
18     yield* delay(10);
R query/pipe.test.ts => query/thunk.test.ts
+13, -14
  1@@ -4,9 +4,8 @@ import { configureStore, put, takeEvery } from "../store/mod.ts";
  2 import { sleep as delay } from "../deps.ts";
  3 import type { QueryState } from "../types.ts";
  4 import { createQueryState } from "../action.ts";
  5-
  6 import { sleep } from "../test.ts";
  7-import { createPipe } from "./pipe.ts";
  8+import { createThunks } from "./thunk.ts";
  9 import type { Next, PipeCtx } from "./types.ts";
 10 import { updateStore } from "../store/fx.ts";
 11 
 12@@ -121,13 +120,13 @@ function* processTickets(
 13   yield* next();
 14 }
 15 
 16-const tests = describe("createPipe()");
 17+const tests = describe("createThunks()");
 18 
 19 it(
 20   tests,
 21   "when create a query fetch pipeline - execute all middleware and save to redux",
 22   () => {
 23-    const api = createPipe<RoboCtx>();
 24+    const api = createThunks<RoboCtx>();
 25     api.use(api.routes());
 26     api.use(convertNameToUrl);
 27     api.use(onFetchApi);
 28@@ -154,7 +153,7 @@ it(
 29   tests,
 30   "when providing a generator the to api.create function - should call that generator before all other middleware",
 31   () => {
 32-    const api = createPipe<RoboCtx>();
 33+    const api = createThunks<RoboCtx>();
 34     api.use(api.routes());
 35     api.use(convertNameToUrl);
 36     api.use(onFetchApi);
 37@@ -189,7 +188,7 @@ it(
 38 
 39 it(tests, "error handling", () => {
 40   let called;
 41-  const api = createPipe<RoboCtx>();
 42+  const api = createThunks<RoboCtx>();
 43   api.use(api.routes());
 44   api.use(function* upstream(_, next) {
 45     try {
 46@@ -212,7 +211,7 @@ it(tests, "error handling", () => {
 47 
 48 it(tests, "error handling inside create", () => {
 49   let called = false;
 50-  const api = createPipe<RoboCtx>();
 51+  const api = createThunks<RoboCtx>();
 52   api.use(api.routes());
 53   api.use(function* fail() {
 54     throw new Error("some error");
 55@@ -237,7 +236,7 @@ it(tests, "error handling inside create", () => {
 56 
 57 it(tests, "error inside endpoint mdw", () => {
 58   let called = false;
 59-  const query = createPipe();
 60+  const query = createThunks();
 61   query.use(function* (_, next) {
 62     try {
 63       yield* next();
 64@@ -268,7 +267,7 @@ it(tests, "error inside endpoint mdw", () => {
 65 });
 66 
 67 it(tests, "create fn is an array", () => {
 68-  const api = createPipe<RoboCtx>();
 69+  const api = createThunks<RoboCtx>();
 70   api.use(api.routes());
 71   api.use(function* (ctx, next) {
 72     asserts.assertEquals(ctx.request, {
 73@@ -298,7 +297,7 @@ it(tests, "create fn is an array", () => {
 74 });
 75 
 76 it(tests, "run() on endpoint action - should run the effect", () => {
 77-  const api = createPipe<RoboCtx>();
 78+  const api = createThunks<RoboCtx>();
 79   api.use(api.routes());
 80   let acc = "";
 81   const action1 = api.create(
 82@@ -338,7 +337,7 @@ it(tests, "run() on endpoint action - should run the effect", () => {
 83 
 84 it(tests, "middleware order of execution", async () => {
 85   let acc = "";
 86-  const api = createPipe();
 87+  const api = createThunks();
 88   api.use(api.routes());
 89 
 90   api.use(function* (_, next) {
 91@@ -379,7 +378,7 @@ it(tests, "retry with actionFn", async () => {
 92   let acc = "";
 93   let called = false;
 94 
 95-  const api = createPipe();
 96+  const api = createThunks();
 97   api.use(api.routes());
 98 
 99   const action = api.create(
100@@ -407,7 +406,7 @@ it(tests, "retry with actionFn", async () => {
101 
102 it(tests, "retry with actionFn with payload", async () => {
103   let acc = "";
104-  const api = createPipe();
105+  const api = createThunks();
106   api.use(api.routes());
107 
108   api.use(function* (ctx: PipeCtx<{ page: number }>, next) {
109@@ -436,7 +435,7 @@ it(tests, "retry with actionFn with payload", async () => {
110 });
111 
112 it(tests, "should only call thunk once", () => {
113-  const api = createPipe<RoboCtx>();
114+  const api = createThunks<RoboCtx>();
115   api.use(api.routes());
116   let acc = "";
117 
R query/pipe.ts => query/thunk.ts
+8, -9
 1@@ -1,6 +1,6 @@
 2 import { compose } from "../compose.ts";
 3 import type { Operator, Payload } from "../types.ts";
 4-import { parallel } from "../mod.ts";
 5+import { keepAlive } from "../mod.ts";
 6 
 7 // TODO: remove store deps
 8 import { takeEvery } from "../redux/mod.ts";
 9@@ -24,7 +24,7 @@ import { Ok } from "../deps.ts";
10 export interface SagaApi<Ctx extends PipeCtx> {
11   use: (fn: Middleware<Ctx>) => void;
12   routes: () => Middleware<Ctx>;
13-  bootup: Operator<unknown>;
14+  bootup: Operator<void>;
15 
16   /**
17    * Name only
18@@ -94,17 +94,17 @@ export interface SagaApi<Ctx extends PipeCtx> {
19  *
20  * @example
21  * ```ts
22- * import { createPipe } from 'starfx';
23+ * import { createThunks } from 'starfx';
24  *
25- * const thunk = createPipe();
26- * thunk.use(function* (ctx, next) {
27+ * const thunks = createThunks();
28+ * thunks.use(function* (ctx, next) {
29  *   console.log('beginning');
30  *   yield* next();
31  *   console.log('end');
32  * });
33  * thunks.use(thunks.routes());
34  *
35- * const doit = thunk.create('do-something', function*(ctx, next) {
36+ * const doit = thunks.create('do-something', function*(ctx, next) {
37  *   console.log('middle');
38  *   yield* next();
39  *   console.log('middle end');
40@@ -119,7 +119,7 @@ export interface SagaApi<Ctx extends PipeCtx> {
41  * // end
42  * ```
43  */
44-export function createPipe<Ctx extends PipeCtx = PipeCtx<any>>(
45+export function createThunks<Ctx extends PipeCtx = PipeCtx<any>>(
46   {
47     supervisor = takeEvery,
48   }: {
49@@ -215,8 +215,7 @@ export function createPipe<Ctx extends PipeCtx = PipeCtx<any>>(
50   }
51 
52   function* bootup() {
53-    const group = yield* parallel(Object.values(visors));
54-    return yield* group;
55+    yield* keepAlive(Object.values(visors));
56   }
57 
58   function routes() {
M query/types.ts
+23, -15
 1@@ -19,19 +19,23 @@ export interface LoaderCtx<P = unknown> extends PipeCtx<P> {
 2   loader: Partial<LoaderItemState> | null;
 3 }
 4 
 5-export interface ApiFetchSuccess<ApiSuccess = any> {
 6-  ok: true;
 7-  data: ApiSuccess;
 8-}
 9-
10-export interface ApiFetchError<ApiError = any> {
11-  ok: false;
12-  data: ApiError;
13-}
14-
15-export type ApiFetchResponse<ApiSuccess = any, ApiError = any> =
16-  | ApiFetchSuccess<ApiSuccess>
17-  | ApiFetchError<ApiError>;
18+export type ApiFetchResult<ApiSuccess = any, ApiError = any> =
19+  | {
20+    ok: true;
21+    value: ApiSuccess;
22+    /**
23+     * @deprecated Use {@link ApiFetchResult.value} instead.
24+     */
25+    data: ApiSuccess;
26+  }
27+  | {
28+    ok: false;
29+    error: ApiError;
30+    /**
31+     * @deprecated Use {@link ApiFetchResult.error} instead.
32+     */
33+    data: ApiError;
34+  };
35 
36 export type ApiRequest = Partial<{ url: string } & RequestInit>;
37 export type RequiredApiRequest = {
38@@ -47,7 +51,7 @@ export interface FetchCtx<P = any> extends PipeCtx<P> {
39 }
40 
41 export interface FetchJson<ApiSuccess = any, ApiError = any> {
42-  json: ApiFetchResponse<ApiSuccess, ApiError>;
43+  json: ApiFetchResult<ApiSuccess, ApiError>;
44 }
45 
46 export interface FetchJsonCtx<P = any, ApiSuccess = any, ApiError = any>
47@@ -61,6 +65,10 @@ export interface ApiCtx<Payload = any, ApiSuccess = any, ApiError = any>
48   cacheData: any;
49 }
50 
51+export interface PerfCtx<P = unknown> extends PipeCtx<P> {
52+  performance: number;
53+}
54+
55 export type Middleware<Ctx extends PipeCtx = PipeCtx> = (
56   ctx: Ctx,
57   next: Next,
58@@ -77,7 +85,7 @@ export type MiddlewareApiCo<Ctx extends ApiCtx = ApiCtx> =
59   | Middleware<Ctx>
60   | Middleware<Ctx>[];
61 
62-export type Next = () => Operation<unknown>;
63+export type Next = () => Operation<void>;
64 
65 export interface Action {
66   type: string;
M redux/query.ts
+11, -4
 1@@ -65,7 +65,7 @@ export function* dispatchActions(ctx: { actions: AnyAction[] }, next: Next) {
 2 
 3 /**
 4  * This middleware will automatically cache any data found inside `ctx.json`
 5- * which is where we store JSON data from the `fetcher` middleware.
 6+ * which is where we store JSON data from the {@link mdw.fetch} middleware.
 7  */
 8 export function* simpleCache<Ctx extends ApiCtx = ApiCtx>(
 9   ctx: Ctx,
10@@ -76,8 +76,11 @@ export function* simpleCache<Ctx extends ApiCtx = ApiCtx>(
11   );
12   yield* next();
13   if (!ctx.cache) return;
14-  const { data } = ctx.json;
15-  yield* put(addData({ [ctx.key]: data }));
16+  if (ctx.json.ok) {
17+    yield* put(addData({ [ctx.key]: ctx.json.value }));
18+  } else {
19+    yield* put(addData({ [ctx.key]: ctx.json.error }));
20+  }
21   ctx.cacheData = data;
22 }
23 
24@@ -85,7 +88,11 @@ export function* simpleCache<Ctx extends ApiCtx = ApiCtx>(
25  * This middleware will track the status of a fetch request.
26  */
27 export function loadingMonitor<Ctx extends ApiCtx = ApiCtx>(
28-  errorFn: (ctx: Ctx) => string = (ctx) => ctx.json?.data?.message || "",
29+  errorFn: (ctx: Ctx) => string = (ctx) => {
30+    const jso = ctx.json;
31+    if (jso.ok) return "";
32+    return jso.error?.message || "";
33+  },
34 ) {
35   return function* trackLoading(ctx: Ctx, next: Next) {
36     yield* put([
M store/query.ts
+12, -3
 1@@ -23,7 +23,7 @@ export function storeMdw<
 2 
 3 /**
 4  * This middleware will automatically cache any data found inside `ctx.json`
 5- * which is where we store JSON data from the `fetcher` middleware.
 6+ * which is where we store JSON data from the {@link mdw.fetch} middleware.
 7  */
 8 export function simpleCache<Ctx extends ApiCtx = ApiCtx>(
 9   dataSchema: TableOutput<any, AnyState>,
10@@ -35,7 +35,12 @@ export function simpleCache<Ctx extends ApiCtx = ApiCtx>(
11     ctx.cacheData = yield* select(dataSchema.selectById, { id: ctx.key });
12     yield* next();
13     if (!ctx.cache) return;
14-    const { data } = ctx.json;
15+    let data;
16+    if (ctx.json.ok) {
17+      data = ctx.json.value;
18+    } else {
19+      data = ctx.json.error;
20+    }
21     yield* updateStore(dataSchema.add({ [ctx.key]: data }));
22     ctx.cacheData = data;
23   };
24@@ -64,7 +69,11 @@ export function loadingMonitor<
25   M extends AnyState = AnyState,
26 >(
27   loaderSchema: LoaderOutput<M, AnyState>,
28-  errorFn: (ctx: Ctx) => string = (ctx) => ctx.json?.data?.message || "",
29+  errorFn: (ctx: Ctx) => string = (ctx) => {
30+    const jso = ctx.json;
31+    if (jso.ok) return "";
32+    return jso.error?.message || "";
33+  },
34 ) {
35   return function* trackLoading(ctx: Ctx, next: Next) {
36     yield* updateStore([
M store/store.ts
+8, -1
 1@@ -15,6 +15,7 @@ import { Next } from "../query/types.ts";
 2 import type { FxStore, Listener, StoreUpdater, UpdaterCtx } from "./types.ts";
 3 import { ActionContext, StoreContext, StoreUpdateContext } from "./context.ts";
 4 import { put } from "./fx.ts";
 5+import { log } from "../log.ts";
 6 
 7 const stubMsg = "This is merely a stub, not implemented";
 8 
 9@@ -120,7 +121,13 @@ export function createStore<S extends AnyState>({
10     yield* mdw(ctx);
11     // TODO: dev mode only?
12     if (!ctx.result.ok) {
13-      console.error(ctx.result);
14+      yield* log({
15+        type: "store:update",
16+        payload: {
17+          message: `Exception raised when calling store updaters`,
18+          error: ctx.result.error,
19+        },
20+      });
21     }
22     return ctx;
23   }