@suluk/sdk 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suluk/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Generate a COMPLETE, intuitive TypeScript SDK from a v4 'Suluk' contract — built on ofetch, entity-grouped, fully typed from the schemas, auth wired (bearer/session) via interceptors, and the v4 superpowers (declared cost + access) surfaced as typed metadata. Not a bag of functions: a library a developer downloads and uses straight away. CANDIDATE tooling.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -40,7 +40,8 @@ export function generateStores(doc: OpenAPIv4Document, opts: StoresOptions = {})
40
40
  const notify = (doc as { ["x-suluk-notify"]?: SulukNotifyPolicy })["x-suluk-notify"] ?? {};
41
41
 
42
42
  const queries = ops.filter((o) => o.store?.key);
43
- const mutations = ops.filter((o) => o.store?.invalidates && o.store.invalidates.length > 0);
43
+ // a mutation = a store facet that is NOT a query (no `key`) and does something on write: invalidate stores OR toast onSuccess.
44
+ const mutations = ops.filter((o) => o.store && !o.store.key && ((o.store.invalidates && o.store.invalidates.length > 0) || !!o.store.onSuccess));
44
45
 
45
46
  // ── STATES: a $<key> fetcher store (or a (…args)=>store factory) per query op ──
46
47
  const queryDecls = queries
@@ -53,31 +54,36 @@ export function generateStores(doc: OpenAPIv4Document, opts: StoresOptions = {})
53
54
  if (op.store!.ttl != null) settings.push(`cacheLifetime: ${Math.round(op.store!.ttl * 1000)}`);
54
55
  if (op.store!.revalidateOnFocus) settings.push(`revalidateOnFocus: true`);
55
56
  const setStr = settings.length ? `, ${settings.join(", ")}` : "";
57
+ // on success clear the per-op dedupe marker so a recovered query re-arms notifications; on error report+dedupe (true)
58
+ // so nanoquery's retry-backoff + revalidate-on-focus re-runs don't re-toast the SAME failure. Always re-throw.
56
59
  if (argful(op)) {
57
60
  return (
58
61
  ` const ${v} = (...args: Parameters<SulukClient${typeIndex(op)}>) =>\n` +
59
- ` createFetcherStore<${T}>([${JSON.stringify("@" + key)}, JSON.stringify(args)], {\n` +
60
- ` fetcher: async () => { try { return await client.${acc}(...args); } catch (e) { await report(${JSON.stringify(key)}, e); throw e; } }${setStr},\n` +
62
+ ` createFetcherStore<${T}>([${JSON.stringify("@" + key + "\u0000")}, JSON.stringify(args)], {\n` +
63
+ ` fetcher: async () => { try { const v = await client.${acc}(...args); _seen.delete(${JSON.stringify(key)}); return v; } catch (e) { await report(${JSON.stringify(key)}, e, true); throw e; } }${setStr},\n` +
61
64
  ` });`
62
65
  );
63
66
  }
64
67
  return (
65
68
  ` const ${v} = createFetcherStore<${T}>([${JSON.stringify("@" + key)}], {\n` +
66
- ` fetcher: async () => { try { return await client.${acc}(); } catch (e) { await report(${JSON.stringify(key)}, e); throw e; } }${setStr},\n` +
69
+ ` fetcher: async () => { try { const v = await client.${acc}(); _seen.delete(${JSON.stringify(key)}); return v; } catch (e) { await report(${JSON.stringify(key)}, e, true); throw e; } }${setStr},\n` +
67
70
  ` });`
68
71
  );
69
72
  })
70
73
  .join("\n");
71
74
 
72
- // ── invalidators: store key → a function that refetches it (exact `.invalidate()` for plain stores; prefix-match
73
- // for parameterized families @nanostores/query joins key parts with "", so a "@<key>" prefix is unambiguous). ──
75
+ // ── invalidators: store key → a function that refreshes it. Use REVALIDATE (not invalidate): revalidateKeys keeps the
76
+ // cached data and refetches in the background, so a list stays on screen during a mutation refresh instead of
77
+ // blinking to a spinner. Exact `.revalidate()` for plain stores; a delimited-prefix match for parameterized
78
+ // families (@nanostores/query joins key parts with "", so the "@<key>\u0000" prefix — NUL-delimited — is unambiguous
79
+ // and can't collide a key that is another key's string-prefix, e.g. "credit" vs "credits"). ──
74
80
  const invalDecls = queries
