@loopwise/sdk 0.1.0-beta.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/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @loopwise/sdk
2
+
3
+ Storefront GraphQL client for Loopwise / Teachify. The SDK has no concept of
4
+ "tenant" in its public surface — calls hit the same-origin BFF at
5
+ `/api/graphql`, which derives the tenant from the request host and binds it
6
+ to the response on the trusted server side.
7
+
8
+ ## Quick start
9
+
10
+ ```ts
11
+ import { createClient } from '@loopwise/sdk';
12
+
13
+ const loopwise = createClient({});
14
+ const { items } = await loopwise.courses.list({ limit: 20 });
15
+ ```
16
+
17
+ That's the entire happy path. Whether the page is on `acme.kaik.io`,
18
+ `acme.myteachify.com`, or `nii.school`, the BFF on each origin already knows
19
+ which tenant it's serving — the SDK just talks to its own origin.
20
+
21
+ ### Cross-origin / SSR (advanced)
22
+
23
+ When the SDK runs outside a tenant origin (build pipeline, third-party site,
24
+ SSR worker), pass `tenant` explicitly so the SDK knows where to send the
25
+ request. An `apiKey` is required outside the browser to bypass cookie auth.
26
+
27
+ ```ts
28
+ const loopwise = createClient({
29
+ tenant: 'acme',
30
+ apiKey: process.env.LOOPWISE_API_KEY, // SSR / build only
31
+ });
32
+ ```
33
+
34
+ ## Cache option (`cache`)
35
+
36
+ Read-only resource calls accept an opt-in `cache` option that flips the
37
+ request to a GraphQL-over-HTTP `GET` and asks the BFF to apply a
38
+ `Cache-Control` hint so Vercel's edge can serve repeats without a Rails
39
+ round-trip.
40
+
41
+ ```ts
42
+ // 60s fresh + 300s stale-while-revalidate at the edge.
43
+ const { items } = await loopwise.courses.list({ limit: 20 }, { cache: 60 });
44
+
45
+ // Force fresh; never share the response across visitors.
46
+ const fresh = await loopwise.courses.list({ limit: 20 }, { cache: 'no-store' });
47
+ ```
48
+
49
+ `courses.list()` returns a thenable `QueryBuilder` — without `await` (or
50
+ `.then()` / passing it to `batch()`) the call is a no-op and no HTTP request
51
+ is sent.
52
+
53
+ ### When to use which
54
+
55
+ | Context | What to pass | Why |
56
+ |---------|--------------|-----|
57
+ | Storefront list / detail (public, semi-static) | `{ cache: 60 }` | Tenant-public, OK to share across visitors. |
58
+ | Storefront very-static metadata (school info, footer) | `{ cache: 300 }` | Capped at 300s by the BFF; rarely changes. |
59
+ | **Admin / preview path** | **`{ cache: 'no-store' }`** | Admin previewing a draft must always see fresh, never share their response. |
60
+ | Mutations | (n/a — never cached) | SDK always POSTs mutations. |
61
+ | Per-user data (cart, viewer profile) | `{ cache: 'no-store' }` (or omit entirely) | Caching per-user data on a shared edge would leak across visitors. |
62
+
63
+ ### Defence in depth — what happens if you get it wrong
64
+
65
+ The BFF refuses to cache any response when:
66
+
67
+ 1. The request carries `Cookie` or `Authorization` (admin sessions, logged-in
68
+ visitors). Even if you pass `cache: 60`, the cookie strips the hint.
69
+ 2. The upstream Rails response sets `Set-Cookie` (session establishment).
70
+ 3. The upstream returns a non-2xx status.
71
+
72
+ So a misconfigured `cache: 60` on an admin path is still safe — the cookie
73
+ guard prevents the response from reaching Vercel's shared cache. `cache: 'no-store'`
74
+ is the explicit, type-system-visible declaration of "this must never be cached".
75
+
76
+ ### Propagation delay after content updates
77
+
78
+ `cache: 60` ⇒ a publish becomes visible to visitors in roughly 1–6 minutes
79
+ per Vercel POP, depending on traffic. Use shorter TTLs for time-sensitive
80
+ content; use `cache: 'no-store'` for admin previews.
81
+
82
+ ## Public API
83
+
84
+ - `createClient(config)` — storefront client. `config.tenant` is optional
85
+ in the browser (BFF derives it from host) and required in SSR / build.
86
+ - `loopwise.courses.list(args, options?)` / `.get(args, options?)`.
87
+ - `loopwise.batch(builders, options?)` — composes N queries into one HTTP request.
88
+ - `loopwise.query<T>(document, variables?, operationName?, options?)` — raw GraphQL.
89
+
90
+ See `apps/sdk-runtime-poc/` for a runnable browser validation page.
@@ -0,0 +1,26 @@
1
+ //#region src/config.d.ts
2
+ interface LoopwiseConfig {
3
+ /**
4
+ * Tenant identifier (subdomain "acme" or full FQDN "nii.school").
5
+ *
6
+ * Optional in browser runtime — SDK auto-resolves from `window.location.hostname`
7
+ * (e.g. `acme.kaik.io` -> `"acme"`). Required in build/SSR runtime.
8
+ */
9
+ tenant?: string;
10
+ /**
11
+ * API key for build/SSR (Bearer auth). Reads from `LOOPWISE_API_KEY` env if
12
+ * omitted. NEVER bundle this into a browser build — `defineConfig` runs at
13
+ * build/SSR; the value never reaches client-side code unless the consumer
14
+ * misuses it.
15
+ */
16
+ apiKey?: string;
17
+ /**
18
+ * Override the GraphQL endpoint. Defaults to:
19
+ * - Browser: same-origin `/api/graphql`
20
+ * - Build/SSR: `https://{tenant}/api/graphql` (or kaik.io fallback)
21
+ */
22
+ endpoint?: string;
23
+ }
24
+ declare function defineConfig(config: LoopwiseConfig): LoopwiseConfig;
25
+ //#endregion
26
+ export { LoopwiseConfig, defineConfig };
@@ -0,0 +1,7 @@
1
+ //#region src/config.ts
2
+ function defineConfig(config) {
3
+ return config;
4
+ }
5
+
6
+ //#endregion
7
+ export { defineConfig };
@@ -0,0 +1,193 @@
1
+ import { LoopwiseConfig } from "./config.mjs";
2
+
3
+ //#region src/error.d.ts
4
+ type LoopwiseErrorCode = 'NETWORK' | 'AUTH' | 'GRAPHQL' | 'RATE_LIMIT' | 'TENANT_NOT_FOUND' | 'CONFIG';
5
+ interface GraphQLError {
6
+ message: string;
7
+ path?: (string | number)[];
8
+ extensions?: Record<string, unknown>;
9
+ }
10
+ declare class LoopwiseError extends Error {
11
+ readonly code: LoopwiseErrorCode;
12
+ readonly tenant?: string;
13
+ readonly endpoint?: string;
14
+ readonly graphqlErrors?: GraphQLError[];
15
+ readonly requestId?: string;
16
+ constructor(code: LoopwiseErrorCode, message: string, detail?: {
17
+ tenant?: string;
18
+ endpoint?: string;
19
+ graphqlErrors?: GraphQLError[];
20
+ requestId?: string;
21
+ });
22
+ }
23
+ //#endregion
24
+ //#region src/select.d.ts
25
+ type SelectionInner<T> = T extends Array<infer U> ? SelectionInner<U> : T extends object ? { [K in keyof T]?: SelectionInner<T[K]> } : true;
26
+ type Selection<T> = SelectionInner<NonNullable<T>>;
27
+ type SelectedInner<T, S> = T extends null | undefined ? T : S extends true ? T : T extends Array<infer U> ? Array<SelectedInner<U, S>> : T extends object ? { [K in keyof S & keyof T]: SelectedInner<T[K], S[K]> } : T;
28
+ type Selected<T, S> = SelectedInner<T, S>;
29
+ //#endregion
30
+ //#region src/query.d.ts
31
+ interface QueryDescriptor {
32
+ /** GraphQL operation name (used as alias key inside batch documents). */
33
+ operation: string;
34
+ /** Pre-built selection set string ("id title { x }"). */
35
+ selectionSet: string;
36
+ /** Top-level field name on the GraphQL schema (e.g. "courses", "course"). */
37
+ field: string;
38
+ /** Top-level field arguments rendered as inline literals. */
39
+ args: Record<string, unknown>;
40
+ /**
41
+ * Top-level field arguments rendered as GraphQL variables. The renderer
42
+ * emits `query Op($name: Type, ...) { field(name: $name, ...) }` and
43
+ * places `value` into the request's `variables` payload. Variable values
44
+ * stay JSON-shaped on the wire, which is what lets the BFF / proxy layer
45
+ * rewrite tenant-scoped variables (`subdomain`) without parsing the
46
+ * GraphQL document.
47
+ */
48
+ vars?: Record<string, {
49
+ type: string;
50
+ value: unknown;
51
+ }>;
52
+ /** Optional response transform (e.g. wrap into ListResult shape). */
53
+ transform?: (raw: unknown) => unknown;
54
+ }
55
+ interface FetchOptions {
56
+ /**
57
+ * Cache hint. A positive number opts the call into GraphQL-over-HTTP GET
58
+ * mode (per spec §5.1) so HTTP caches and CDNs can serve repeat hits, and
59
+ * sends `X-Loopwise-Cache: <seconds>` so the BFF can echo it as
60
+ * `Cache-Control: public, s-maxage=<seconds>`. `'no-store'` forces POST and
61
+ * skips any cache hint. Omit for the legacy POST behaviour.
62
+ */
63
+ cache?: number | 'no-store';
64
+ }
65
+ interface BatchedFetcher {
66
+ /**
67
+ * Execute one descriptor. Implementations may either fire an HTTP request
68
+ * immediately, or buffer for `batch()` to flush.
69
+ */
70
+ execute<T>(descriptor: QueryDescriptor, options?: FetchOptions): Promise<T>;
71
+ }
72
+ declare class QueryBuilder<T> implements PromiseLike<T> {
73
+ readonly descriptor: QueryDescriptor;
74
+ readonly fetchOptions: FetchOptions;
75
+ private fetcher;
76
+ constructor(fetcher: BatchedFetcher, descriptor: QueryDescriptor, fetchOptions?: FetchOptions);
77
+ then<R1 = T, R2 = never>(onFulfilled?: ((value: T) => R1 | PromiseLike<R1>) | null, onRejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null): PromiseLike<R1 | R2>;
78
+ }
79
+ //#endregion
80
+ //#region src/client.d.ts
81
+ declare class LoopwiseClient implements BatchedFetcher {
82
+ readonly config: {
83
+ tenant?: string;
84
+ } & LoopwiseConfig;
85
+ private endpoint;
86
+ constructor(config: LoopwiseConfig);
87
+ private defaultEndpoint;
88
+ execute<T>(descriptor: QueryDescriptor, options?: FetchOptions): Promise<T>;
89
+ /**
90
+ * Raw GraphQL escape hatch. Caller writes the query/mutation document and
91
+ * receives the typed `data` payload. Variables are sent as-is. Errors are
92
+ * still mapped to LoopwiseError.
93
+ */
94
+ raw<T = unknown>(document: string, variables?: Record<string, unknown>, operationName?: string, options?: FetchOptions): Promise<T>;
95
+ batch<R extends Record<string, unknown>>(builders: { [K in keyof R]: {
96
+ descriptor: QueryDescriptor;
97
+ } }, options?: FetchOptions): Promise<R>;
98
+ private fetchGraphQL;
99
+ private shouldUseGet;
100
+ private buildGetUrl;
101
+ private resolveApiKey;
102
+ }
103
+ //#endregion
104
+ //#region src/types.d.ts
105
+ interface Course {
106
+ id: string;
107
+ slug: string;
108
+ /** Maps to schema `Course.name`. */
109
+ title: string;
110
+ description: string | null;
111
+ /** Maps to schema `Course.image`. */
112
+ coverImage: string | null;
113
+ createdAt: number;
114
+ lecturer: Lecturer | null;
115
+ }
116
+ interface Lecturer {
117
+ id: string;
118
+ /** Maps to schema `Lecturer.name`. */
119
+ name: string;
120
+ avatar: string | null;
121
+ }
122
+ interface PageInfo {
123
+ page: number;
124
+ totalPages: number;
125
+ totalCount: number;
126
+ hasNext: boolean;
127
+ hasPrev: boolean;
128
+ }
129
+ interface ListResult<T> {
130
+ items: T[];
131
+ pageInfo: PageInfo;
132
+ }
133
+ //#endregion
134
+ //#region src/resources/courses.d.ts
135
+ declare const DEFAULT_LIST_FIELDS: Selection<Course>;
136
+ declare const DEFAULT_GET_FIELDS: Selection<Course>;
137
+ interface CourseListArgs<S extends Selection<Course> = typeof DEFAULT_LIST_FIELDS> {
138
+ limit?: number;
139
+ page?: number;
140
+ /** SDK `limit` maps to schema `perPage`. Schema also has its own `limit`
141
+ * which we expose as `topN` if needed (not in v0). */
142
+ select?: S;
143
+ }
144
+ interface CourseGetArgs<S extends Selection<Course> = typeof DEFAULT_GET_FIELDS> {
145
+ slug: string;
146
+ select?: S;
147
+ }
148
+ declare class CoursesResource {
149
+ private client;
150
+ constructor(client: LoopwiseClient);
151
+ /**
152
+ * List courses for the current tenant.
153
+ * @example
154
+ * const { items, pageInfo } = await loopwise.courses.list({ limit: 20 })
155
+ * @example
156
+ * // Opt into GET + Cache-Control hint (60s):
157
+ * await loopwise.courses.list({ limit: 20 }, { cache: 60 })
158
+ */
159
+ list<S extends Selection<Course> = typeof DEFAULT_LIST_FIELDS>(args?: CourseListArgs<S>, options?: FetchOptions): QueryBuilder<ListResult<Selected<Course, S>>>;
160
+ /**
161
+ * Get a single course by slug. Returns `null` if not found.
162
+ * @example
163
+ * const course = await loopwise.courses.get({ slug: 'finance' })
164
+ * if (!course) return Astro.redirect('/404')
165
+ */
166
+ get<S extends Selection<Course> = typeof DEFAULT_GET_FIELDS>(args: CourseGetArgs<S>, options?: FetchOptions): QueryBuilder<Selected<Course, S> | null>;
167
+ }
168
+ //#endregion
169
+ //#region src/index.d.ts
170
+ interface Loopwise {
171
+ readonly courses: CoursesResource;
172
+ /**
173
+ * Compose multiple resource queries into a single GraphQL HTTP request.
174
+ * @example
175
+ * const home = await loopwise.batch({
176
+ * featured: loopwise.courses.list({ limit: 6 }),
177
+ * recent: loopwise.courses.list({ limit: 3, page: 2 }),
178
+ * }, { cache: 60 })
179
+ */
180
+ batch<R extends Record<string, QueryBuilder<unknown>>>(builders: R, options?: FetchOptions): Promise<{ [K in keyof R]: R[K] extends QueryBuilder<infer T> ? T : never }>;
181
+ /**
182
+ * Raw GraphQL escape hatch. Use when a typed resource method doesn't cover
183
+ * what you need (new schema fields, custom queries, ad-hoc inspection).
184
+ * @example
185
+ * const data = await loopwise.query<{ school: { name: string } }>(`
186
+ * query { school(subdomain: "acme") { name } }
187
+ * `)
188
+ */
189
+ query<T = unknown>(document: string, variables?: Record<string, unknown>, operationName?: string, options?: FetchOptions): Promise<T>;
190
+ }
191
+ declare function createClient(config: LoopwiseConfig): Loopwise;
192
+ //#endregion
193
+ export { type Course, type FetchOptions, type Lecturer, type ListResult, Loopwise, type LoopwiseConfig, LoopwiseError, type PageInfo, QueryBuilder, type Selected, type Selection, createClient };
package/dist/index.mjs ADDED
@@ -0,0 +1,390 @@
1
+ //#region src/error.ts
2
+ var LoopwiseError = class extends Error {
3
+ code;
4
+ tenant;
5
+ endpoint;
6
+ graphqlErrors;
7
+ requestId;
8
+ constructor(code, message, detail = {}) {
9
+ super(message);
10
+ this.name = "LoopwiseError";
11
+ this.code = code;
12
+ this.tenant = detail.tenant;
13
+ this.endpoint = detail.endpoint;
14
+ this.graphqlErrors = detail.graphqlErrors;
15
+ this.requestId = detail.requestId;
16
+ }
17
+ };
18
+
19
+ //#endregion
20
+ //#region src/runtime.ts
21
+ function detectRuntime() {
22
+ if (typeof window !== "undefined" && typeof window.document !== "undefined") return "browser";
23
+ return "ssr";
24
+ }
25
+
26
+ //#endregion
27
+ //#region src/client.ts
28
+ const GET_URL_MAX_LENGTH = 2e3;
29
+ const GRAPHQL_NAME_RE = /^[_A-Za-z][_0-9A-Za-z]*$/;
30
+ var LoopwiseClient = class {
31
+ config;
32
+ endpoint;
33
+ constructor(config) {
34
+ const tenant = config.tenant ?? void 0;
35
+ if (detectRuntime() !== "browser" && !tenant) throw new LoopwiseError("CONFIG", "tenant is required outside browser runtime. Pass it to createClient({ tenant }).");
36
+ this.config = {
37
+ ...config,
38
+ tenant
39
+ };
40
+ this.endpoint = config.endpoint ?? this.defaultEndpoint();
41
+ }
42
+ defaultEndpoint() {
43
+ if (detectRuntime() === "browser") return "/api/graphql";
44
+ const tenant = this.config.tenant;
45
+ if (tenant.includes(".")) return `https://${tenant}/api/graphql`;
46
+ return `https://${tenant}.kaik.io/api/graphql`;
47
+ }
48
+ async execute(descriptor, options = {}) {
49
+ const document = renderSingleDocument(descriptor);
50
+ const payload = {
51
+ query: document.query,
52
+ variables: document.variables,
53
+ operationName: descriptor.operation
54
+ };
55
+ const raw = (await this.fetchGraphQL(payload, options))?.[descriptor.field];
56
+ return descriptor.transform ? descriptor.transform(raw) : raw;
57
+ }
58
+ /**
59
+ * Raw GraphQL escape hatch. Caller writes the query/mutation document and
60
+ * receives the typed `data` payload. Variables are sent as-is. Errors are
61
+ * still mapped to LoopwiseError.
62
+ */
63
+ async raw(document, variables = {}, operationName, options = {}) {
64
+ return await this.fetchGraphQL({
65
+ query: document,
66
+ variables,
67
+ operationName
68
+ }, options);
69
+ }
70
+ async batch(builders, options = {}) {
71
+ const entries = Object.entries(builders);
72
+ if (entries.length === 0) return {};
73
+ for (const [alias, builder] of entries) {
74
+ const aliasStr = String(alias);
75
+ if (!GRAPHQL_NAME_RE.test(aliasStr)) throw new LoopwiseError("CONFIG", `batch() alias "${aliasStr}" is not a valid GraphQL Name`);
76
+ const builderOptions = builder.fetchOptions;
77
+ if (builderOptions && Object.keys(builderOptions).length > 0) throw new LoopwiseError("CONFIG", `batch() builder "${aliasStr}" has per-call FetchOptions; pass them to batch(builders, options) instead so they apply to the whole request.`);
78
+ }
79
+ const document = renderBatchDocument(entries.map(([alias, b]) => ({
80
+ alias: String(alias),
81
+ descriptor: b.descriptor
82
+ })));
83
+ const data = await this.fetchGraphQL({
84
+ query: document.query,
85
+ variables: document.variables,
86
+ operationName: "BatchQuery"
87
+ }, options);
88
+ const result = {};
89
+ for (const [alias, builder] of entries) {
90
+ const raw = data?.[String(alias)];
91
+ result[alias] = builder.descriptor.transform ? builder.descriptor.transform(raw) : raw;
92
+ }
93
+ return result;
94
+ }
95
+ async fetchGraphQL(payload, options = {}) {
96
+ const headers = { accept: "application/graphql-response+json, application/json" };
97
+ const apiKey = this.resolveApiKey();
98
+ if (apiKey) headers.authorization = `Bearer ${apiKey}`;
99
+ const useGet = this.shouldUseGet(payload, options);
100
+ if (typeof options.cache === "number" && options.cache > 0) headers["x-loopwise-cache"] = String(options.cache);
101
+ let response;
102
+ try {
103
+ if (useGet) response = await fetch(this.buildGetUrl(payload), {
104
+ method: "GET",
105
+ credentials: "omit",
106
+ headers
107
+ });
108
+ else response = await fetch(this.endpoint, {
109
+ method: "POST",
110
+ credentials: detectRuntime() === "browser" ? "same-origin" : "omit",
111
+ headers: {
112
+ ...headers,
113
+ "content-type": "application/json"
114
+ },
115
+ body: JSON.stringify(payload)
116
+ });
117
+ } catch (err) {
118
+ throw new LoopwiseError("NETWORK", `Network error: ${err.message}`, {
119
+ tenant: this.config.tenant,
120
+ endpoint: this.endpoint
121
+ });
122
+ }
123
+ if (response.status === 401 || response.status === 403) throw new LoopwiseError("AUTH", `Auth failed (${response.status})`, {
124
+ tenant: this.config.tenant,
125
+ endpoint: this.endpoint,
126
+ requestId: response.headers.get("x-request-id") ?? void 0
127
+ });
128
+ if (response.status === 429) throw new LoopwiseError("RATE_LIMIT", "Rate limited", {
129
+ tenant: this.config.tenant,
130
+ endpoint: this.endpoint
131
+ });
132
+ if (!response.ok) throw new LoopwiseError("NETWORK", `Upstream ${response.status}`, {
133
+ tenant: this.config.tenant,
134
+ endpoint: this.endpoint
135
+ });
136
+ const body = await response.json();
137
+ if (body.errors?.length) throw new LoopwiseError("GRAPHQL", body.errors[0]?.message ?? "GraphQL error", {
138
+ tenant: this.config.tenant,
139
+ endpoint: this.endpoint,
140
+ graphqlErrors: body.errors
141
+ });
142
+ return body.data ?? null;
143
+ }
144
+ shouldUseGet(payload, options) {
145
+ if (typeof options.cache !== "number" || options.cache <= 0) return false;
146
+ if (detectRuntime() !== "browser" || this.resolveApiKey()) return false;
147
+ const op = firstOperationKeyword(payload.query);
148
+ if (op === "mutation" || op === "subscription") return false;
149
+ if (containsNonQueryOperation(payload.query)) return false;
150
+ return this.buildGetUrl(payload).length <= GET_URL_MAX_LENGTH;
151
+ }
152
+ buildGetUrl(payload) {
153
+ const params = new URLSearchParams();
154
+ params.set("query", payload.query);
155
+ if (payload.variables && Object.keys(payload.variables).length > 0) params.set("variables", JSON.stringify(payload.variables));
156
+ if (payload.operationName) params.set("operationName", payload.operationName);
157
+ const sep = this.endpoint.includes("?") ? "&" : "?";
158
+ return `${this.endpoint}${sep}${params.toString()}`;
159
+ }
160
+ resolveApiKey() {
161
+ if (detectRuntime() === "browser") return void 0;
162
+ if (this.config.apiKey) return this.config.apiKey;
163
+ return globalThis.process?.env?.LOOPWISE_API_KEY;
164
+ }
165
+ };
166
+ function firstOperationKeyword(document) {
167
+ let i = 0;
168
+ if (document.charCodeAt(0) === 65279) i = 1;
169
+ while (i < document.length) {
170
+ const ch = document[i];
171
+ if (ch === " " || ch === " " || ch === "\r" || ch === "\n" || ch === ",") {
172
+ i++;
173
+ continue;
174
+ }
175
+ if (ch === "#") {
176
+ while (i < document.length && document[i] !== "\n") i++;
177
+ continue;
178
+ }
179
+ break;
180
+ }
181
+ const rest = document.slice(i);
182
+ if (/^mutation\b/.test(rest)) return "mutation";
183
+ if (/^subscription\b/.test(rest)) return "subscription";
184
+ if (/^query\b/.test(rest) || rest.startsWith("{")) return "query";
185
+ return null;
186
+ }
187
+ function containsNonQueryOperation(document) {
188
+ const stripped = document.replace(/#[^\n]*/g, "").replace(/"""[\s\S]*?"""/g, "\"\"").replace(/"(?:\\.|[^"\\])*"/g, "\"\"");
189
+ return /\b(mutation|subscription)\b\s*[A-Za-z_][\w]*?\s*[({@]?[\s\S]{0,256}?\{/m.test(stripped) || /\b(mutation|subscription)\b\s*\{/m.test(stripped);
190
+ }
191
+ function composeFieldCall(descriptor) {
192
+ const inline = stringifyArgs(descriptor.args);
193
+ const allArgs = [Object.keys(descriptor.vars ?? {}).map((name) => `${name}: $${name}`).join(", "), inline].filter(Boolean).join(", ");
194
+ return allArgs ? `${descriptor.field}(${allArgs})` : descriptor.field;
195
+ }
196
+ function renderSingleDocument(descriptor) {
197
+ const fieldCall = composeFieldCall(descriptor);
198
+ const varDecls = Object.entries(descriptor.vars ?? {}).map(([name, { type }]) => `$${name}: ${type}`).join(", ");
199
+ const query = `query ${varDecls ? `${descriptor.operation}(${varDecls})` : descriptor.operation} { ${fieldCall} { ${descriptor.selectionSet} } }`;
200
+ const variables = {};
201
+ for (const [name, { value }] of Object.entries(descriptor.vars ?? {})) variables[name] = value;
202
+ return {
203
+ query,
204
+ variables
205
+ };
206
+ }
207
+ function renderBatchDocument(entries) {
208
+ const mergedVars = {};
209
+ for (const { descriptor } of entries) for (const [name, def] of Object.entries(descriptor.vars ?? {})) mergedVars[name] = def;
210
+ const varDecls = Object.entries(mergedVars).map(([name, { type }]) => `$${name}: ${type}`).join(", ");
211
+ const opSig = varDecls ? `BatchQuery(${varDecls})` : "BatchQuery";
212
+ const fields = entries.map(({ alias, descriptor }) => `${alias}: ${composeFieldCall(descriptor)} { ${descriptor.selectionSet} }`);
213
+ const variables = {};
214
+ for (const [name, { value }] of Object.entries(mergedVars)) variables[name] = value;
215
+ return {
216
+ query: `query ${opSig} { ${fields.join(" ")} }`,
217
+ variables
218
+ };
219
+ }
220
+ function stringifyArgs(args) {
221
+ const parts = [];
222
+ for (const [k, v] of Object.entries(args)) {
223
+ if (v === void 0) continue;
224
+ parts.push(`${k}: ${literal(v)}`);
225
+ }
226
+ return parts.join(", ");
227
+ }
228
+ function literal(value) {
229
+ if (value === null) return "null";
230
+ if (typeof value === "string") return JSON.stringify(value);
231
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
232
+ if (Array.isArray(value)) return `[${value.map(literal).join(", ")}]`;
233
+ if (typeof value === "object") return `{ ${Object.entries(value).filter(([, v]) => v !== void 0).map(([k, v]) => `${k}: ${literal(v)}`).join(", ")} }`;
234
+ throw new LoopwiseError("CONFIG", `Unsupported argument type for GraphQL literal: ${typeof value}`);
235
+ }
236
+
237
+ //#endregion
238
+ //#region src/query.ts
239
+ var QueryBuilder = class {
240
+ descriptor;
241
+ fetchOptions;
242
+ fetcher;
243
+ constructor(fetcher, descriptor, fetchOptions = {}) {
244
+ this.fetcher = fetcher;
245
+ this.descriptor = descriptor;
246
+ this.fetchOptions = fetchOptions;
247
+ }
248
+ then(onFulfilled, onRejected) {
249
+ return this.fetcher.execute(this.descriptor, this.fetchOptions).then(onFulfilled, onRejected);
250
+ }
251
+ };
252
+
253
+ //#endregion
254
+ //#region src/schema/courses.ts
255
+ /** Map SDK Course selection -> schema field selection set. */
256
+ function buildCourseSelection(select) {
257
+ const parts = ["id", "slug"];
258
+ const sel = select;
259
+ if (sel.title) parts.push("name");
260
+ if (sel.description) parts.push("description");
261
+ if (sel.coverImage) parts.push("image");
262
+ if (sel.createdAt) parts.push("createdAt");
263
+ if (sel.lecturer && typeof sel.lecturer === "object") {
264
+ const lec = sel.lecturer;
265
+ const lecParts = ["id"];
266
+ if (lec.name) lecParts.push("name");
267
+ if (lec.avatar) lecParts.push("avatar");
268
+ parts.push(`lecturer { ${lecParts.join(" ")} }`);
269
+ }
270
+ return parts.join(" ");
271
+ }
272
+ function mapCourse(raw) {
273
+ const out = {
274
+ id: raw.id,
275
+ slug: raw.slug
276
+ };
277
+ if (raw.name !== void 0) out.title = raw.name;
278
+ if (raw.description !== void 0) out.description = raw.description ?? null;
279
+ if (raw.image !== void 0) out.coverImage = raw.image ?? null;
280
+ if (raw.createdAt !== void 0) out.createdAt = raw.createdAt;
281
+ if (raw.lecturer !== void 0) out.lecturer = raw.lecturer ? mapLecturer(raw.lecturer) : null;
282
+ return out;
283
+ }
284
+ function mapLecturer(raw) {
285
+ const out = { id: raw.id };
286
+ if (raw.name !== void 0) out.name = raw.name;
287
+ if (raw.avatar !== void 0) out.avatar = raw.avatar ?? null;
288
+ return out;
289
+ }
290
+ function mapCoursePage(raw) {
291
+ return {
292
+ items: raw.nodes.map(mapCourse),
293
+ pageInfo: {
294
+ page: raw.currentPage,
295
+ totalPages: raw.totalPages,
296
+ totalCount: raw.nodesCount,
297
+ hasNext: raw.hasNextPage,
298
+ hasPrev: raw.hasPreviousPage
299
+ }
300
+ };
301
+ }
302
+
303
+ //#endregion
304
+ //#region src/resources/courses.ts
305
+ const DEFAULT_LIST_FIELDS = {
306
+ id: true,
307
+ slug: true,
308
+ title: true,
309
+ coverImage: true
310
+ };
311
+ const DEFAULT_GET_FIELDS = {
312
+ id: true,
313
+ slug: true,
314
+ title: true,
315
+ description: true,
316
+ coverImage: true,
317
+ lecturer: {
318
+ id: true,
319
+ name: true,
320
+ avatar: true
321
+ }
322
+ };
323
+ function tenantVars(client) {
324
+ return { subdomain: {
325
+ type: "String!",
326
+ value: client.config.tenant ?? ""
327
+ } };
328
+ }
329
+ var CoursesResource = class {
330
+ constructor(client) {
331
+ this.client = client;
332
+ }
333
+ /**
334
+ * List courses for the current tenant.
335
+ * @example
336
+ * const { items, pageInfo } = await loopwise.courses.list({ limit: 20 })
337
+ * @example
338
+ * // Opt into GET + Cache-Control hint (60s):
339
+ * await loopwise.courses.list({ limit: 20 }, { cache: 60 })
340
+ */
341
+ list(args = {}, options = {}) {
342
+ const selectionSet = `nodes { ${buildCourseSelection(args.select ?? DEFAULT_LIST_FIELDS)} } currentPage totalPages nodesCount hasNextPage hasPreviousPage`;
343
+ return new QueryBuilder(this.client, {
344
+ operation: "CourseList",
345
+ field: "courses",
346
+ selectionSet,
347
+ args: {
348
+ page: args.page,
349
+ perPage: args.limit
350
+ },
351
+ vars: tenantVars(this.client),
352
+ transform: (raw) => mapCoursePage(raw)
353
+ }, options);
354
+ }
355
+ /**
356
+ * Get a single course by slug. Returns `null` if not found.
357
+ * @example
358
+ * const course = await loopwise.courses.get({ slug: 'finance' })
359
+ * if (!course) return Astro.redirect('/404')
360
+ */
361
+ get(args, options = {}) {
362
+ const sel = args.select ?? DEFAULT_GET_FIELDS;
363
+ return new QueryBuilder(this.client, {
364
+ operation: "CourseGet",
365
+ field: "course",
366
+ selectionSet: buildCourseSelection(sel),
367
+ args: { slug: args.slug },
368
+ vars: tenantVars(this.client),
369
+ transform: (raw) => raw == null ? null : mapCourse(raw)
370
+ }, options);
371
+ }
372
+ };
373
+
374
+ //#endregion
375
+ //#region src/index.ts
376
+ function createClient(config) {
377
+ const client = new LoopwiseClient(config);
378
+ return {
379
+ courses: new CoursesResource(client),
380
+ batch(builders, options) {
381
+ return client.batch(builders, options);
382
+ },
383
+ query(document, variables, operationName, options) {
384
+ return client.raw(document, variables, operationName, options);
385
+ }
386
+ };
387
+ }
388
+
389
+ //#endregion
390
+ export { LoopwiseError, QueryBuilder, createClient };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@loopwise/sdk",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "Loopwise platform SDK — agent-friendly headless API client",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/kaikhq/manekineko.git",
8
+ "directory": "packages/sdk"
9
+ },
10
+ "homepage": "https://github.com/kaikhq/manekineko/tree/develop/packages/sdk#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/kaikhq/manekineko/issues"
13
+ },
14
+ "keywords": [
15
+ "loopwise",
16
+ "teachify",
17
+ "graphql",
18
+ "headless",
19
+ "storefront",
20
+ "sdk"
21
+ ],
22
+ "type": "module",
23
+ "license": "MIT",
24
+ "main": "./dist/index.mjs",
25
+ "module": "./dist/index.mjs",
26
+ "types": "./dist/index.d.mts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.mts",
30
+ "import": "./dist/index.mjs"
31
+ },
32
+ "./config": {
33
+ "types": "./dist/config.d.mts",
34
+ "import": "./dist/config.mjs"
35
+ }
36
+ },
37
+ "files": [
38
+ "dist/*.mjs",
39
+ "dist/*.d.mts",
40
+ "README.md"
41
+ ],
42
+ "scripts": {
43
+ "build": "tsdown",
44
+ "dev": "tsdown --watch",
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "vitest run"
47
+ },
48
+ "devDependencies": {
49
+ "tsconfig": "workspace:*",
50
+ "tsdown": "0.20.3",
51
+ "typescript": "5.9.3",
52
+ "vitest": "4.1.0"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ }
57
+ }