@suluk/sdk 0.2.1 → 0.3.0

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.1",
3
+ "version": "0.3.0",
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,8 @@
19
19
  ".": "./src/index.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@suluk/core": "^0.1.11"
22
+ "@suluk/core": "^0.1.13",
23
+ "@suluk/examples": "^0.1.0"
23
24
  },
24
25
  "peerDependencies": {
25
26
  "ofetch": "^1.5.1",
@@ -204,7 +204,19 @@ export interface CreateStoresOptions {
204
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
205
  export function createStores(client: SulukClient, options: CreateStoresOptions = {}) {
206
206
  const hooks = options.hooks ?? createHooks<StoreHooks>();
207
- const [createFetcherStore, , ctx] = nanoquery();
207
+ // A bounded cache: @nanostores/query's default cache (a plain Map) is never evicted (cacheLifetime only gates a HIT),
208
+ // so a parameterized store driven by free text (a search box) would grow it for the page's lifetime. Cap it — evict
209
+ // the oldest entry past the limit (LRU-ish). The entry shape matches @nanostores/query's so the type stays exact.
210
+ const cache = new Map<string, { data?: unknown; error?: unknown; retryCount?: number; created?: number; expires?: number }>();
211
+ const _set = cache.set.bind(cache);
212
+ cache.set = (k, v) => {
213
+ if (!cache.has(k) && cache.size >= 500) {
214
+ const oldest = cache.keys().next().value;
215
+ if (oldest !== undefined) cache.delete(oldest);
216
+ }
217
+ return _set(k, v);
218
+ };
219
+ const [createFetcherStore, , ctx] = nanoquery({ cache });
208
220
  /** last surfaced status per op — so an AUTO re-run of a failing query (retry-backoff / revalidate-on-focus) doesn't
209
221
  * re-toast the SAME failure. A query clears its entry on success; user-triggered actions/one-offs pass dedupe=false. */
210
222
  const _seen = new Map<string, number | "network">();
package/src/generate.ts CHANGED
@@ -8,11 +8,14 @@
8
8
  * • an ofetch-based createClient(config) factory — auth wired via an onRequest interceptor (bearer / cookie)
9
9
  * • methods grouped intuitively: CRUD by entity (client.product.create), custom ops by path (client.checkout.order)
10
10
  * • the v4 SUPERPOWERS surfaced as TYPED METADATA on each method: `.cost` (µ$), `.requires` (access), `.input`
11
- * (the Standard Schema). Metadata + a client-side guard, not enforcement the server is the boundary (C022).
11
+ * (the Standard Schema), `.fields` (C041 per-input origin: input/sourced/computed + a wireable source edge so a
12
+ * caller knows which inputs to faker, which to chain from a prior call, and which are server-set). Metadata + a
13
+ * client-side guard, not enforcement — the server is the boundary (C022).
12
14
  * Static TS types come from the SAME JSON Schema (tsType), so the body is typed AND validated from one source.
13
15
  */
14
16
  import type { OpenAPIv4Document, SulukStore } from "@suluk/core";
15
17
  import { isReference } from "@suluk/core";
18
+ import { describeInputs, type FieldDescriptor } from "@suluk/examples";
16
19
 
17
20
  const reserved = new Set(["delete", "new", "function", "default", "return", "class", "in", "for"]);
18
21
  export const ident = (s: string) => { const c = s.replace(/[^a-zA-Z0-9_$]/g, "_").replace(/^[0-9]/, "_$&"); return reserved.has(c) ? `${c}_` : c; };
@@ -56,6 +59,7 @@ export interface OpInfo {
56
59
  pathParams: string[]; queryRaw?: unknown; bodyRaw?: unknown; respType: string;
57
60
  cost: number | null; requires: string; scope?: string; summary?: string;
58
61
  store?: SulukStore; // the C037 reactive facet (read by generateStores, not generateSdk)
62
+ fields?: FieldDescriptor[]; // C041 per-input field origin (input/sourced/computed + wireable source edge)
59
63
  bid?: string; qid?: string; bodyTs?: string; queryTs?: string; // assigned after collision resolution
60
64
  }
61
65
 
@@ -80,11 +84,14 @@ function walkOps(doc: OpenAPIv4Document): OpInfo[] {
80
84
  else { const segs = uri.split("/").filter((x) => x && !x.startsWith("{")); ns = segs.slice(0, -1).map(camel); member = camel(segs[segs.length - 1] ?? name); }
81
85
  const ps = req.parameterSchema ?? {};
82
86
  const acc = req["x-suluk-access"];
87
+ const bodyRaw = req.contentSchema ?? ps.body;
88
+ const fields = describeInputs(bodyRaw as Record<string, unknown> | undefined);
83
89
  ops.push({
84
90
  name, ns, member, method: req.method.toLowerCase(), uri, pathParams: pathVars(uri),
85
- queryRaw: ps.query, bodyRaw: req.contentSchema ?? ps.body, respType: respType(doc, req),
91
+ queryRaw: ps.query, bodyRaw, respType: respType(doc, req),
86
92
  cost: costOf(req), requires: acc?.requires ?? "anyone", scope: acc?.scope, summary: req.summary,
87
93
  store: req["x-suluk-store"],
94
+ fields: fields.length ? fields : undefined,
88
95
  });
89
96
  }
90
97
  }
@@ -128,7 +135,8 @@ function emitMethod(op: OpInfo): string {
128
135
  const opts = [`method: "${op.method.toUpperCase()}"`];
129
136
  if (op.bid) opts.push(`body: _v ? parse(${op.bid}, body) : body`);
130
137
  if (op.qid) opts.push(`query: _v && query ? parse(${op.qid}, query) : query`);
131
- const meta = `{ cost: ${op.cost ?? "null"}, requires: ${JSON.stringify(op.requires)}${op.scope ? `, scope: ${JSON.stringify(op.scope)}` : ""}${op.bid ? `, input: ${op.bid}` : ""} }`;
138
+ const fieldsMeta = op.fields?.length ? `, fields: ${JSON.stringify(op.fields)}` : "";
139
+ const meta = `{ cost: ${op.cost ?? "null"}, requires: ${JSON.stringify(op.requires)}${op.scope ? `, scope: ${JSON.stringify(op.scope)}` : ""}${op.bid ? `, input: ${op.bid}` : ""}${fieldsMeta} }`;
132
140
  const doc = op.summary ? ` /** ${op.summary.replace(/\*\//g, "*\\/")} — ${op.requires}${op.cost != null ? ` · ⛁ ${op.cost}µ$` : ""} */\n` : "";
133
141
  return `${doc} ${ident(op.member)}: Object.assign(\n (${args.join(", ")}) => api<${op.respType}>(${url}, { ${opts.join(", ")} }),\n ${meta},\n )`;
134
142
  }
@@ -177,6 +185,7 @@ export function generateSdk(doc: OpenAPIv4Document, opts: SdkOptions = {}): stri
177
185
  inputDecls.push(`const ${op.qid} = std<${op.queryTs}>(schemas.${ident(base + "_q")});`);
178
186
  }
179
187
  }
