@loopwise/sdk 0.1.0-beta.2 → 0.1.0-beta.4

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/CHANGELOG.md ADDED
@@ -0,0 +1,107 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@loopwise/sdk` are documented here.
4
+
5
+ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+ Versioning: [SemVer](https://semver.org/) with pre-1.0 semantics — minor bumps
7
+ (`0.x → 0.x+1`) may include breaking changes; beta tags (`0.1.0-beta.N`)
8
+ follow the same rules as a single 0.x line.
9
+
10
+ ## [Unreleased]
11
+
12
+ ## [0.1.0-beta.4] — 2026-09-10
13
+
14
+ Storefront schema pinning for browser clients. A design partner could
15
+ not tell a honoured `API-Version` pin from an ignored header because the
16
+ same-origin `/api/graphql` proxy dropped it (TEACH-22755, #16417); the
17
+ proxy now forwards it, answers the CORS preflight for off-origin callers
18
+ (TEACH-22760, #16429), and this release gives the SDK a first-class way
19
+ to send the pin.
20
+
21
+ ### Added
22
+
23
+ - `LoopwiseConfig.apiVersion` — pins the storefront schema to a release
24
+ date. Sent as the `API-Version` header on every request (GET and POST);
25
+ the server echoes the resolved version in the `API-Version` response
26
+ header. A blank value throws `LoopwiseError` code `'CONFIG'` at
27
+ `createClient()`, because the server would treat it as absent and serve
28
+ the baseline silently. (TEACH-22755)
29
+
30
+ ### Changed
31
+
32
+ - **Breaking:** a non-2xx response whose body is a GraphQL errors payload
33
+ (objects with a `message`) now raises `LoopwiseError` with code
34
+ `'GRAPHQL'` and the errors on `graphqlErrors`, instead of `'NETWORK'` /
35
+ `Upstream <status>`. The first such response is the 400
36
+ `INVALID_API_VERSION` for a malformed `apiVersion`. Migration: code that
37
+ retries or branches on `code === 'NETWORK'` for a 4xx/5xx carrying a
38
+ GraphQL body should also handle `'GRAPHQL'` (check
39
+ `graphqlErrors[0].extensions.code` for the server's reason). Status
40
+ precedence is unchanged: 401/403 stay `'AUTH'` and 429 stays
41
+ `'RATE_LIMIT'` whatever their body, and other non-2xx responses without a
42
+ GraphQL body stay `'NETWORK'`.
43
+
44
+ ## [0.1.0-beta.3] — 2026-05-20
45
+
46
+ ### Added
47
+
48
+ - `LoopwiseConfig.site` — preferred name for the site this client reads
49
+ from. Accepts a subdomain (`'acme'`) or full domain
50
+ (`'acme.myteachify.com'`, `'nii.school'`). Replaces the deprecated
51
+ `tenant` field; both fields are accepted, `site` wins when both are set.
52
+ - `LoopwiseError.site` — mirror of the deprecated `tenant` property.
53
+ Catch-and-read consumers can migrate at their own pace.
54
+
55
+ ### Deprecated
56
+
57
+ - `LoopwiseConfig.tenant` — renamed to `site`. Both fields still work;
58
+ `site` wins when both are set. **Removed in `0.2.0`.**
59
+ - `LoopwiseError.tenant` — renamed to `site`. **Removed in `0.2.0`.**
60
+
61
+ ### Removed
62
+
63
+ - `LoopwiseErrorCode = '…' | 'TENANT_NOT_FOUND'` — the literal was never
64
+ emitted by any SDK code path (it was a reserved/aspirational shape
65
+ that never got runtime mapping). Removed instead of renaming to
66
+ `'SITE_NOT_FOUND'` — no point preserving phantom API surface. If a
67
+ future SDK release adds actual runtime mapping (e.g. translating
68
+ Rails' `School-000` GraphQL error into a typed code), the canonical
69
+ `'SITE_NOT_FOUND'` literal will be added then. Removal is type-only
70
+ (narrowing checks like `case 'TENANT_NOT_FOUND':` were unreachable
71
+ branches in any consumer code).
72
+
73
+ ### Changed (non-breaking)
74
+
75
+ - README + every user-visible JSDoc reframed to use "site" language
76
+ instead of "tenant" / "multi-tenant" / "BFF derives tenant from host"
77
+ — SDK consumers don't need to learn the platform's multi-tenancy
78
+ model, just the SDK's behaviour and limits (`createClient({})` reads
79
+ from the page's own site; `createClient({ site: '…' })` reads from a
80
+ different site at the cost of authenticated calls).
81
+
82
+ ## [0.1.0-beta.2] — 2026-05-19
83
+
84
+ ### Fixed
85
+
86
+ - `orders.get` returns `null` from a null GraphQL payload instead of
87
+ throwing.
88
+ - `orders.get` passes the `id` as an inline GraphQL argument rather than
89
+ a `$variable`, avoiding collision when composed via `batch()`.
90
+
91
+ ### Documentation
92
+
93
+ - `orders.get` JSDoc clarifies `OrderItem.amount` is per-unit price
94
+ (not line total), and null-guards the `tradeNo` URL-param lookup
95
+ example.
96
+
97
+ ## [0.1.0-beta.1] — 2026-05-05
98
+
99
+ Initial public beta. `courses.list/get`, `posts.list/get`, `orders.get`,
100
+ `batch()`, raw `query()`. Schema-lock pipeline. Same-origin BFF and
101
+ explicit cross-origin endpoint resolution.
102
+
103
+ [Unreleased]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.4...HEAD
104
+ [0.1.0-beta.4]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.3...@loopwise/sdk@0.1.0-beta.4
105
+ [0.1.0-beta.3]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.2...@loopwise/sdk@0.1.0-beta.3
106
+ [0.1.0-beta.2]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.1...@loopwise/sdk@0.1.0-beta.2
107
+ [0.1.0-beta.1]: https://github.com/kaikhq/manekineko/compare/@loopwise/sdk@0.1.0-beta.0...@loopwise/sdk@0.1.0-beta.1
package/README.md CHANGED
@@ -6,7 +6,7 @@ Storefront GraphQL client for Loopwise / Teachify. Pick your path:
6
6
  |---|---|---|
7
7
  | A single self-contained HTML file (no build step — for `deploy_static_page`, a Claude Design artifact, or a CodePen) | none — load from a CDN | `https://esm.sh/@loopwise/sdk@beta` |