75
81
  .map((op) => {
76
82
  const key = op.store!.key!;
77
83
  const v = "$" + ident(key);
78
84
  const body = argful(op)
79
- ? `ctx.invalidateKeys((k) => typeof k === "string" && k.startsWith(${JSON.stringify("@" + key)}))`
80
- : `${v}.invalidate()`;
85
+ ? `ctx.revalidateKeys((k) => typeof k === "string" && k.startsWith(${JSON.stringify("@" + key + "\u0000")}))`
86
+ : `${v}.revalidate()`;
81
87
  return ` ${JSON.stringify(key)}: () => { void hooks.callHook("store:invalidate", { store: ${JSON.stringify(key)} }); ${body}; },`;
82
88
  })
83
89
  .join("\n");
@@ -88,8 +94,8 @@ export function generateStores(doc: OpenAPIv4Document, opts: StoresOptions = {})
88
94
  .map((op) => {
89
95
  const name = ident(camel(op.name));
90
96
  const acc = clientAccessor(op);
91
- const inv = op.store!.invalidates!;
92
- const invCalls = inv.map((k) => `_invalidate[${JSON.stringify(k)}]?.();`).join(" ");
97
+ const inv = op.store!.invalidates ?? [];
98
+ const invCalls = inv.length ? inv.map((k) => `_invalidate[${JSON.stringify(k)}]?.();`).join(" ") : "/* no stores to invalidate */";
93
99
  const successHook = op.store!.onSuccess
94
100
  ? `\n await hooks.callHook("notify", { severity: "success", problem: { status: 200, detail: ${JSON.stringify(op.store!.onSuccess)} } });`
95
101
  : "";
@@ -131,7 +137,10 @@ export function generateStores(doc: OpenAPIv4Document, opts: StoresOptions = {})
131
137
  * import { toast } from "sonner";
132
138
  * const api = createClient({ baseURL: "…" });
133
139
  * const stores = createStores(api);
134
- * stores.hooks.hook("notify", ({ severity, problem }) => severity !== "silent" && toast[severity](problem.detail ?? problem.title ?? "Error"));
140
+ * stores.hooks.hook("notify", ({ severity, problem }) => {
141
+ * if (severity === "silent") return; // sonner exposes toast.warning (not toast.warn) — map it:
142
+ * (severity === "warn" ? toast.warning : toast[severity])(problem.detail ?? problem.title ?? "Error");
143
+ * });
135
144
  * // component: const { data } = useStore(stores.$paymentMethods); action: await stores.actions.setDefaultPaymentMethod({ id });
136
145
  *
137
146
  * Requires: \`npm i @nanostores/query nanostores hookable\`.
@@ -167,15 +176,14 @@ export interface StoreHooks {
167
176
  /** The DECLARED status→severity policy (x-suluk-notify). Keys: a status ("402"), a class ("2xx"/"4xx"/"5xx"), or "network". */
168
177
  const NOTIFY: Record<string, NotifySeverity> = ${JSON.stringify(notify)};
169
178
 
170
- /** Classify a status to a severity: exact status > class (Nxx) > "network" > "silent". */
179
+ /** Classify a status to a severity: exact status ("402" / "network") > class (Nxx) > "silent". */
171
180
  function classify(status: number | "network"): NotifySeverity {
172
- const k = String(status);
181
+ const k = String(status); // exact key — covers a numeric status AND "network"
173
182
  if (NOTIFY[k]) return NOTIFY[k]!;
174
183
  if (typeof status === "number") {
175
184
  const cls = Math.floor(status / 100) + "xx";
176
185
  if (NOTIFY[cls]) return NOTIFY[cls]!;
177
186
  }
178
- if (status === "network" && NOTIFY["network"]) return NOTIFY["network"]!;
179
187
  return "silent";
180
188
  }
181
189
 
@@ -197,14 +205,21 @@ export interface CreateStoresOptions {
197
205
  export function createStores(client: SulukClient, options: CreateStoresOptions = {}) {
198
206
  const hooks = options.hooks ?? createHooks<StoreHooks>();
199
207
  const [createFetcherStore, , ctx] = nanoquery();
200
-
201
- /** classify fire request:error (always) fire notify (unless silent). The error seam exposed as \`report\` so
202
- * one-off / multi-call actions you compose in app code route errors through the SAME declared notify policy. */
203
- async function report(op: string, e: unknown): Promise<void> {
208
+ /** last surfaced status per op — so an AUTO re-run of a failing query (retry-backoff / revalidate-on-focus) doesn't
209
+ * re-toast the SAME failure. A query clears its entry on success; user-triggered actions/one-offs pass dedupe=false. */
210
+ const _seen = new Map<string, number | "network">();
211
+
212
+ /** classify → fire request:error (always) → fire notify (unless silent, or — when \`dedupe\` — unchanged since last).
213
+ * The error seam — exposed as \`report\` so one-off / multi-call actions you compose in app code route errors through
214
+ * the SAME declared notify policy. Pass \`dedupe=true\` for auto-refetching queries; omit it for user-driven calls. */
215
+ async function report(op: string, e: unknown, dedupe = false): Promise<void> {
204
216
  const problem = problemOf(e);
205
217
  const severity = classify(problem.status);
206
218
  await hooks.callHook("request:error", { op, severity, problem });
207
- if (severity !== "silent") await hooks.callHook("notify", { severity, problem });
219
+ if (severity === "silent") return;
220
+ if (dedupe && _seen.get(op) === problem.status) return;
221
+ _seen.set(op, problem.status);
222
+ await hooks.callHook("notify", { severity, problem });
208
223
  }
209
224
 
210
225
  ${queryDecls || " // (no query stores declared)"}
@@ -37,9 +37,9 @@ describe("@suluk/sdk generateStores — a typed Nano Stores reactive layer from
37
37
  expect(stores).toContain("cacheLifetime: 60000");
38
38
  });
39
39
 
40
- test("a parameterized query (path param) becomes a (…args)=>store factory keyed by the args", () => {
40
+ test("a parameterized query (path param) becomes a (…args)=>store factory keyed by the args (delimited prefix)", () => {
41
41
  expect(stores).toContain('const $pet = (...args: Parameters<SulukClient["pet"]["get"]>) =>');
42
- expect(stores).toContain('createFetcherStore<Awaited<ReturnType<SulukClient["pet"]["get"]>>>(["@pet", JSON.stringify(args)]');
42
+ expect(stores).toContain("JSON.stringify(args)], {"); // parameterized query store keyed by its args
43
43
  expect(stores).toContain("client.pet.get(...args)");
44
44
  });
45
45
 
@@ -59,14 +59,21 @@ describe("@suluk/sdk generateStores — a typed Nano Stores reactive layer from
59
59
  expect(stores).toContain('"network":"error"');
60
60
  expect(stores).toContain('"2xx":"silent"');
61
61
  expect(stores).toContain("function classify(status: number | \"network\"): NotifySeverity");
62
- expect(stores).toContain('if (severity !== "silent") await hooks.callHook("notify"'); // policy decides; renderer injected
62
+ expect(stores).toContain('if (severity === "silent") return;'); // policy decides; renderer injected
63
63
  expect(stores).toContain("export interface StoreHooks");
64
64
  });
65
65
 
66
- test("invalidators — exact .invalidate() for plain stores; prefix match for parameterized families", () => {
66
+ test("invalidators — REVALIDATE (keep cache visible): exact .revalidate() for plain stores; delimited-prefix for families", () => {
67
67
  expect(stores).toContain('"session": () => {');
68
- expect(stores).toContain("$session.invalidate()");
69
- expect(stores).toContain('ctx.invalidateKeys((k) => typeof k === "string" && k.startsWith("@pet"))'); // pet is parameterized
68
+ expect(stores).toContain("$session.revalidate()");
69
+ expect(stores).toContain('ctx.revalidateKeys((k) => typeof k === "string" && k.startsWith('); // parameterized family // pet is parameterized
70
+ });
71
+
72
+ test("query errors dedupe so an auto re-run (retry/refocus) of the SAME failure doesn't re-toast", () => {
73
+ expect(stores).toContain("const _seen = new Map<string, number | \"network\">()");
74
+ expect(stores).toContain("if (dedupe && _seen.get(op) === problem.status) return;");
75
+ expect(stores).toContain('catch (e) { await report("session", e, true); throw e; }'); // queries dedupe
76
+ expect(stores).toContain('_seen.delete("session")'); // cleared on success → re-arms
70
77
  });
71
78
 
72
79
  test("returns the stores + actions + hooks + ctx", () => {