@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 +1 -1
- package/src/generate-stores.ts +34 -19
- package/test/stores.test.ts +13 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/sdk",
|
|
3
|
-
"version": "0.2.
|
|
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"
|
package/src/generate-stores.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
73
|
-
//
|
|
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.
|
|
80
|
-
: `${v}.
|
|
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 }) =>
|
|
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) > "
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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)"}
|
package/test/stores.test.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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 .
|
|
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.
|
|
69
|
-
expect(stores).toContain('ctx.
|
|
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", () => {
|