8
8
  | A project with a bundler (Vite / Astro / Next.js / Remix) | `npm install @loopwise/sdk@beta` | `@loopwise/sdk` |
9
- | **React** UI in either of the above | (see [`@loopwise/sdk-react`](#react--coming-soon)) | — |
9
+ | **React** UI in either of the above | (see [`@loopwise/react`](#react--coming-soon)) | — |
10
10
 
11
11
  Same package powers all three; the difference is the loader.
12
12
 
@@ -15,15 +15,15 @@ Same package powers all three; the difference is the loader.
15
15
  ```ts
16
16
  import { createClient } from '@loopwise/sdk';
17
17
 
18
- // Browser, same-origin (the page is served from the tenant origin):
18
+ // Reads from the site this page is on. No config needed.
19
19
  const loopwise = createClient({});
20
20
  const { items } = await loopwise.courses.list({ limit: 20 });
21
21
  ```
22
22
 
23
- That's the entire happy path **when the page is served from the tenant
24
- origin** (`acme.kaik.io`, `acme.myteachify.com`, `nii.school`, etc.). The BFF
25
- on that origin derives the tenant from the request host and binds it to the
26
- response on the trusted server side.
23
+ That's it. The SDK calls the site that's serving the page
24
+ (`acme.kaik.io`, `acme.myteachify.com`, `nii.school`, etc.) and the
25
+ site's API binds the response to itself — you never identify the site
26
+ yourself. To call a different site, see [Calling a different site](#calling-a-different-site).
27
27
 
28
28
  ## Quick start — single-file HTML (no build)
29
29
 
@@ -32,44 +32,57 @@ response on the trusted server side.
32
32
  <script type="module">
33
33
  import { createClient } from 'https://esm.sh/@loopwise/sdk@beta';
34
34
 
35
- // Same-origin variant — works when the HTML is served from the tenant origin
36
- // (e.g. acme.kaik.io). Empty config; BFF derives the tenant from host.
35
+ // Reads from the site this HTML is loaded on.
37
36
  const loopwise = createClient({});
38
37
  const { items } = await loopwise.courses.list({ limit: 20 });
39
38
  document.body.textContent = items.map(c => c.title).join(', ');
40
39
  </script>
41
40
  ```
42
41
 
43
- ## Cross-origin (Loopwise Pages preview, SSR, build pipelines)
42
+ ## Calling a different site
44
43
 
45
- When the SDK runs **outside** a tenant origin, pass `tenant` explicitly so
46
- the SDK can pick the right Rails origin to call.
44
+ When your code runs **somewhere other than the site itself** — a Loopwise
45
+ Pages preview at `<scriptName>.loopwise.page`, a build / SSR pipeline,
46
+ server-to-server tooling — name the site you want to read from:
47
47
 
48
48
  ```ts
49
49
  const loopwise = createClient({
50
- tenant: 'acme', // required cross-origin
51
- apiKey: process.env.LOOPWISE_API_KEY, // required in SSR / build (browser ignores)
50
+ site: 'acme', // 'acme' or 'acme.myteachify.com'
51
+ apiKey: process.env.LOOPWISE_API_KEY, // build/SSR only; browser ignores
52
52
  });
53
53
  ```
54
54
 
55
- ### Where this matters most: `deploy_static_page`
55
+ `site` accepts a subdomain (`'acme'`) or a full domain
56
+ (`'acme.myteachify.com'`, `'nii.school'`). The SDK uses it to pick the
57
+ right API origin.
56
58
 
57
- Pages deployed via `deploy_static_page` live at `https://<script>.loopwise.page/`
58
- — that is **not** a tenant origin, so:
59
+ ### Limits when not on the site itself
59
60
 
60
- - The BFF same-origin trick does not apply. You **must** pass `tenant`.
61
- - Browser cross-origin requests carry no first-party cookies, so anything
62
- that requires a logged-in viewer (`cart`, `viewer.profile`,
63
- authenticated mutations) **will not work** at preview time.
64
- - Public, anonymous reads (`courses.list`, `posts.list`, etc.) are fine.
61
+ Cross-origin requests **do not carry first-party cookies**. So calls
62
+ that require a signed-in visitor — `cart`, `viewer.profile`,
63
+ authenticated mutations — **will not work** from off-site code.
64
+ Public, anonymous reads (`courses.list`, `posts.list`, etc.) work fine.
65
+
66
+ The site's `/api/graphql` answers the browser's CORS preflight with a
67
+ static `Access-Control-Allow-Origin: *` and no credentials. The
68
+ `Access-Control-Allow-Headers` grant names the non-safelisted headers
69
+ this SDK sends (`content-type` with a JSON value, `api-version`,
70
+ `x-loopwise-cache`); CORS-safelisted headers such as `Accept` need no
71
+ grant. A hand-rolled `fetch` that adds any other non-safelisted header
72
+ (`Authorization`, for one) fails the preflight, and
73
+ `credentials: 'include'` is refused by the browser. The response exposes
74
+ `API-Version` and `X-Request-Id`.
75
+
76
+ This is the dominant case for `deploy_static_page` bundles (which
77
+ serve at `<scriptName>.loopwise.page`):
65
78
 
66
79
  ```ts
67
80
  // Inside an HTML bundle deployed to https://lw-acme-home.loopwise.page/
68
- const loopwise = createClient({ tenant: 'acme' });
81
+ const loopwise = createClient({ site: 'acme' });
69
82
  const { items } = await loopwise.courses.list({ limit: 20 }, { cache: 60 });
70
83
  ```
71
84
 
72
- For preview UX that needs authenticated state, see
85
+ For preview UX that needs an authenticated viewer, see
73
86
  [Preview modes](#preview-modes) below.
74
87
 
75
88
  ## `cache` option
@@ -120,30 +133,60 @@ is the explicit, type-system-visible declaration of "this must never be cached".
120
133
  per Vercel POP, depending on traffic. Use shorter TTLs for time-sensitive
121
134
  content; use `cache: 'no-store'` for admin previews.
122
135
 
136
+ ## `apiVersion` option
137
+
138
+ The storefront schema is versioned by release date. Pin the one your code
139
+ was built against and the SDK sends it as the `API-Version` header on
140
+ every request, GET and POST alike:
141
+
142
+ ```ts
143
+ const loopwise = createClient({ apiVersion: '2027-01-01' });
144
+ ```
145
+
146
+ Omit it to get the baseline schema. The server echoes the version it
147
+ resolved in the `API-Version` response header (and lists the header in
148
+ `Vary`), and this holds through the same-origin `/api/graphql` proxy too.
149
+ The SDK's methods return parsed data only and do not surface response
150
+ headers, so confirm the pin once with the browser's network inspector or
151
+ a direct `fetch` to the same endpoint, not through an SDK return value:
152
+
153
+ ```ts
154
+ const res = await fetch('/api/graphql', {
155
+ method: 'POST',
156
+ headers: { 'content-type': 'application/json', 'API-Version': '2027-01-01' },
157
+ body: JSON.stringify({ query: '{ __typename }' }),
158
+ });
159
+ res.headers.get('API-Version'); // '2027-01-01' when the pin is honoured
160
+ ```
161
+
162
+ A value that is not a date is rejected with a 400 whose body is a GraphQL
163
+ errors payload; the SDK raises it as a `LoopwiseError` with code
164
+ `'GRAPHQL'` and `graphqlErrors[0].extensions.code === 'INVALID_API_VERSION'`,
165
+ so a malformed pin does fail loudly through the SDK.
166
+
123
167
  ## Preview modes
124
168
 
125
169
  Two preview surfaces exist, each with different capability:
126
170
 
127
- | Surface | Origin | Same-origin BFF? | Authenticated viewer? | Use for |
171
+ | Surface | URL | Hosted on the site? | Authenticated viewer? | Use for |
128
172
  |---|---|---|---|---|
129
- | **Loopwise Pages preview** | `<scriptName>.loopwise.page` | ❌ cross-origin | ❌ no first-party cookies | Anonymous storefront, marketing pages, public listings |
130
- | **Tenant-origin preview** | `<tenant-origin>/_preview/<scriptName>/` | ✅ same-origin | ✅ admin can preview as a logged-in viewer | Cart, checkout, member-gated content, full storefront |
173
+ | **Loopwise Pages preview** | `<scriptName>.loopwise.page` | ❌ separate origin | ❌ no first-party cookies | Anonymous storefront, marketing pages, public listings |
174
+ | **Site-origin preview** | `<site>/_preview/<scriptName>/` | ✅ same origin as the site | ✅ admins can preview signed in | Cart, checkout, member-gated content, full storefront |
131
175
 
132
- The tenant origin can be `<sub>.kaik.io`, `<sub>.myteachify.com`, or a
133
- custom FQDN like `nii.school`. `deploy_static_page` returns both
134
- surfaces (`preview_url` + `tenant_preview_url_template` with a
135
- `<TENANT_ORIGIN>` placeholder).
176
+ The site URL can be `<sub>.kaik.io`, `<sub>.myteachify.com`, or a custom
177
+ domain like `nii.school`. `deploy_static_page` returns both URLs in its
178
+ response.
136
179
 
137
- Pick the surface that matches what you're testing — anonymous storefront
138
- reads on Surface A (faster, edge-cacheable), authenticated flows on
139
- Surface B (logged-in only, never edge-cached). Surface B requires
140
- relative asset paths in your bundle (the proxy injects `<base>` so
141
- relative paths resolve under `/_preview/<scriptName>/`; root-absolute
142
- paths like `/style.css` still go to the tenant root and 404).
180
+ Pick the surface that matches what you're testing — anonymous reads on
181
+ Surface A (faster, edge-cacheable), authenticated flows on Surface B
182
+ (signed-in only, never edge-cached). Surface B requires relative asset
183
+ paths in your bundle (the proxy injects `<base>` so relative paths
184
+ resolve under `/_preview/<scriptName>/`; root-absolute paths like
185
+ `/style.css` still go to the site root and 404).
143
186
 
144
187
  ## React — coming soon
145
188
 
146
- A `@loopwise/sdk-react` companion package is planned, providing:
189
+ A `@loopwise/react` companion package is planned, providing:
147
190
 
148
191
  - `<LoopwiseProvider client={loopwise}>` context provider
149
192
  - `useCourses({ limit })`, `useCourse({ slug })` hooks with Suspense support
@@ -155,10 +198,12 @@ your own state (`useEffect` + `useState`, TanStack Query, etc.).
155
198
 
156
199
  ## Public API
157
200
 
158
- - `createClient(config)` — storefront client. `config.tenant` is optional in
159
- the browser when the page is on a tenant origin (BFF derives it from
160
- host); required cross-origin and in SSR / build.
201
+ - `createClient(config)` — storefront client. Pass `config.site` only
202
+ when calling a different site than the one your code is running on
203
+ (see [Calling a different site](#calling-a-different-site)).
161
204
  - `loopwise.courses.list(args, options?)` / `.get(args, options?)`.
205
+ - `loopwise.posts.list(args, options?)` / `.get(args, options?)`.
206
+ - `loopwise.orders.get(args, options?)` — post-checkout pixel-tracking pattern; see the method's JSDoc for the `?tradeNo=…` → `fbq('track', 'Purchase', …)` recipe.
162
207
  - `loopwise.batch(builders, options?)` — composes N queries into one HTTP request.
163
208
  - `loopwise.query<T>(document, variables?, operationName?, options?)` — raw GraphQL escape hatch.
164
209
 
package/dist/config.d.mts CHANGED
@@ -1,10 +1,22 @@
1
1
  //#region src/config.d.ts
2
2
  interface LoopwiseConfig {
3
3
  /**
4
- * Tenant identifier (subdomain "acme" or full FQDN "nii.school").
4
+ * The site this client reads from. Accepts a subdomain (`'acme'`) or a
5
+ * full domain (`'acme.myteachify.com'`, `'nii.school'`).
5
6
  *
6
- * Optional in browser runtime — SDK auto-resolves from `window.location.hostname`
7
- * (e.g. `acme.kaik.io` -> `"acme"`). Required in build/SSR runtime.
7
+ * Omit when your code runs on the site itself — the SDK calls the
8
+ * page's own origin. Required when running off-origin: cross-domain
9
+ * embeds (Loopwise Pages previews at `*.loopwise.page`), SSR / build
10
+ * pipelines, server-to-server tooling.
11
+ *
12
+ * Cross-origin requests carry no first-party cookies, so authenticated
13
+ * calls (`cart`, `viewer.profile`, mutations that need a signed-in
14
+ * visitor) won't work in that mode — public reads do.
15
+ */
16
+ site?: string;
17
+ /**
18
+ * @deprecated Renamed to `site`. Both fields work for now; if both are
19
+ * set, `site` wins. **Will be removed in `0.2.0`.**
8
20
  */
9
21
  tenant?: string;
10
22
  /**
@@ -15,11 +27,22 @@ interface LoopwiseConfig {
15
27
  */
16
28
  apiKey?: string;
17
29
  /**
18
- * Override the GraphQL endpoint. Defaults to:
19
- * - Browser: same-origin `/api/graphql`
20
- * - Build/SSR: `https://{tenant}/api/graphql` (or kaik.io fallback)
30
+ * Override the GraphQL endpoint. You normally never set this — the SDK
31
+ * picks the right URL from `site` (or the page's own origin when `site`
32
+ * is omitted).
21
33
  */
22
34
  endpoint?: string;
35
+ /**
36
+ * Pin the storefront schema to a release date (`'2027-01-01'`). Sent as
37
+ * the `API-Version` header on every request; omit it to get the baseline
38
+ * schema. The server echoes the resolved version in the `API-Version`
39
+ * response header; SDK methods return parsed data only, so read that
40
+ * header with a network inspector or a direct `fetch`. A value that is
41
+ * not a date is rejected with a 400 whose GraphQL error carries
42
+ * `extensions.code: 'INVALID_API_VERSION'` (surfaced as a
43
+ * `LoopwiseError` with code `'GRAPHQL'`).
44
+ */
45
+ apiVersion?: string;
23
46
  }
24
47
  declare function defineConfig(config: LoopwiseConfig): LoopwiseConfig;
25
48
  //#endregion
package/dist/index.d.mts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { LoopwiseConfig } from "./config.mjs";
2
-
3
2
  //#region src/error.d.ts
4
- type LoopwiseErrorCode = 'NETWORK' | 'AUTH' | 'GRAPHQL' | 'RATE_LIMIT' | 'TENANT_NOT_FOUND' | 'CONFIG';
3
+ type LoopwiseErrorCode = "NETWORK" | "AUTH" | "GRAPHQL" | "RATE_LIMIT" | "CONFIG";
5
4
  interface GraphQLError {
6
5
  message: string;
7
6
  path?: (string | number)[];
@@ -9,11 +8,16 @@ interface GraphQLError {
9
8
  }
10
9
  declare class LoopwiseError extends Error {
11
10
  readonly code: LoopwiseErrorCode;
11
+ /** The site this client was configured for. */
12
+ readonly site?: string;
13
+ /** @deprecated Renamed to `site`. Holds the same resolved value. Removed in 0.2.0. */
12
14
  readonly tenant?: string;
13
15
  readonly endpoint?: string;
14
16
  readonly graphqlErrors?: GraphQLError[];
15
17
  readonly requestId?: string;
16
18
  constructor(code: LoopwiseErrorCode, message: string, detail?: {
19
+ site?: string;
20
+ /** @deprecated Renamed to `site`. Removed in 0.2.0. */
17
21
  tenant?: string;
18
22
  endpoint?: string;
19
23
  graphqlErrors?: GraphQLError[];
@@ -22,9 +26,9 @@ declare class LoopwiseError extends Error {
22
26
  }
23
27
  //#endregion
24
28
  //#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;
29
+ type SelectionInner<T> = T extends Array<infer U> ? SelectionInner<U> : T extends object ? { [K in keyof T]?: SelectionInner<T[K]>; } : true;
26
30
  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;
31
+ 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
32
  type Selected<T, S> = SelectedInner<T, S>;
29
33
  //#endregion
30
34
  //#region src/query.d.ts
@@ -60,7 +64,7 @@ interface FetchOptions {
60
64
  * `Cache-Control: public, s-maxage=<seconds>`. `'no-store'` forces POST and
61
65
  * skips any cache hint. Omit for the legacy POST behaviour.
62
66
  */
63
- cache?: number | 'no-store';
67
+ cache?: number | "no-store";
64
68
  }
65
69
  interface BatchedFetcher {
66
70
  /**
@@ -79,9 +83,7 @@ declare class QueryBuilder<T> implements PromiseLike<T> {
79
83
  //#endregion
80
84
  //#region src/client.d.ts
81
85
  declare class LoopwiseClient implements BatchedFetcher {
82
- readonly config: {
83
- tenant?: string;
84
- } & LoopwiseConfig;
86
+ readonly config: LoopwiseConfig;
85
87
  private endpoint;
86
88
  constructor(config: LoopwiseConfig);
87
89
  private defaultEndpoint;
@@ -94,7 +96,7 @@ declare class LoopwiseClient implements BatchedFetcher {
94
96
  raw<T = unknown>(document: string, variables?: Record<string, unknown>, operationName?: string, options?: FetchOptions): Promise<T>;
95
97
  batch<R extends Record<string, unknown>>(builders: { [K in keyof R]: {
96
98
  descriptor: QueryDescriptor;
97
- } }, options?: FetchOptions): Promise<R>;
99
+ }; }, options?: FetchOptions): Promise<R>;
98
100
  private fetchGraphQL;
99
101
  private shouldUseGet;
100
102
  private buildGetUrl;
@@ -142,7 +144,7 @@ interface Post {
142
144
  /** Known values: `login_required`, `paid`, `public_access`. The
143
145
  * `string & {}` escape hatch keeps autocomplete on the known set
144
146
  * while accepting forward-compat values the schema may add. */
145
- accessType: 'login_required' | 'paid' | 'public_access' | (string & {});
147
+ accessType: "login_required" | "paid" | "public_access" | (string & {});
146
148
  tags: string[] | null;
147
149
  noindex: boolean;
148
150
  author: Lecturer | null;
@@ -159,7 +161,7 @@ interface ListResult<T> {
159
161
  pageInfo: PageInfo;
160
162
  }
161
163
  /** Simplified order status. Mirrors the storefront `Order.status` enum. */
162
- type OrderStatus = 'PAID' | 'PENDING' | 'FAILED' | 'REFUNDED';
164
+ type OrderStatus = "PAID" | "PENDING" | "FAILED" | "REFUNDED";
163
165
  interface OrderItem {
164
166
  /**
165
167
  * Meta-pixel-aligned identifier (Lineitem#tracking_id server-side).
@@ -201,7 +203,7 @@ declare class CoursesResource {
201
203
  private client;
202
204
  constructor(client: LoopwiseClient);
203
205
  /**
204
- * List courses for the current tenant.
206
+ * List published courses.
205
207
  * @example
206
208
  * const { items, pageInfo } = await loopwise.courses.list({ limit: 20 })
207
209
  * @example
@@ -248,9 +250,8 @@ declare class OrdersResource {
248
250
  * Look up a PII-safe order projection by id (the value the storefront
249
251
  * appends to a merchant's `checkoutSuccessUrl` as `?tradeNo=...`).
250
252
  *
251
- * Returns `null`-shaped result via the GraphQL `null` payload when the
252
- * id doesn't resolve in the current tenant — does NOT throw. Use the
253
- * returned `status` to gate Meta Pixel `Purchase` events:
253
+ * Returns `null` when the id doesn't resolve on this site — does NOT
254
+ * throw. Use the returned `status` to gate Meta Pixel `Purchase` events:
254
255
  *
255
256
  * @example
256
257
  * const id = new URLSearchParams(location.search).get('tradeNo');
@@ -289,7 +290,7 @@ declare class PostsResource {
289
290
  private client;
290
291
  constructor(client: LoopwiseClient);
291
292
  /**
292
- * List published posts for the current tenant, ordered by publishedAt desc.
293
+ * List published posts, ordered by publishedAt desc.
293
294
  * @example
294
295
  * const { items, pageInfo } = await loopwise.posts.list({ limit: 10 })
295
296
  * @example
@@ -330,13 +331,22 @@ interface Loopwise {
330
331
  * recent: loopwise.courses.list({ limit: 3, page: 2 }),
331
332
  * }, { cache: 60 })
332
333
  */
333
- batch<R extends Record<string, QueryBuilder<unknown>>>(builders: R, options?: FetchOptions): Promise<{ [K in keyof R]: R[K] extends QueryBuilder<infer T> ? T : never }>;
334
+ batch<R extends Record<string, QueryBuilder<unknown>>>(builders: R, options?: FetchOptions): Promise<{ [K in keyof R]: R[K] extends QueryBuilder<infer T> ? T : never; }>;
334
335
  /**
335
336
  * Raw GraphQL escape hatch. Use when a typed resource method doesn't cover
336
337
  * what you need (new schema fields, custom queries, ad-hoc inspection).
338
+ * Variables are sent as-is; errors are still mapped to LoopwiseError.
339
+ *
340
+ * Most storefront fields (`courses`, `posts`, `order`, ...) take a
341
+ * required `subdomain: String!` argument. The BFF rewrites that variable
342
+ * to the trusted server-derived value on every request, so consumers
343
+ * declaring `$subdomain` see whatever value the BFF binds — not what
344
+ * they pass.
345
+ *
337
346
  * @example
338
- * const data = await loopwise.query<{ school: { name: string } }>(`
339
- * query { school(subdomain: "acme") { name } }
347
+ * // Minimal shape demo (always valid, no scoping required):
348
+ * const data = await loopwise.query<{ __typename: string }>(`
349
+ * query { __typename }
340
350
  * `)
341
351
  */
342
352
  query<T = unknown>(document: string, variables?: Record<string, unknown>, operationName?: string, options?: FetchOptions): Promise<T>;
package/dist/index.mjs CHANGED
@@ -1,6 +1,9 @@
1
1
  //#region src/error.ts
2
2
  var LoopwiseError = class extends Error {
3
3
  code;
4
+ /** The site this client was configured for. */
5
+ site;
6
+ /** @deprecated Renamed to `site`. Holds the same resolved value. Removed in 0.2.0. */
4
7
  tenant;
5
8
  endpoint;
6
9
  graphqlErrors;
@@ -9,7 +12,9 @@ var LoopwiseError = class extends Error {
9
12
  super(message);
10
13
  this.name = "LoopwiseError";
11
14
  this.code = code;
12
- this.tenant = detail.tenant;
15
+ const site = detail.site ?? detail.tenant;
16
+ this.site = site;
17
+ this.tenant = site;
13
18
  this.endpoint = detail.endpoint;
14
19
  this.graphqlErrors = detail.graphqlErrors;
15
20
  this.requestId = detail.requestId;
@@ -29,20 +34,22 @@ var LoopwiseClient = class {
29
34
  config;
30
35
  endpoint;
31
36
  constructor(config) {
32
- const tenant = config.tenant ?? void 0;
33
- if (detectRuntime() !== "browser" && !tenant) throw new LoopwiseError("CONFIG", "tenant is required outside browser runtime. Pass it to createClient({ tenant }).");
37
+ const site = config.site ?? config.tenant;
38
+ if (detectRuntime() !== "browser" && !site) throw new LoopwiseError("CONFIG", "site is required outside browser runtime. Pass it to createClient({ site }).");
39
+ if (config.apiVersion !== void 0 && config.apiVersion.trim() === "") throw new LoopwiseError("CONFIG", "apiVersion must be a release date (YYYY-MM-DD) or omitted; a blank value would be served as the baseline schema.");
34
40
  this.config = {
35
41
  ...config,
36
- tenant
42
+ site,
43
+ tenant: site
37
44
  };
38
45
  this.endpoint = config.endpoint ?? this.defaultEndpoint();
39
46
  }
40
47
  defaultEndpoint() {
41
- const tenant = this.config.tenant;
42
- if (detectRuntime() === "browser" && !tenant) return "/api/graphql";
43
- const t = tenant;
44
- if (t.includes(".")) return `https://${t}/api/graphql`;
45
- return `https://${t}.kaik.io/api/graphql`;
48
+ const site = this.config.site;
49
+ if (detectRuntime() === "browser" && !site) return "/api/graphql";
50
+ const s = site;
51
+ if (s.includes(".")) return `https://${s}/api/graphql`;
52
+ return `https://${s}.kaik.io/api/graphql`;
46
53
  }
47
54
  async execute(descriptor, options = {}) {
48
55
  const document = renderSingleDocument(descriptor);
@@ -95,6 +102,7 @@ var LoopwiseClient = class {
95
102
  const headers = { accept: "application/graphql-response+json, application/json" };
96
103
  const apiKey = this.resolveApiKey();
97
104
  if (apiKey) headers.authorization = `Bearer ${apiKey}`;
105
+ if (this.config.apiVersion) headers["api-version"] = this.config.apiVersion;
98
106
  const useGet = this.shouldUseGet(payload, options);
99
107
  if (typeof options.cache === "number" && options.cache > 0) headers["x-loopwise-cache"] = String(options.cache);
100
108
  let response;
@@ -115,26 +123,31 @@ var LoopwiseClient = class {
115
123
  });
116
124
  } catch (err) {
117
125
  throw new LoopwiseError("NETWORK", `Network error: ${err.message}`, {
118
- tenant: this.config.tenant,
126
+ site: this.config.site,
119
127
  endpoint: this.endpoint
120
128
  });
121
129
  }
122
130
  if (response.status === 401 || response.status === 403) throw new LoopwiseError("AUTH", `Auth failed (${response.status})`, {
123
- tenant: this.config.tenant,
131
+ site: this.config.site,
124
132
  endpoint: this.endpoint,
125
133
  requestId: response.headers.get("x-request-id") ?? void 0
126
134
  });
127
135
  if (response.status === 429) throw new LoopwiseError("RATE_LIMIT", "Rate limited", {
128
- tenant: this.config.tenant,
136
+ site: this.config.site,
129
137
  endpoint: this.endpoint
130
138
  });
131
- if (!response.ok) throw new LoopwiseError("NETWORK", `Upstream ${response.status}`, {
132
- tenant: this.config.tenant,
133
- endpoint: this.endpoint
134
- });
135
- const body = await response.json();
139
+ let body;
140
+ if (response.ok) body = await response.json();
141
+ else {
142
+ const errorBody = await parseGraphQLErrorBody(response);
143
+ if (!errorBody) throw new LoopwiseError("NETWORK", `Upstream ${response.status}`, {
144
+ site: this.config.site,
145
+ endpoint: this.endpoint
146
+ });
147
+ body = errorBody;
148
+ }
136
149
  if (body.errors?.length) throw new LoopwiseError("GRAPHQL", body.errors[0]?.message ?? "GraphQL error", {
137
- tenant: this.config.tenant,
150
+ site: this.config.site,
138
151
  endpoint: this.endpoint,
139
152
  graphqlErrors: body.errors
140
153
  });
@@ -162,6 +175,18 @@ var LoopwiseClient = class {
162
175
  return globalThis.process?.env?.LOOPWISE_API_KEY;
163
176
  }
164
177
  };
178
+ async function parseGraphQLErrorBody(response) {
179
+ let parsed;
180
+ try {
181
+ parsed = JSON.parse(await response.text());
182
+ } catch {
183
+ return null;
184
+ }
185
+ if (!parsed || typeof parsed !== "object") return null;
186
+ const errors = parsed.errors;
187
+ if (!Array.isArray(errors) || errors.length === 0) return null;
188
+ return errors.every((e) => e !== null && typeof e === "object" && typeof e.message === "string") ? parsed : null;
189
+ }
165
190
  function firstOperationKeyword(document) {
166
191
  let i = 0;
167
192
  if (document.charCodeAt(0) === 65279) i = 1;
@@ -319,7 +344,7 @@ const DEFAULT_GET_FIELDS$2 = {
319
344
  function tenantVars$2(client) {
320
345
  return { subdomain: {
321
346
  type: "String!",
322
- value: client.config.tenant ?? ""
347
+ value: client.config.site ?? ""
323
348
  } };
324
349
  }
325
350
  var CoursesResource = class {
@@ -328,7 +353,7 @@ var CoursesResource = class {
328
353
  this.client = client;
329
354
  }
330
355
  /**
331
- * List courses for the current tenant.
356
+ * List published courses.
332
357
  * @example
333
358
  * const { items, pageInfo } = await loopwise.courses.list({ limit: 20 })
334
359
  * @example
@@ -433,7 +458,7 @@ const DEFAULT_GET_FIELDS$1 = {
433
458
  function tenantVars$1(client) {
434
459
  return { subdomain: {
435
460
  type: "String!",
436
- value: client.config.tenant ?? ""
461
+ value: client.config.site ?? ""
437
462
  } };
438
463
  }
439
464
  var OrdersResource = class {
@@ -445,9 +470,8 @@ var OrdersResource = class {
445
470
  * Look up a PII-safe order projection by id (the value the storefront
446
471
  * appends to a merchant's `checkoutSuccessUrl` as `?tradeNo=...`).
447
472
  *
448
- * Returns `null`-shaped result via the GraphQL `null` payload when the
449
- * id doesn't resolve in the current tenant — does NOT throw. Use the
450
- * returned `status` to gate Meta Pixel `Purchase` events:
473
+ * Returns `null` when the id doesn't resolve on this site — does NOT
474
+ * throw. Use the returned `status` to gate Meta Pixel `Purchase` events:
451
475
  *
452
476
  * @example
453
477
  * const id = new URLSearchParams(location.search).get('tradeNo');
@@ -563,7 +587,7 @@ const DEFAULT_GET_FIELDS = {
563
587
  function tenantVars(client) {
564
588
  return { subdomain: {
565
589
  type: "String!",
566
- value: client.config.tenant ?? ""
590
+ value: client.config.site ?? ""
567
591
  } };
568
592
  }
569
593
  var PostsResource = class {
@@ -572,7 +596,7 @@ var PostsResource = class {
572
596
  this.client = client;
573
597
  }
574
598
  /**
575
- * List published posts for the current tenant, ordered by publishedAt desc.
599
+ * List published posts, ordered by publishedAt desc.
576
600
  * @example
577
601
  * const { items, pageInfo } = await loopwise.posts.list({ limit: 10 })
578
602
  * @example
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loopwise/sdk",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.4",
4
4
  "description": "Loopwise platform SDK — agent-friendly headless API client",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,21 +37,23 @@
37
37
  "files": [
38
38
  "dist/*.mjs",
39
39
  "dist/*.d.mts",
40
- "README.md"
40
+ "README.md",
41
+ "CHANGELOG.md"
41
42
  ],
42
43
  "scripts": {
43
44
  "build": "tsdown",
44
45
  "dev": "tsdown --watch",
45
- "typecheck": "tsc --noEmit",
46
+ "typecheck": "tsgo --noEmit",
46
47
  "test": "vitest run",
47
48
  "regen:lock": "node scripts/regen-lock.mjs"
48
49
  },
49
50
  "devDependencies": {
51
+ "@kaikhq/graphql-proxy": "workspace:*",
50
52
  "graphql": "16.13.1",
51
53
  "tsconfig": "workspace:*",
52
- "tsdown": "0.22.0",
54
+ "tsdown": "catalog:",
53
55
  "typescript": "5.9.3",
54
- "vitest": "4.1.0"
56
+ "vitest": "catalog:"
55
57
  },
56
58
  "publishConfig": {
57
59
  "access": "public"