188
+ // $manifest stays the LEAN facet index (cost/requires/scope); the full per-input origin lives on each method's `.fields`.
180
189
  const manifest = Object.fromEntries(ops.map((o) => [[...o.ns, o.member].join("."), { cost: o.cost, requires: o.requires, ...(o.scope ? { scope: o.scope } : {}) }]));
181
190
  const title = doc.info?.title ?? "API";
182
191
  const version = doc.openapi ?? "4.0.0-candidate";
@@ -192,7 +201,8 @@ export function generateSdk(doc: OpenAPIv4Document, opts: SdkOptions = {}): stri
192
201
  * single validator's source — so what runs is exactly what the contract stores (lossless). Each input is a STANDARD
193
202
  * SCHEMA (\`.input\`), so it drops into react-hook-form / TanStack Form / tRPC unchanged. Auth is wired via an
194
203
  * interceptor; every method carries the v4 facets as typed metadata — \`.cost\` (µ$), \`.requires\` (who can call it),
195
- * \`.input\` (the Standard Schema). Those are HINTS + a client-side guard, not enforcement the server is the
204
+ * \`.input\` (the Standard Schema), and \`.fields\` (per-input origin: input/sourced/computed + wireable source edges).
205
+ * Those are HINTS + a client-side guard, not enforcement — the server is the
196
206
  * security boundary.${collisions.length ? `\n * Namespaced ${collisions.length} method collision(s) (a v4 multi-request-per-method capability): ${collisions.join("; ")}.` : ""}
197
207
  *
198
208
  * import { createClient } from "./suluk-sdk";
@@ -0,0 +1,71 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { generateSdk, resolveOps } from "../src/index";
3
+ import type { OpenAPIv4Document } from "@suluk/core";
4
+
5
+ /**
6
+ * C041 — the SDK generator surfaces field-origin as typed metadata (`.fields`) so a caller knows which inputs to faker
7
+ * (`input`), which to CHAIN from a prior call (`sourced`, with a wireable `{op, select}` edge), and which are server-set
8
+ * (`computed`). Read from `@suluk/examples` (the shared leaf), not re-derived. The marker rides the request body schema.
9
+ */
10
+ const doc = {
11
+ openapi: "4.0.0-candidate",
12
+ info: { title: "Billing API" },
13
+ paths: {
14
+ "billing/charge": {
15
+ requests: {
16
+ charge: {
17
+ method: "post",
18
+ contentSchema: {
19
+ type: "object",
20
+ required: ["amountCents", "subscriptionId"],
21
+ properties: {
22
+ amountCents: { type: "integer", minimum: 100, "x-suluk-origin": "input" },
23
+ subscriptionId: { type: "string", "x-suluk-origin": "sourced", "x-suluk-from": { op: "createSubscription", select: "id" } },
24
+ total: { type: "number", "x-suluk-origin": "computed", "x-suluk-from": "amountCents + fees" },
25
+ },
26
+ },
27
+ responses: { ok: { status: 200 } },
28
+ "x-suluk-access": { requires: "authenticated" },
29
+ },
30
+ },
31
+ },
32
+ },
33
+ } as unknown as OpenAPIv4Document;
34
+
35
+ describe("@suluk/sdk surfaces C041 field origin as method metadata", () => {
36
+ const src = generateSdk(doc, { baseURL: "https://api.example.com" });
37
+
38
+ test("the op carries a `fields:` metadata block", () => {
39
+ expect(src).toContain("fields:");
40
+ });
41
+
42
+ test("the sourced field is surfaced as a wireable edge (op + select), not a free input", () => {
43
+ expect(src).toContain('"origin":"sourced"');
44
+ expect(src).toContain('"op":"createSubscription"');
45
+ expect(src).toContain('"select":"id"');
46
+ });
47
+
48
+ test("the input field is marked fakerable; the computed field is marked not-fakerable", () => {
49
+ expect(src).toMatch(/"name":"amountCents"[^}]*"origin":"input"[^}]*"fakerable":true/);
50
+ expect(src).toMatch(/"name":"total"[^}]*"origin":"computed"[^}]*"fakerable":false/);
51
+ });
52
+
53
+ test("resolveOps populates op.fields from the request body (the SDK reads, never re-derives)", () => {
54
+ const { ops } = resolveOps(doc);
55
+ const charge = ops.find((o) => o.name === "charge")!;
56
+ expect(charge.fields?.map((f) => [f.name, f.origin])).toEqual([
57
+ ["amountCents", "input"],
58
+ ["subscriptionId", "sourced"],
59
+ ["total", "computed"],
60
+ ]);
61
+ expect(charge.fields?.find((f) => f.name === "subscriptionId")?.source).toEqual({ op: "createSubscription", select: "id" });
62
+ });
63
+
64
+ test("an op with no body emits no fields block (no bloat)", () => {
65
+ const noBody = {
66
+ openapi: "4.0.0-candidate", info: { title: "T" },
67
+ paths: { health: { requests: { health: { method: "get", responses: { ok: { status: 200 } } } } } },
68
+ } as unknown as OpenAPIv4Document;
69
+ expect(generateSdk(noBody)).not.toContain("fields:");
70
+ });
71
+ });
@@ -24,7 +24,8 @@ describe("@suluk/sdk generateStores — a typed Nano Stores reactive layer from
24
24
  expect(stores).toContain('import { createHooks, type Hookable } from "hookable"');
25
25
  expect(stores).toContain('import type { SulukClient } from "./sdk"'); // client TYPE only — self-contained
26
26
  expect(stores).toContain("export function createStores(client: SulukClient");
27
- expect(stores).toContain("const [createFetcherStore, , ctx] = nanoquery()");
27
+ expect(stores).toContain("const [createFetcherStore, , ctx] = nanoquery({ cache })"); // bounded cache (no unbounded growth)
28
+ expect(stores).toContain("if (!cache.has(k) && cache.size >= 500)");
28
29
  expect(stores).toContain("Requires: `npm i @nanostores/query nanostores hookable`");
29
30
  });
30
31