@suluk/sdk 0.1.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/README.md +182 -0
- package/package.json +2 -2
- package/src/generate-stores.ts +239 -0
- package/src/generate.ts +39 -20
- package/src/index.ts +10 -1
- package/test/stores.test.ts +99 -0
package/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# @suluk/sdk
|
|
2
|
+
|
|
3
|
+
**Generate a complete, intuitive TypeScript SDK from one v4 "Suluk" contract — ofetch-based, entity-grouped, fully typed, auth wired, with the v4 superpowers (declared cost + access + input schema) surfaced as typed metadata on every method.**
|
|
4
|
+
|
|
5
|
+
> **CANDIDATE tooling — not official OpenAPI.** Suluk is a single-contributor candidate for
|
|
6
|
+
> OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable
|
|
7
|
+
> to ratify anything on the SIG's behalf.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
bun add @suluk/sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`ofetch` and `@cfworker/json-schema` are peer dependencies — bring your own. The **generated** SDK
|
|
16
|
+
imports both at runtime, so an app that ships the output needs `bun add ofetch @cfworker/json-schema`.
|
|
17
|
+
|
|
18
|
+
## What it does
|
|
19
|
+
|
|
20
|
+
`generateSdk(doc)` takes a v4 document and returns **one self-contained `.ts` file** (a string) — a
|
|
21
|
+
client library a developer downloads and uses straight away, not a bag of functions:
|
|
22
|
+
|
|
23
|
+
- **`ofetch`-based `createClient(config)` factory** — auth wired via an `onRequest` interceptor (bearer
|
|
24
|
+
token or session cookie), retries, configurable `baseURL`.
|
|
25
|
+
- **Entity-grouped methods** — CRUD operations group by entity (`api.product.create(...)`), custom ops
|
|
26
|
+
by path (`api.checkout.order(...)`). Method-name collisions are resolved deterministically and surfaced.
|
|
27
|
+
- **Fully typed from the schemas** — request bodies, query params, and response types are TS types
|
|
28
|
+
derived from the contract's JSON Schemas (the same `tsType` mapping is exported for reuse).
|
|
29
|
+
- **Inputs shipped AS DATA + validated directly** — the contract's input JSON Schemas (2020-12) are
|
|
30
|
+
emitted as a literal `schemas` map and validated by a generic, eval-free engine (`@cfworker/json-schema`,
|
|
31
|
+
Workers-native) — never transpiled into one validator's source, so what runs is exactly what the
|
|
32
|
+
contract stores. Each input is exposed as a **Standard Schema** (`.input`), so it drops into
|
|
33
|
+
react-hook-form / TanStack Form / tRPC unchanged.
|
|
34
|
+
- **v4 facets as typed metadata** — every method carries `.cost` (µ$), `.requires` (access), and `.input`
|
|
35
|
+
(the Standard Schema), plus a client-level `$manifest` / `$meta` for tooling. These are hints + a
|
|
36
|
+
client-side guard, **not** enforcement — the server stays the security boundary.
|
|
37
|
+
|
|
38
|
+
## When to reach for it
|
|
39
|
+
|
|
40
|
+
- You want to hand API **consumers** a typed client library generated from the contract — e.g. expose a
|
|
41
|
+
`GET /sdk.ts` route that streams the generated source as a download.
|
|
42
|
+
- You want the client to validate inputs against the contract's real constraints before sending, and to
|
|
43
|
+
carry cost/access metadata for cost-aware or permission-aware UIs.
|
|
44
|
+
|
|
45
|
+
**Not** for an admin UI (use `@suluk/panel` / `@suluk/admin`), rendered API reference docs
|
|
46
|
+
(`@suluk/reference` / `@suluk/scalar`), or owned React form/table components (`@suluk/shadcn`). And it
|
|
47
|
+
generates source the consumer **owns** — it does not host a client runtime they have to call home to.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
`generateSdk` is codegen: feed it a v4 document, get back TypeScript source as a string. Common case —
|
|
52
|
+
serve it as a downloadable file from your API (this is exactly how `saasuluk` exposes its SDK):
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { generateSdk } from "@suluk/sdk";
|
|
56
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
57
|
+
|
|
58
|
+
// `document` is your v4 contract (e.g. projected by @suluk/hono / @suluk/drizzle)
|
|
59
|
+
app.get("/sdk.ts", (c) =>
|
|
60
|
+
new Response(generateSdk(document, { baseURL: new URL(c.req.url).origin }), {
|
|
61
|
+
headers: {
|
|
62
|
+
"content-type": "application/typescript; charset=utf-8",
|
|
63
|
+
"content-disposition": 'attachment; filename="my-sdk.ts"',
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or write it to disk in a build step:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { generateSdk } from "@suluk/sdk";
|
|
73
|
+
|
|
74
|
+
const source = generateSdk(v4Document, { baseURL: "https://api.example.com" });
|
|
75
|
+
await Bun.write("./client/suluk-sdk.ts", source);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Using the generated SDK
|
|
79
|
+
|
|
80
|
+
The emitted file exports `createClient`. A consumer drops it in alongside `ofetch` +
|
|
81
|
+
`@cfworker/json-schema`:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { createClient } from "./suluk-sdk";
|
|
85
|
+
|
|
86
|
+
const api = createClient({
|
|
87
|
+
baseURL: "https://api.example.com",
|
|
88
|
+
token: () => localStorage.getItem("token"), // string | sync/async getter → `Authorization: Bearer …`
|
|
89
|
+
// credentials: "include" // session-cookie auth (default)
|
|
90
|
+
// validate: false // skip client-side input validation (default: on)
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// entity-grouped: CRUD by entity, custom ops by path
|
|
94
|
+
const products = await api.product.list();
|
|
95
|
+
const order = await api.checkout.order({ items: [{ productId: 1, qty: 2 }] }); // input validated before send
|
|
96
|
+
|
|
97
|
+
// v4 facets ride along as typed metadata on each method
|
|
98
|
+
api.product.create.cost; // declared cost in µ$ (number | null)
|
|
99
|
+
api.product.create.requires; // who can call it ("anyone" | "admin" | …)
|
|
100
|
+
api.product.create.input; // the Standard Schema (plugs into react-hook-form / TanStack Form / tRPC)
|
|
101
|
+
|
|
102
|
+
// client-level escape hatches + introspection
|
|
103
|
+
api.$fetch; // the raw ofetch instance
|
|
104
|
+
api.$schemas; // the contract's input JSON Schemas, as data
|
|
105
|
+
api.$manifest; // { "<entity.method>": { cost, requires, scope? } }
|
|
106
|
+
api.$meta; // { operations, totalDeclaredMicroUsd, version }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The generated SDK also exports the shared models from `components.schemas` (a TS `type` + a `…Schema`
|
|
110
|
+
Standard Schema per model, both derived from the one JSON Schema), `SulukValidationError` (thrown when an
|
|
111
|
+
input fails validation), and the `schemas` data map.
|
|
112
|
+
|
|
113
|
+
### `tsType` — the schema → TS-type mapping
|
|
114
|
+
|
|
115
|
+
The same JSON-Schema → TS-type-string function the generator uses internally is exported, in case you
|
|
116
|
+
need it standalone (e.g. building adjacent codegen):
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { tsType } from "@suluk/sdk";
|
|
120
|
+
|
|
121
|
+
tsType(doc, { type: "string" }); // "string"
|
|
122
|
+
tsType(doc, { type: "array", items: { type: "integer" } }); // "number[]"
|
|
123
|
+
tsType(doc, { type: "object", properties: { a: { type: "string" } }, required: ["a"] }); // "{ a: string }"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Reactive stores (`generateStores`) — the C037 reactive layer
|
|
127
|
+
|
|
128
|
+
`generateSdk` gives you the typed RPC calls. `generateStores(doc)` projects the **C037 reactive facet**
|
|
129
|
+
(`x-suluk-store` + `x-suluk-notify`) into a typed **Nano Stores** layer *on top of* that client — also a
|
|
130
|
+
self-contained `.ts` string. The contract declares the **policy**; the generator emits the **plumbing**; your
|
|
131
|
+
app fills the **behavior** through an unjs [`hookable`](https://unjs.io/packages/hookable) hook-bus:
|
|
132
|
+
|
|
133
|
+
- **STATES** — a query op (`x-suluk-store.key`) → a `$<key>` `@nanostores/query` fetcher store (cached by
|
|
134
|
+
`ttl`, optional `revalidateOnFocus`); a parameterized query → a `(…args) => store` factory.
|
|
135
|
+
- **EVENTS** — a mutation op (`x-suluk-store.invalidates`) → an action that invalidates the named stores on a
|
|
136
|
+
2xx (→ refetch), re-throwing so callers still `catch` (the propagation contract).
|
|
137
|
+
- **CALLBACKS** — the declared `x-suluk-notify` status→severity policy **classifies + emits**; you tap typed
|
|
138
|
+
hooks (`notify`, `request:error`, `mutation:success`, `mutation:settled`, `store:invalidate`) to render/act.
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { generateSdk, generateStores } from "@suluk/sdk";
|
|
142
|
+
const sdkSrc = generateSdk(doc, { baseURL }); // -> web/src/lib/sdk.ts
|
|
143
|
+
const storesSrc = generateStores(doc); // -> web/src/lib/stores.ts (imports SulukClient from ./sdk)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
// in the app: declare policy in the contract, inject rendering once
|
|
148
|
+
const stores = createStores(api);
|
|
149
|
+
stores.hooks.hook("notify", ({ severity, problem }) => toast[severity](problem.detail ?? problem.title ?? "Error"));
|
|
150
|
+
const { data } = useStore(stores.$paymentMethods); // STATE
|
|
151
|
+
await stores.actions.setDefaultPaymentMethod({ id }); // EVENT -> auto-invalidates $paymentMethods
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
It deliberately does **not** declare multi-call / zero-call actions, optimistic/rollback, retry, or
|
|
155
|
+
derived/normalized state — those are composition, app-config, or a typed seam, never the contract (ADR C037
|
|
156
|
+
§"Parity boundary"). The generated peer deps are `@nanostores/query`, `nanostores`, `hookable`.
|
|
157
|
+
|
|
158
|
+
> Overlap: `@suluk/nano-stores` is a *runtime* `createApiStores(RouteContract[])` that does not read the
|
|
159
|
+
> facet; `generateStores` is the **owned-source**, v4-doc + facet-driven projection (the `generateSdk` posture).
|
|
160
|
+
|
|
161
|
+
## API
|
|
162
|
+
|
|
163
|
+
| Export | What it does |
|
|
164
|
+
| --- | --- |
|
|
165
|
+
| `generateSdk(doc, opts?)` | Takes an `OpenAPIv4Document`, returns a complete self-contained SDK as a TypeScript source string. |
|
|
166
|
+
| `generateStores(doc, opts?)` | Projects the C037 reactive facet into a self-contained Nano Stores layer (states + invalidation + hookable callbacks) over the generated client. |
|
|
167
|
+
| `tsType(doc, schema, depth?)` | Maps a JSON Schema to a TypeScript type string (used for typed inputs/responses). |
|
|
168
|
+
| `resolveOps(doc)` | walkOps + deterministic collision resolution — the shared op list (so SDK + stores accessor names never drift). |
|
|
169
|
+
| `SdkOptions` / `StoresOptions` | Options — `{ baseURL? }` / `{ clientModule? }`. |
|
|
170
|
+
|
|
171
|
+
## Boundary
|
|
172
|
+
|
|
173
|
+
This is **codegen** (L3: render/generate, never host) — `generateSdk` returns a string of source the
|
|
174
|
+
consumer owns and can edit; nothing here becomes a runtime they must call home to. The package does **not**
|
|
175
|
+
fetch your contract, host a client, or enforce the facets: `.cost` / `.requires` are inert metadata + a
|
|
176
|
+
client-side guard, and the **server is the security boundary** (per ADR C022). You inject the v4 document
|
|
177
|
+
(projected by `@suluk/hono` / `@suluk/drizzle` / your own source) and the `baseURL`; serving the generated
|
|
178
|
+
file and authenticating real requests stay app-side.
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
Apache-2.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/sdk",
|
|
3
|
-
"version": "0.1
|
|
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"
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
".": "./src/index.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@suluk/core": "^0.1.
|
|
22
|
+
"@suluk/core": "^0.1.11"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"ofetch": "^1.5.1",
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generateStores — project the C037 reactive facet (`x-suluk-store` + `x-suluk-notify`) into a typed Nano Stores
|
|
3
|
+
* reactive layer ON TOP of the generated SDK client. One self-contained .ts file (the L3 codegen posture of
|
|
4
|
+
* generateSdk — owned source, no @suluk/* runtime dep), implementing the council-verified parity standard (ADR C037):
|
|
5
|
+
*
|
|
6
|
+
* • POLICY is declared, PLUMBING is emitted, BEHAVIOR is a typed seam.
|
|
7
|
+
* • STATES — a query op (`x-suluk-store.key`) becomes a `$<key>` @nanostores/query fetcher store (lazy, cached by
|
|
8
|
+
* `ttl`, optionally `revalidateOnFocus`); a parameterized query becomes a `(…args) => store` factory.
|
|
9
|
+
* • EVENTS — a mutation op (`x-suluk-store.invalidates`) invalidates the named query stores on a 2xx → refetch.
|
|
10
|
+
* • CALLBACKS — surfaced through an unjs `hookable` hook-bus (ecosystem-parity with ofetch): the DECLARED
|
|
11
|
+
* `x-suluk-notify` status→severity policy CLASSIFIES + EMITS; the app TAPS `hooks.hook(name, fn)` to render/act.
|
|
12
|
+
* `onSuccess` text rides the same `notify` hook. The renderer is injected; the policy is declared.
|
|
13
|
+
*
|
|
14
|
+
* What it deliberately does NOT do (parity boundary, ADR §"Parity boundary"): multi-call / zero-call ACTIONS (compose
|
|
15
|
+
* over the generated primitives, or a server aggregate endpoint), optimistic/rollback, retry/cache tuning, derived /
|
|
16
|
+
* normalized state — all BEHAVIOR (a hook/seam) or target-specific adapter config, never the contract.
|
|
17
|
+
*
|
|
18
|
+
* The client accessor names come from the SAME `resolveOps` generateSdk uses, so a store calls EXACTLY the method the
|
|
19
|
+
* SDK emitted (no drift). Generated peer deps: `@nanostores/query`, `nanostores`, `hookable`.
|
|
20
|
+
*/
|
|
21
|
+
import type { OpenAPIv4Document, SulukNotifyPolicy } from "@suluk/core";
|
|
22
|
+
import { resolveOps, clientAccessor, ident, camel, type OpInfo } from "./generate";
|
|
23
|
+
|
|
24
|
+
export interface StoresOptions {
|
|
25
|
+
/** Import specifier for the generated SDK module (where `SulukClient` lives). Default `"./sdk"`. */
|
|
26
|
+
clientModule?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The client TYPE index for an op (`["paymentMethods"]["list"]`), matching `clientAccessor`'s runtime path. */
|
|
30
|
+
const typeIndex = (op: OpInfo): string =>
|
|
31
|
+
(op.ns.length ? [op.ns[op.ns.length - 1]!, op.member] : [op.member]).map((p) => `["${ident(p!)}"]`).join("");
|
|
32
|
+
|
|
33
|
+
/** A store/action whose SDK method takes arguments (path params / query / body) → a parameterized factory. */
|
|
34
|
+
const argful = (op: OpInfo): boolean => op.pathParams.length > 0 || op.queryRaw != null || op.bodyRaw != null;
|
|
35
|
+
|
|
36
|
+
export function generateStores(doc: OpenAPIv4Document, opts: StoresOptions = {}): string {
|
|
37
|
+
const { ops } = resolveOps(doc);
|
|
38
|
+
const clientModule = opts.clientModule ?? "./sdk";
|
|
39
|
+
const title = doc.info?.title ?? "API";
|
|
40
|
+
const notify = (doc as { ["x-suluk-notify"]?: SulukNotifyPolicy })["x-suluk-notify"] ?? {};
|
|
41
|
+
|
|
42
|
+
const queries = ops.filter((o) => o.store?.key);
|
|
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));
|
|
45
|
+
|
|
46
|
+
// ── STATES: a $<key> fetcher store (or a (…args)=>store factory) per query op ──
|
|
47
|
+
const queryDecls = queries
|
|
48
|
+
.map((op) => {
|
|
49
|
+
const key = op.store!.key!;
|
|
50
|
+
const v = "$" + ident(key);
|
|
51
|
+
const acc = clientAccessor(op);
|
|
52
|
+
const T = `Awaited<ReturnType<SulukClient${typeIndex(op)}>>`;
|
|
53
|
+
const settings: string[] = [];
|
|
54
|
+
if (op.store!.ttl != null) settings.push(`cacheLifetime: ${Math.round(op.store!.ttl * 1000)}`);
|
|
55
|
+
if (op.store!.revalidateOnFocus) settings.push(`revalidateOnFocus: true`);
|
|
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.
|
|
59
|
+
if (argful(op)) {
|
|
60
|
+
return (
|
|
61
|
+
` const ${v} = (...args: Parameters<SulukClient${typeIndex(op)}>) =>\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` +
|
|
64
|
+
` });`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return (
|
|
68
|
+
` const ${v} = createFetcherStore<${T}>([${JSON.stringify("@" + key)}], {\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` +
|
|
70
|
+
` });`
|
|
71
|
+
);
|
|
72
|
+
})
|
|
73
|
+
.join("\n");
|
|
74
|
+
|
|
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"). ──
|
|
80
|
+
const invalDecls = queries
|
|
81
|
+
.map((op) => {
|
|
82
|
+
const key = op.store!.key!;
|
|
83
|
+
const v = "$" + ident(key);
|
|
84
|
+
const body = argful(op)
|
|
85
|
+
? `ctx.revalidateKeys((k) => typeof k === "string" && k.startsWith(${JSON.stringify("@" + key + "\u0000")}))`
|
|
86
|
+
: `${v}.revalidate()`;
|
|
87
|
+
return ` ${JSON.stringify(key)}: () => { void hooks.callHook("store:invalidate", { store: ${JSON.stringify(key)} }); ${body}; },`;
|
|
88
|
+
})
|
|
89
|
+
.join("\n");
|
|
90
|
+
|
|
91
|
+
// ── EVENTS + CALLBACKS: a wrapped action per mutation op (invalidate named stores on 2xx; surface onSuccess; route
|
|
92
|
+
// errors through the notify policy; ALWAYS re-throw so callers still catch — the propagation contract). ──
|
|
93
|
+
const actionDecls = mutations
|
|
94
|
+
.map((op) => {
|
|
95
|
+
const name = ident(camel(op.name));
|
|
96
|
+
const acc = clientAccessor(op);
|
|
97
|
+
const inv = op.store!.invalidates ?? [];
|
|
98
|
+
const invCalls = inv.length ? inv.map((k) => `_invalidate[${JSON.stringify(k)}]?.();`).join(" ") : "/* no stores to invalidate */";
|
|
99
|
+
const successHook = op.store!.onSuccess
|
|
100
|
+
? `\n await hooks.callHook("notify", { severity: "success", problem: { status: 200, detail: ${JSON.stringify(op.store!.onSuccess)} } });`
|
|
101
|
+
: "";
|
|
102
|
+
return (
|
|
103
|
+
` async function ${name}(...args: Parameters<SulukClient${typeIndex(op)}>) {\n` +
|
|
104
|
+
` try {\n` +
|
|
105
|
+
` const r = await client.${acc}(...args);\n` +
|
|
106
|
+
` ${invCalls}\n` +
|
|
107
|
+
` await hooks.callHook("mutation:success", { op: ${JSON.stringify(name)}, invalidated: ${JSON.stringify(inv)} });${successHook}\n` +
|
|
108
|
+
` return r;\n` +
|
|
109
|
+
` } catch (e) {\n` +
|
|
110
|
+
` await report(${JSON.stringify(name)}, e);\n` +
|
|
111
|
+
` throw e;\n` +
|
|
112
|
+
` } finally {\n` +
|
|
113
|
+
` await hooks.callHook("mutation:settled", { op: ${JSON.stringify(name)} });\n` +
|
|
114
|
+
` }\n` +
|
|
115
|
+
` }`
|
|
116
|
+
);
|
|
117
|
+
})
|
|
118
|
+
.join("\n");
|
|
119
|
+
|
|
120
|
+
const queryNames = queries.map((op) => "$" + ident(op.store!.key!));
|
|
121
|
+
const actionNames = mutations.map((op) => ident(camel(op.name)));
|
|
122
|
+
const ret =
|
|
123
|
+
` return { ` +
|
|
124
|
+
(queryNames.length ? queryNames.join(", ") + ", " : "") +
|
|
125
|
+
`actions: { ${actionNames.join(", ")} }, report, hooks, ctx };`;
|
|
126
|
+
|
|
127
|
+
return `/**
|
|
128
|
+
* ${title} — reactive Nano Stores layer. AUTO-GENERATED by @suluk/sdk (generateStores) from the v4 contract. Do not edit.
|
|
129
|
+
*
|
|
130
|
+
* Built from the C037 reactive facet: ${queries.length} query store(s), ${mutations.length} mutation action(s). STATES are
|
|
131
|
+
* @nanostores/query fetcher stores; EVENTS are mutation→store invalidations; CALLBACKS surface through an unjs hookable
|
|
132
|
+
* hook-bus (the declared x-suluk-notify policy classifies + emits; YOU tap hooks.hook(name, fn) to render/act). The
|
|
133
|
+
* client accessors match the generated SDK exactly (one resolveOps source). Self-contained: no @suluk/* runtime dep.
|
|
134
|
+
*
|
|
135
|
+
* import { createClient } from "${clientModule}";
|
|
136
|
+
* import { createStores } from "./stores";
|
|
137
|
+
* import { toast } from "sonner";
|
|
138
|
+
* const api = createClient({ baseURL: "…" });
|
|
139
|
+
* const stores = createStores(api);
|
|
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
|
+
* });
|
|
144
|
+
* // component: const { data } = useStore(stores.$paymentMethods); action: await stores.actions.setDefaultPaymentMethod({ id });
|
|
145
|
+
*
|
|
146
|
+
* Requires: \`npm i @nanostores/query nanostores hookable\`.
|
|
147
|
+
*/
|
|
148
|
+
import { nanoquery } from "@nanostores/query";
|
|
149
|
+
import { createHooks, type Hookable } from "hookable";
|
|
150
|
+
import type { SulukClient } from "${clientModule}";
|
|
151
|
+
|
|
152
|
+
/** How loudly a response surfaces (the x-suluk-notify severities). */
|
|
153
|
+
export type NotifySeverity = "silent" | "info" | "success" | "warn" | "error";
|
|
154
|
+
/** An RFC-9457-ish problem surfaced to hooks (the parsed error body + its status). */
|
|
155
|
+
export interface StoreProblem {
|
|
156
|
+
status: number | "network";
|
|
157
|
+
title?: string;
|
|
158
|
+
detail?: string;
|
|
159
|
+
raw?: unknown;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The typed hook bus (unjs hookable). Tap these to render/act — the BEHAVIOR seam (the contract declares POLICY only). */
|
|
163
|
+
export interface StoreHooks {
|
|
164
|
+
/** the policy decided this response should surface — render it (e.g. a toast). */
|
|
165
|
+
notify: (e: { severity: NotifySeverity; problem: StoreProblem }) => void | Promise<void>;
|
|
166
|
+
/** a query/action errored (fires for EVERY error, even silent ones — for logging). */
|
|
167
|
+
"request:error": (e: { op: string; severity: NotifySeverity; problem: StoreProblem }) => void | Promise<void>;
|
|
168
|
+
/** a mutation action succeeded (2xx) and invalidated its stores. */
|
|
169
|
+
"mutation:success": (e: { op: string; invalidated: string[] }) => void | Promise<void>;
|
|
170
|
+
/** a mutation action settled (success or error). */
|
|
171
|
+
"mutation:settled": (e: { op: string }) => void | Promise<void>;
|
|
172
|
+
/** a query store was invalidated (about to refetch). */
|
|
173
|
+
"store:invalidate": (e: { store: string }) => void | Promise<void>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The DECLARED status→severity policy (x-suluk-notify). Keys: a status ("402"), a class ("2xx"/"4xx"/"5xx"), or "network". */
|
|
177
|
+
const NOTIFY: Record<string, NotifySeverity> = ${JSON.stringify(notify)};
|
|
178
|
+
|
|
179
|
+
/** Classify a status to a severity: exact status ("402" / "network") > class (Nxx) > "silent". */
|
|
180
|
+
function classify(status: number | "network"): NotifySeverity {
|
|
181
|
+
const k = String(status); // exact key — covers a numeric status AND "network"
|
|
182
|
+
if (NOTIFY[k]) return NOTIFY[k]!;
|
|
183
|
+
if (typeof status === "number") {
|
|
184
|
+
const cls = Math.floor(status / 100) + "xx";
|
|
185
|
+
if (NOTIFY[cls]) return NOTIFY[cls]!;
|
|
186
|
+
}
|
|
187
|
+
return "silent";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Extract an RFC-9457 problem from an ofetch error (status + parsed body). */
|
|
191
|
+
function problemOf(e: unknown): StoreProblem {
|
|
192
|
+
const err = (e ?? {}) as { status?: number; statusCode?: number; response?: { status?: number; _data?: unknown }; data?: { title?: string; detail?: string } };
|
|
193
|
+
const raw = (err.response?.status ?? err.status ?? err.statusCode);
|
|
194
|
+
const status: number | "network" = typeof raw === "number" ? raw : "network";
|
|
195
|
+
const data = (err.data ?? (err.response?._data as { title?: string; detail?: string } | undefined)) ?? undefined;
|
|
196
|
+
return { status, title: data?.title, detail: data?.detail ?? (e instanceof Error ? e.message : undefined), raw: data };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface CreateStoresOptions {
|
|
200
|
+
/** Bring your own hook bus (e.g. to share one across modules). Defaults to a fresh \`createHooks<StoreHooks>()\`. */
|
|
201
|
+
hooks?: Hookable<StoreHooks>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Create the reactive store layer for ${title}, bound to an SDK client. The contract declares the policy; you inject the rendering via \`hooks\`. */
|
|
205
|
+
export function createStores(client: SulukClient, options: CreateStoresOptions = {}) {
|
|
206
|
+
const hooks = options.hooks ?? createHooks<StoreHooks>();
|
|
207
|
+
const [createFetcherStore, , ctx] = nanoquery();
|
|
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> {
|
|
216
|
+
const problem = problemOf(e);
|
|
217
|
+
const severity = classify(problem.status);
|
|
218
|
+
await hooks.callHook("request:error", { op, 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 });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
${queryDecls || " // (no query stores declared)"}
|
|
226
|
+
|
|
227
|
+
const _invalidate: Record<string, () => void> = {
|
|
228
|
+
${invalDecls}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
${actionDecls || " // (no mutation actions declared)"}
|
|
232
|
+
|
|
233
|
+
${ret}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The reactive store layer's shape (stores + actions + hooks + the @nanostores/query ctx). */
|
|
237
|
+
export type SulukStores = ReturnType<typeof createStores>;
|
|
238
|
+
`;
|
|
239
|
+
}
|
package/src/generate.ts
CHANGED
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
* (the Standard Schema). Metadata + a client-side guard, not enforcement — the server is the boundary (C022).
|
|
12
12
|
* Static TS types come from the SAME JSON Schema (tsType), so the body is typed AND validated from one source.
|
|
13
13
|
*/
|
|
14
|
-
import type { OpenAPIv4Document } from "@suluk/core";
|
|
14
|
+
import type { OpenAPIv4Document, SulukStore } from "@suluk/core";
|
|
15
15
|
import { isReference } from "@suluk/core";
|
|
16
16
|
|
|
17
17
|
const reserved = new Set(["delete", "new", "function", "default", "return", "class", "in", "for"]);
|
|
18
|
-
const ident = (s: string) => { const c = s.replace(/[^a-zA-Z0-9_$]/g, "_").replace(/^[0-9]/, "_$&"); return reserved.has(c) ? `${c}_` : c; };
|
|
19
|
-
const camel = (s: string) => s.replace(/[-_/]+(.)/g, (_, c) => c.toUpperCase()).replace(/[^a-zA-Z0-9_$]/g, "");
|
|
18
|
+
export const ident = (s: string) => { const c = s.replace(/[^a-zA-Z0-9_$]/g, "_").replace(/^[0-9]/, "_$&"); return reserved.has(c) ? `${c}_` : c; };
|
|
19
|
+
export const camel = (s: string) => s.replace(/[-_/]+(.)/g, (_, c) => c.toUpperCase()).replace(/[^a-zA-Z0-9_$]/g, "");
|
|
20
20
|
const jsKey = (k: string) => (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(k) ? k : JSON.stringify(k));
|
|
21
21
|
const refName = (r: unknown) => (isReference(r) ? String((r as { $ref: string }).$ref).split("/").pop()! : null);
|
|
22
22
|
|
|
@@ -49,11 +49,13 @@ interface RawReq {
|
|
|
49
49
|
responses?: Record<string, { status: string | number; contentSchema?: unknown }>;
|
|
50
50
|
["x-suluk-cost"]?: { estimateMicroUsd?: number; components?: { microUsd?: number }[] };
|
|
51
51
|
["x-suluk-access"]?: { requires?: string; scope?: string };
|
|
52
|
+
["x-suluk-store"]?: SulukStore;
|
|
52
53
|
}
|
|
53
|
-
interface OpInfo {
|
|
54
|
+
export interface OpInfo {
|
|
54
55
|
name: string; ns: string[]; member: string; method: string; uri: string;
|
|
55
56
|
pathParams: string[]; queryRaw?: unknown; bodyRaw?: unknown; respType: string;
|
|
56
57
|
cost: number | null; requires: string; scope?: string; summary?: string;
|
|
58
|
+
store?: SulukStore; // the C037 reactive facet (read by generateStores, not generateSdk)
|
|
57
59
|
bid?: string; qid?: string; bodyTs?: string; queryTs?: string; // assigned after collision resolution
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -82,12 +84,42 @@ function walkOps(doc: OpenAPIv4Document): OpInfo[] {
|
|
|
82
84
|
name, ns, member, method: req.method.toLowerCase(), uri, pathParams: pathVars(uri),
|
|
83
85
|
queryRaw: ps.query, bodyRaw: req.contentSchema ?? ps.body, respType: respType(doc, req),
|
|
84
86
|
cost: costOf(req), requires: acc?.requires ?? "anyone", scope: acc?.scope, summary: req.summary,
|
|
87
|
+
store: req["x-suluk-store"],
|
|
85
88
|
});
|
|
86
89
|
}
|
|
87
90
|
}
|
|
88
91
|
return ops;
|
|
89
92
|
}
|
|
90
93
|
|
|
94
|
+
/**
|
|
95
|
+
* walkOps + DETERMINISTIC method-name collision resolution — SHARED by generateSdk AND generateStores so the client
|
|
96
|
+
* accessor names (`client.<ns>.<member>`) can NEVER drift between the two projections. Mutates `op.member` in place;
|
|
97
|
+
* returns the resolved ops + the human-readable collision list (for the SDK header). One source of accessor identity.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveOps(doc: OpenAPIv4Document): { ops: OpInfo[]; collisions: string[] } {
|
|
100
|
+
const ops = walkOps(doc);
|
|
101
|
+
const collisions: string[] = [];
|
|
102
|
+
const byKey = new Map<string, OpInfo[]>();
|
|
103
|
+
for (const op of ops) { const k = [...op.ns, op.member].join("."); (byKey.get(k) ?? byKey.set(k, []).get(k)!).push(op); }
|
|
104
|
+
for (const [key, list] of byKey) {
|
|
105
|
+
if (list.length < 2) continue;
|
|
106
|
+
collisions.push(`client.${key} ← ${list.map((o) => o.name).join(", ")}`);
|
|
107
|
+
const used = new Map<string, number>();
|
|
108
|
+
for (const op of [...list].sort((a, b) => a.name.localeCompare(b.name) || a.method.localeCompare(b.method))) {
|
|
109
|
+
const cand = op.member + op.method.charAt(0).toUpperCase() + op.method.slice(1);
|
|
110
|
+
const n = used.get(cand) ?? 0; used.set(cand, n + 1);
|
|
111
|
+
op.member = n === 0 ? cand : `${cand}${n + 1}`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { ops, collisions };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The client accessor for an op — the dotted path AFTER `client.` (e.g. `paymentMethods.list`). Matches emitTree's
|
|
118
|
+
* by-last-namespace-segment grouping, so `createStores` calls EXACTLY the method `generateSdk` emitted. */
|
|
119
|
+
export function clientAccessor(op: OpInfo): string {
|
|
120
|
+
return op.ns.length ? `${ident(op.ns[op.ns.length - 1]!)}.${ident(op.member)}` : ident(op.member);
|
|
121
|
+
}
|
|
122
|
+
|
|
91
123
|
function emitMethod(op: OpInfo): string {
|
|
92
124
|
const args: string[] = op.pathParams.map((p) => `${ident(p)}: string | number`);
|
|
93
125
|
if (op.qid) args.push(`query?: ${op.queryTs}`);
|
|
@@ -113,22 +145,9 @@ function emitTree(ops: OpInfo[]): string {
|
|
|
113
145
|
export interface SdkOptions { baseURL?: string }
|
|
114
146
|
|
|
115
147
|
export function generateSdk(doc: OpenAPIv4Document, opts: SdkOptions = {}): string {
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
const collisions: string[] = [];
|
|
120
|
-
const byKey = new Map<string, OpInfo[]>();
|
|
121
|
-
for (const op of ops) { const k = [...op.ns, op.member].join("."); (byKey.get(k) ?? byKey.set(k, []).get(k)!).push(op); }
|
|
122
|
-
for (const [key, list] of byKey) {
|
|
123
|
-
if (list.length < 2) continue;
|
|
124
|
-
collisions.push(`client.${key} ← ${list.map((o) => o.name).join(", ")}`);
|
|
125
|
-
const used = new Map<string, number>();
|
|
126
|
-
for (const op of [...list].sort((a, b) => a.name.localeCompare(b.name) || a.method.localeCompare(b.method))) {
|
|
127
|
-
const cand = op.member + op.method.charAt(0).toUpperCase() + op.method.slice(1);
|
|
128
|
-
const n = used.get(cand) ?? 0; used.set(cand, n + 1);
|
|
129
|
-
op.member = n === 0 ? cand : `${cand}${n + 1}`;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
148
|
+
// resolveOps does walkOps + DETERMINISTIC collision resolution (council wf4pmh1ie: never a runtime guess), shared
|
|
149
|
+
// with generateStores so accessor names can't drift.
|
|
150
|
+
const { ops, collisions } = resolveOps(doc);
|
|
132
151
|
|
|
133
152
|
// Emit a JSON Schema AS A LITERAL. When it $refs components, splice them in as $defs and rewrite the pointers,
|
|
134
153
|
// so each validator is self-contained (the generic engine resolves refs without the whole document).
|
package/src/index.ts
CHANGED
|
@@ -6,4 +6,13 @@
|
|
|
6
6
|
* import { generateSdk } from "@suluk/sdk";
|
|
7
7
|
* const tsSource = generateSdk(v4Document, { baseURL: "https://api.example.com" }); // a self-contained .ts file
|
|
8
8
|
*/
|
|
9
|
-
export { generateSdk, tsType, type SdkOptions } from "./generate";
|
|
9
|
+
export { generateSdk, tsType, resolveOps, clientAccessor, type SdkOptions, type OpInfo } from "./generate";
|
|
10
|
+
/**
|
|
11
|
+
* generateStores(doc) — project the C037 reactive facet (`x-suluk-store` + `x-suluk-notify`) into a typed Nano Stores
|
|
12
|
+
* reactive layer (states + mutation→store invalidation + a hookable callback seam) on top of the generated client.
|
|
13
|
+
*
|
|
14
|
+
* import { generateSdk, generateStores } from "@suluk/sdk";
|
|
15
|
+
* const sdk = generateSdk(doc, { baseURL }); // the typed RPC client
|
|
16
|
+
* const stores = generateStores(doc); // the reactive layer over it (a self-contained .ts file)
|
|
17
|
+
*/
|
|
18
|
+
export { generateStores, type StoresOptions } from "./generate-stores";
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { generateStores } from "../src/index";
|
|
3
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
4
|
+
|
|
5
|
+
// A contract carrying the C037 reactive facet: a plain query store (session), a cached query store (paymentMethods),
|
|
6
|
+
// a parameterized query store (pet/{id} → a factory), a mutation that invalidates + onSuccess, and a doc notify policy.
|
|
7
|
+
const doc = {
|
|
8
|
+
openapi: "4.0.0-candidate",
|
|
9
|
+
info: { title: "Store API" },
|
|
10
|
+
"x-suluk-notify": { "2xx": "silent", "401": "silent", "402": "error", "4xx": "warn", "5xx": "error", network: "error" },
|
|
11
|
+
paths: {
|
|
12
|
+
session: { requests: { getSession: { method: "get", responses: { ok: { status: 200 } }, "x-suluk-store": { key: "session", ttl: 300, revalidateOnFocus: true } } } },
|
|
13
|
+
paymentMethods: { requests: { listPaymentMethods: { method: "get", responses: { ok: { status: 200, contentSchema: { type: "object", properties: { methods: { type: "array", items: { type: "object", properties: { id: { type: "string" } } } } } } } }, "x-suluk-store": { key: "paymentMethods", ttl: 60 } } } },
|
|
14
|
+
"billing/methods/default": { requests: { setDefaultPaymentMethod: { method: "post", contentSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, responses: { ok: { status: 200 } }, "x-suluk-store": { invalidates: ["paymentMethods"], onSuccess: "Default card updated." } } } },
|
|
15
|
+
"pet/{id}": { requests: { getPet: { method: "get", responses: { ok: { status: 200 } }, "x-suluk-store": { key: "pet", params: ["id"] } } } },
|
|
16
|
+
},
|
|
17
|
+
} as unknown as OpenAPIv4Document;
|
|
18
|
+
|
|
19
|
+
describe("@suluk/sdk generateStores — a typed Nano Stores reactive layer from the C037 facet", () => {
|
|
20
|
+
const stores = generateStores(doc);
|
|
21
|
+
|
|
22
|
+
test("emits a self-contained @nanostores/query + hookable layer over the SDK client", () => {
|
|
23
|
+
expect(stores).toContain('import { nanoquery } from "@nanostores/query"');
|
|
24
|
+
expect(stores).toContain('import { createHooks, type Hookable } from "hookable"');
|
|
25
|
+
expect(stores).toContain('import type { SulukClient } from "./sdk"'); // client TYPE only — self-contained
|
|
26
|
+
expect(stores).toContain("export function createStores(client: SulukClient");
|
|
27
|
+
expect(stores).toContain("const [createFetcherStore, , ctx] = nanoquery()");
|
|
28
|
+
expect(stores).toContain("Requires: `npm i @nanostores/query nanostores hookable`");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("STATES — a $<key> fetcher store per query op, typed off the client, with ttl/focus settings", () => {
|
|
32
|
+
expect(stores).toContain('const $session = createFetcherStore<Awaited<ReturnType<SulukClient["session"]["get"]>>>(["@session"]');
|
|
33
|
+
expect(stores).toContain("cacheLifetime: 300000"); // ttl seconds → ms
|
|
34
|
+
expect(stores).toContain("revalidateOnFocus: true");
|
|
35
|
+
expect(stores).toContain("client.session.get()"); // calls the EXACT SDK accessor (shared resolveOps)
|
|
36
|
+
expect(stores).toContain('const $paymentMethods = createFetcherStore<Awaited<ReturnType<SulukClient["paymentMethods"]["list"]>>>(["@paymentMethods"]');
|
|
37
|
+
expect(stores).toContain("cacheLifetime: 60000");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a parameterized query (path param) becomes a (…args)=>store factory keyed by the args (delimited prefix)", () => {
|
|
41
|
+
expect(stores).toContain('const $pet = (...args: Parameters<SulukClient["pet"]["get"]>) =>');
|
|
42
|
+
expect(stores).toContain("JSON.stringify(args)], {"); // parameterized query store keyed by its args
|
|
43
|
+
expect(stores).toContain("client.pet.get(...args)");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("EVENTS — a mutation action invalidates the named stores on 2xx + fires mutation:success", () => {
|
|
47
|
+
expect(stores).toContain('async function setDefaultPaymentMethod(...args: Parameters<SulukClient["methods"]["default_"]>)');
|
|
48
|
+
expect(stores).toContain("const r = await client.methods.default_(...args);"); // matches the real SDK accessor
|
|
49
|
+
expect(stores).toContain('_invalidate["paymentMethods"]?.();');
|
|
50
|
+
expect(stores).toContain('await hooks.callHook("mutation:success", { op: "setDefaultPaymentMethod", invalidated: ["paymentMethods"] });');
|
|
51
|
+
expect(stores).toContain("throw e;"); // propagation contract — re-throw after the error seam
|
|
52
|
+
expect(stores).toContain('await hooks.callHook("mutation:settled"');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("CALLBACKS — onSuccess rides the notify hook; the x-suluk-notify policy is compiled + classified", () => {
|
|
56
|
+
expect(stores).toContain('detail: "Default card updated."');
|
|
57
|
+
expect(stores).toContain("const NOTIFY: Record<string, NotifySeverity> = {"); // the policy compiled to a data map
|
|
58
|
+
expect(stores).toContain('"402":"error"');
|
|
59
|
+
expect(stores).toContain('"network":"error"');
|
|
60
|
+
expect(stores).toContain('"2xx":"silent"');
|
|
61
|
+
expect(stores).toContain("function classify(status: number | \"network\"): NotifySeverity");
|
|
62
|
+
expect(stores).toContain('if (severity === "silent") return;'); // policy decides; renderer injected
|
|
63
|
+
expect(stores).toContain("export interface StoreHooks");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("invalidators — REVALIDATE (keep cache visible): exact .revalidate() for plain stores; delimited-prefix for families", () => {
|
|
67
|
+
expect(stores).toContain('"session": () => {');
|
|
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
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("returns the stores + actions + hooks + ctx", () => {
|
|
80
|
+
expect(stores).toContain("return { $session, $paymentMethods, $pet, actions: { setDefaultPaymentMethod }, report, hooks, ctx };");
|
|
81
|
+
expect(stores).toContain("export type SulukStores = ReturnType<typeof createStores>");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("clientModule option redirects the type import", () => {
|
|
85
|
+
expect(generateStores(doc, { clientModule: "../lib/sdk" })).toContain('import type { SulukClient } from "../lib/sdk"');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("the emitted source is syntactically valid TypeScript (transpiles clean)", () => {
|
|
89
|
+
expect(() => new Bun.Transpiler({ loader: "tsx" }).transformSync(stores)).not.toThrow();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("declares nothing for a contract with no reactive facet (no stores/actions)", () => {
|
|
93
|
+
const bare = generateStores({ openapi: "4.0.0-candidate", info: { title: "Bare" }, paths: { ping: { requests: { getPing: { method: "get", responses: { ok: { status: 200 } } } } } } } as unknown as OpenAPIv4Document);
|
|
94
|
+
expect(bare).toContain("// (no query stores declared)");
|
|
95
|
+
expect(bare).toContain("// (no mutation actions declared)");
|
|
96
|
+
expect(bare).toContain("return { actions: { }, report, hooks, ctx };");
|
|
97
|
+
expect(() => new Bun.Transpiler({ loader: "tsx" }).transformSync(bare)).not.toThrow();
|
|
98
|
+
});
|
|
99
|
+
});
|