@commerce-blocks/sdk 2.0.0-alpha.1 → 2.0.0-alpha.3

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.
Files changed (4) hide show
  1. package/README.md +313 -758
  2. package/dist/index.d.ts +328 -636
  3. package/dist/index.js +1176 -1946
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @commerce-blocks/sdk
2
2
 
3
- ES module SDK powered by Layers API with optional Shopify Storefront enrichment.
3
+ ES module SDK for product discovery browse collections, search, recommendations, and image search via Layers API.
4
4
 
5
5
  ## Installation
6
6
 
@@ -11,654 +11,321 @@ npm install @commerce-blocks/sdk
11
11
  ## Quick Start
12
12
 
13
13
  ```typescript
14
- import { createSdk } from '@commerce-blocks/sdk'
14
+ import { createClient } from '@commerce-blocks/sdk'
15
15
 
16
- const result = createSdk({
17
- // Layers API
18
- layersPublicToken: 'your-layers-token',
16
+ const { data: client, error } = createClient({
17
+ token: 'your-layers-token',
19
18
  sorts: [
20
- { label: 'Featured', code: 'featured' },
21
- { label: 'Price: Low to High', code: 'price_asc' },
19
+ { name: 'Featured', code: 'featured' },
20
+ { name: 'Price: Low to High', code: 'price_asc' },
21
+ ],
22
+ facets: [
23
+ { name: 'Color', code: 'options.color' },
24
+ { name: 'Size', code: 'options.size' },
22
25
  ],
23
- facets: ['options.color', 'options.size', 'vendor'],
24
-
25
- // Opt in to Storefront API for additional product data
26
- // enableStorefront: true,
27
- // shop: 'your-store.myshopify.com',
28
- // storefrontPublicToken: 'your-storefront-token',
29
26
  })
30
27
 
31
- if (result.error) {
32
- console.error('SDK init failed:', result.error.message)
33
- } else {
34
- const sdk = result.data
35
- // Ready to use
36
- }
37
- ```
38
-
39
- ## Configuration
40
-
41
- ### Layers
42
-
43
- | Option | Type | Required | Description |
44
- | ------------------- | ------------- | -------- | ---------------------------------------------------- |
45
- | `layersPublicToken` | `string` | Yes | Layers API public token |
46
- | `sorts` | `Sort[]` | Yes | Sort options (`{ label, code }`) |
47
- | `facets` | `string[]` | Yes | Facet fields for filtering |
48
- | `attributes` | `string[]` | No | Product attributes to fetch |
49
- | `layersBaseUrl` | `string` | No | Custom API URL |
50
- | `fetch` | `CustomFetch` | No | Custom fetch implementation (SSR, testing, proxying) |
28
+ if (error) throw new Error(error.message)
51
29
 
52
- ### Storefront (optional)
53
-
54
- Storefront API is opt-in. Enable it for collection/page metadata and Shopify-specific fields (metafields, variant detail).
55
-
56
- | Option | Type | Required | Description |
57
- | ----------------------- | ------------- | ----------------------------- | ---------------------------------------------------- |
58
- | `enableStorefront` | `boolean` | No | Enable Storefront API hydration (default: `false`) |
59
- | `shop` | `string` | When `enableStorefront: true` | Store domain |
60
- | `storefrontPublicToken` | `string` | When `enableStorefront: true` | Storefront API public access token |
61
- | `storefrontApiVersion` | `string` | No | API version (default: `2025-01`) |
62
- | `fetch` | `CustomFetch` | No | Custom fetch implementation (SSR, testing, proxying) |
63
-
64
- Layers API supports identity tracking for personalization. Pass identity fields via request context:
30
+ const collection = client.collection({ handle: 'shirts' })
31
+ const result = await collection.execute()
65
32
 
66
- ```typescript
67
- // Identity fields available in browse/search contexts
68
- interface LayersIdentity {
69
- deviceId?: string // Device identifier
70
- sessionId?: string // Session identifier
71
- customerId?: string // Customer identifier
33
+ if (result.data) {
34
+ console.log(result.data.products)
35
+ console.log(result.data.totalResults)
72
36
  }
73
- ```
74
-
75
- ### Product
76
-
77
- | Option | Type | Description |
78
- | ---------------------- | ------------------------------ | ------------------------------ |
79
- | `currencyCode` | `string` | Currency for price formatting |
80
- | `formatPrice` | `(amount, currency) => string` | Custom price formatter |
81
- | `swatches` | `Swatch[]` | Color swatch definitions |
82
- | `options` | `string[]` | Product options to expose |
83
- | `productMetafields` | `{ namespace, key }[]` | Product metafields to fetch |
84
- | `variantMetafields` | `{ namespace, key }[]` | Variant metafields to fetch |
85
- | `collectionMetafields` | `{ namespace, key }[]` | Collection metafields to fetch |
86
- | `pageMetafields` | `{ namespace, key }[]` | Page metafields to fetch |
87
37
 
88
- ### Extensibility
38
+ collection.dispose()
39
+ ```
89
40
 
90
- | Option | Type | Description |
91
- | ------------------ | ---------------------------------- | ---------------------------------- |
92
- | `extendProduct` | `({ base, raw, storefront }) => P` | Transform products after hydration |
93
- | `extendCollection` | `(result, raw) => result` | Transform collection results |
94
- | `extendSearch` | `(result, raw) => result` | Transform search results |
95
- | `extendBlock` | `(result, raw) => result` | Transform blocks results |
96
- | `transformFilters` | `(filters) => FilterGroup` | Custom filter transformation |
97
- | `filterMap` | `FilterMap` | URL-friendly filter key mapping |
41
+ ## Configuration
98
42
 
99
- Once configured, extenders and transformers are applied automatically. You pass simple inputs and receive transformed outputs—no manual transformation needed on each call.
43
+ | Option | Type | Required | Description |
44
+ | ------------------- | ------------------------------ | -------- | ------------------------------------ |
45
+ | `token` | `string` | Yes | Layers API public token |
46
+ | `sorts` | `Sort[]` | Yes | Sort options `{ name, code }` |
47
+ | `facets` | `Facet[]` | Yes | Facet fields `{ name, code }` |
48
+ | `attributes` | `string[]` | No | Product attributes to fetch |
49
+ | `baseUrl` | `string` | No | Custom API URL |
50
+ | `fetch` | `CustomFetch` | No | Custom fetch (SSR, testing) |
51
+ | `currency` | `string` | No | Currency for price formatting |
52
+ | `formatPrice` | `(amount, currency) => string` | No | Custom price formatter |
53
+ | `swatches` | `Swatch[]` | No | Color swatch definitions |
54
+ | `options` | `string[]` | No | Product options to expose |
55
+ | `productMetafields` | `{ namespace, key }[]` | No | Product metafields to fetch |
56
+ | `variantMetafields` | `{ namespace, key }[]` | No | Variant metafields to fetch |
57
+ | `transforms` | `Transforms` | No | Post-process results (see below) |
58
+ | `filterAliases` | `FilterAliases` | No | URL-friendly filter key mapping |
59
+ | `cacheLimit` | `number` | No | Max entries in cache |
60
+ | `cacheLifetime` | `number` | No | TTL in milliseconds |
61
+ | `storage` | `StorageAdapter` | No | Custom storage adapter |
62
+ | `initialData` | `CacheData` | No | Pre-populate cache at init |
63
+ | `restoreCache` | `boolean` | No | Restore from storage (default: true) |
64
+
65
+ ## Controllers
66
+
67
+ All controllers (`collection`, `blocks`, `search`, `suggest`) follow the same pattern:
68
+
69
+ - **`state`** — a `ReadonlySignal<QueryState<T>>` with `{ data, error, isFetching }`
70
+ - **`execute()`** — runs the query, returns `Result<T, ClientError>`
71
+ - **`subscribe(callback)`** — reacts to state changes without importing signals
72
+ - **`dispose()`** — cleans up subscriptions and aborts pending requests
73
+
74
+ ### Subscribing to State
75
+
76
+ Three ways to consume controller state (pick one):
100
77
 
101
78
  ```typescript
102
- // Configure once at initialization
103
- const sdk = createSdk({
104
- // ... base config
105
- extendProduct: ({ base, raw, storefront }) => ({
106
- ...base,
107
- isNew: raw.tags?.includes('new') ?? false,
108
- rating: raw.calculated?.average_rating,
109
- }),
110
- filterMap: {
111
- color: 'options.color',
112
- size: 'options.size',
113
- },
79
+ // 1. Controller subscribe — no signal import needed
80
+ const unsubscribe = controller.subscribe(({ data, error, isFetching }) => {
81
+ if (isFetching) showLoading()
82
+ if (error) showError(error.message)
83
+ if (data) render(data.products)
114
84
  })
115
85
 
116
- // All SDK methods automatically use your transformers
117
- const collection = sdk.collection({ handle: 'shirts' })
118
- await collection.execute({ filters: { color: 'Red' } }) // filterMap applied
119
- // result.products have isNew and rating properties
120
- ```
121
-
122
- ### Cache
123
-
124
- | Option | Type | Description |
125
- | ------------------ | -------- | -------------------------- |
126
- | `cacheMaxProducts` | `number` | Max products in cache |
127
- | `cacheMaxEntries` | `number` | Max query entries in cache |
128
- | `cacheTtl` | `number` | TTL in milliseconds |
129
-
130
- ### Data Hydration
86
+ // 2. Standalone subscribe works with any signal
87
+ import { subscribe } from '@commerce-blocks/sdk'
88
+ const unsubscribe = subscribe(controller.state, (state) => {
89
+ /* ... */
90
+ })
131
91
 
132
- | Option | Type | Description |
133
- | -------------------- | --------------------------------------- | ----------------------------------------- |
134
- | `initialData` | `{ products?, queries?, collections? }` | Pre-populate cache at init |
135
- | `restoreFromStorage` | `boolean` | Auto-restore from storage (default: true) |
92
+ // 3. Direct signal access — for custom reactivity
93
+ import { effect } from '@commerce-blocks/sdk'
94
+ effect(() => {
95
+ const { data } = controller.state.value
96
+ })
97
+ ```
136
98
 
137
- ## SDK Methods
99
+ ### Shared Query Parameters
138
100
 
139
- ### `sdk.collection()` - Browse Collections
101
+ These parameters are available on `execute()` for all controllers except suggest:
140
102
 
141
- Creates a collection controller for browsing products in a collection. Controllers expose two ways to consume results:
103
+ | Parameter | Type | Description |
104
+ | ------------------ | ------------------------- | ---------------------------------- |
105
+ | `page` | `number` | Page number (default: 1) |
106
+ | `limit` | `number` | Products per page (default: 24) |
107
+ | `filters` | `unknown` | Filter criteria |
108
+ | `signal` | `AbortSignal` | Per-call abort signal |
109
+ | `linking` | `Record<string, unknown>` | Dynamic linking parameters |
110
+ | `transformRequest` | `(body) => body` | Custom request body transformation |
142
111
 
143
- - **Reactive**: Subscribe to `controller.state` (a `ReadonlySignal<QueryState<T>>`) via `effect()` — state updates automatically on each execute
144
- - **Imperative**: `await controller.execute()` returns `Result<T, SdkError>` directly
112
+ ### Collection
145
113
 
146
- This pattern applies to all controllers (`collection`, `search`, `blocks`).
114
+ Browse products in a collection.
147
115
 
148
116
  ```typescript
149
- import { effect } from '@preact/signals-core'
150
-
151
- const collection = sdk.collection({
117
+ const collection = client.collection({
152
118
  handle: 'shirts',
153
119
  defaultSort: 'featured', // optional, uses first sort if omitted
154
120
  })
155
121
 
156
- // Subscribe to state changes
157
- effect(() => {
158
- const { data, error, isFetching } = collection.state.value
159
- if (isFetching) console.log('Loading...')
160
- if (error) console.error('Error:', error.message)
161
- if (data) console.log('Products:', data.products)
162
- })
163
-
164
- // Execute queries — returns Result<CollectionResult, SdkError>
165
- const result = await collection.execute() // initial load
166
- if (result.error) console.error(result.error.message)
167
- if (result.data) console.log('Got', result.data.totalResults, 'results')
122
+ await collection.execute() // initial load
123
+ await collection.execute({ page: 2 }) // paginate
124
+ await collection.execute({ sort: 'price_asc' }) // change sort
125
+ await collection.execute({ filters: { color: 'Red' } })
126
+ await collection.execute({ includeMeta: true }) // fetch collection metadata
168
127
 
169
- await collection.execute({ page: 2 }) // pagination
170
- await collection.execute({ sortOrderCode: 'price_asc' }) // change sort
171
- await collection.execute({ filters: { color: 'Red' } }) // with filters
172
-
173
- // Cleanup
174
128
  collection.dispose()
175
129
  ```
176
130
 
177
- **Options:**
178
-
179
- | Parameter | Type | Required | Description |
180
- | ------------- | -------- | -------- | ---------------------------------------------- |
181
- | `handle` | `string` | Yes | Collection URL handle |
182
- | `defaultSort` | `string` | No | Default sort code (uses first configured sort) |
131
+ Additional `execute()` params: `sort`, `includeMeta`.
183
132
 
184
- **Execute parameters:**
133
+ ### Blocks
185
134
 
186
- | Parameter | Type | Description |
187
- | ---------------- | ------------------------- | ------------------------------------- |
188
- | `page` | `number` | Page number (default: 1) |
189
- | `limit` | `number` | Products per page (default: 24) |
190
- | `sortOrderCode` | `string` | Sort option code |
191
- | `filters` | `unknown` | Filter criteria |
192
- | `signal` | `AbortSignal` | External abort signal |
193
- | `includeMeta` | `boolean` | Fetch collection metadata |
194
- | `includeFilters` | `boolean` | Include filter counts in response |
195
- | `dynamicLinking` | `Record<string, unknown>` | Custom dynamic linking parameters |
196
- | `params` | `Record<string, unknown>` | Additional request parameters |
197
- | `transformBody` | `(body) => body` | Custom request body mutation function |
198
-
199
- ### `sdk.blocks()` - Product Recommendations
200
-
201
- Creates a blocks controller for product recommendations powered by Layers blocks. Blocks can be anchored to a product, collection, or cart for contextual recommendations.
135
+ Product recommendations powered by Layers blocks. Anchored to a product, collection, or cart.
202
136
 
203
137
  ```typescript
204
- const blocks = sdk.blocks({
138
+ const blocks = client.blocks({
205
139
  blockId: 'block-abc123',
206
- anchorHandle: 'gold-necklace', // anchor by product handle
207
- })
208
-
209
- // Subscribe to state changes
210
- effect(() => {
211
- const { data, error, isFetching } = blocks.state.value
212
- if (data) {
213
- console.log('Recommendations:', data.products)
214
- console.log('Block info:', data.block) // { title, anchor_type, strategy_type, ... }
215
- }
140
+ anchor: 'gold-necklace', // product/collection ID or handle
216
141
  })
217
142
 
218
- // Execute queries
219
143
  await blocks.execute()
220
- await blocks.execute({ page: 2, limit: 12 })
221
-
222
- // With cart context for personalized recommendations
223
144
  await blocks.execute({
145
+ discounts: [
146
+ {
147
+ entitled: { all: true },
148
+ discount: { type: 'PERCENTAGE', value: 10 },
149
+ },
150
+ ],
224
151
  context: {
225
152
  productsInCart: [{ productId: '123', variantId: '456', quantity: 1 }],
226
153
  geo: { country: 'US' },
227
154
  },
228
155
  })
229
156
 
230
- // With discount entitlements
231
- await blocks.execute({
232
- discountEntitlements: [{ id: 'discount-123' }],
233
- })
234
-
235
- // Cleanup
157
+ // result.data.block has { id, title, anchor_type, strategy_type, ... }
236
158
  blocks.dispose()
237
159
  ```
238
160
 
239
- **Options:**
161
+ Additional `execute()` params: `discounts`, `context`.
240
162
 
241
- | Parameter | Type | Required | Description |
242
- | -------------- | -------- | -------- | -------------------------------- |
243
- | `blockId` | `string` | Yes | Layers block ID |
244
- | `anchorId` | `string` | No | Anchor product/collection ID |
245
- | `anchorHandle` | `string` | No | Anchor product/collection handle |
163
+ ### Search
246
164
 
247
- **Execute parameters:**
165
+ Full-text search with facets. Options persist across calls — subsequent `execute()` calls merge with existing options.
248
166
 
249
- | Parameter | Type | Description |
250
- | ---------------------- | ------------------------- | ------------------------------------- |
251
- | `page` | `number` | Page number (default: 1) |
252
- | `limit` | `number` | Products per page (default: 24) |
253
- | `filters` | `unknown` | Filter criteria |
254
- | `signal` | `AbortSignal` | External abort signal |
255
- | `discountEntitlements` | `DiscountEntitlement[]` | Discount entitlements to apply |
256
- | `context` | `BlocksContext` | Cart, geo, and custom context |
257
- | `dynamicLinking` | `Record<string, unknown>` | Custom dynamic linking parameters |
258
- | `params` | `Record<string, unknown>` | Additional request parameters |
259
- | `transformBody` | `(body) => body` | Custom request body mutation function |
167
+ ```typescript
168
+ const search = client.search({ query: 'ring', limit: 20 })
260
169
 
261
- **`BlocksContext`:**
170
+ await search.execute()
171
+ await search.execute({ page: 2 }) // page persists
172
+ await search.execute({ filters: { vendor: 'Nike' } }) // filters update
262
173
 
263
- | Property | Type | Description |
264
- | ---------------- | ---------------------------------------- | -------------------- |
265
- | `productsInCart` | `{ productId, variantId?, quantity? }[]` | Products in the cart |
266
- | `geo` | `{ country?, province?, city? }` | Geographic context |
267
- | `custom` | `Record<string, unknown>` | Custom context data |
174
+ // Temporary override (doesn't persist for next call)
175
+ await search.execute({ query: 'shoes', temporary: true })
268
176
 
269
- **`DiscountEntitlement`:**
177
+ // Prepare search (caches searchId for faster execute)
178
+ await search.prepare()
179
+ await search.execute() // uses cached searchId
270
180
 
271
- ```typescript
272
- interface DiscountEntitlement {
273
- entitled: {
274
- all?: boolean // Apply to all products
275
- products?: string[] // Product IDs
276
- variants?: (string | number)[] // Variant IDs
277
- collections?: string[] // Collection handles
278
- }
279
- discount: {
280
- type: 'PERCENTAGE' | 'FIXED_AMOUNT'
281
- value: number
282
- }
283
- }
181
+ search.dispose()
284
182
  ```
285
183
 
286
- ### `sdk.autocomplete()` - Predictive Search
184
+ Additional `execute()` params: `query`, `searchId`, `tuning`, `temporary`.
185
+
186
+ `SearchTuning` controls matching weights: `textualWeight`, `visualWeight`, `multipleFactor`, `minimumMatch`.
287
187
 
288
- Creates a standalone autocomplete controller with debounced search and local caching. Only full words (completed by a trailing space) are cached — partial input filters cached results client-side.
188
+ ### Suggest
189
+
190
+ Predictive search with debouncing and local caching. Only full words (trailing space) are cached — partial input filters cached results client-side.
289
191
 
290
192
  ```typescript
291
- const autocomplete = sdk.autocomplete({ debounceMs: 300 })
193
+ const suggest = client.suggest({ debounce: 300 })
292
194
 
293
- // Subscribe to state
294
- effect(() => {
295
- const { data, isFetching, error } = autocomplete.state.value
296
- if (isFetching) console.log('Loading suggestions...')
195
+ suggest.subscribe(({ data }) => {
297
196
  if (data) renderSuggestions(data.matchedQueries)
298
197
  })
299
198
 
300
- // Wire to input (debounced automatically)
301
199
  input.addEventListener('input', (e) => {
302
- autocomplete.execute(e.target.value)
200
+ suggest.execute(e.target.value) // debounced automatically
303
201
  })
304
202
 
305
- // Cleanup
306
- autocomplete.dispose()
203
+ suggest.dispose()
307
204
  ```
308
205
 
309
- **Options:**
206
+ | Option | Type | Description |
207
+ | ------------------- | ------------- | ------------------------------------------- |
208
+ | `debounce` | `number` | Debounce delay in ms (default: 300) |
209
+ | `excludeInputQuery` | `boolean` | Remove user's input from suggestions |
210
+ | `excludeQueries` | `string[]` | Custom strings to filter from suggestions |
211
+ | `signal` | `AbortSignal` | Shared abort signal (acts like `dispose()`) |
310
212
 
311
- | Parameter | Type | Description |
312
- | ------------ | ------------- | ---------------------------------------------------------- |
313
- | `debounceMs` | `number` | Debounce delay (default: 300) |
314
- | `signal` | `AbortSignal` | External abort signal (acts like `dispose()` when aborted) |
213
+ ### Image Search
315
214
 
316
- **Controller methods:**
317
-
318
- | Method | Description |
319
- | ---------------- | -------------------------------------------- |
320
- | `execute(query)` | Debounced predictive search for autocomplete |
321
- | `dispose()` | Cleanup abort controller and timers |
322
-
323
- ### `sdk.search()` - Search Products
324
-
325
- Creates a search controller for full text search with prepare/execute flow.
215
+ Upload an image, then search by it.
326
216
 
327
217
  ```typescript
328
- const search = sdk.search()
329
-
330
- // Subscribe to state
331
- effect(() => {
332
- const { data, isFetching, error } = search.state.value
333
- if (isFetching) console.log('Searching...')
334
- if (data) renderResults(data.products)
335
- })
336
-
337
- // Prepare search (optional, caches searchId for reuse)
338
- await search.prepare({ query: 'ring' })
339
-
340
- // Execute search with pagination and filters
341
- form.addEventListener('submit', async (e) => {
342
- e.preventDefault()
343
- const result = await search.execute({
344
- query: inputValue,
345
- limit: 20,
346
- filters: { color: 'Blue' },
218
+ const upload = client.uploadImage({ image: file })
219
+ upload.subscribe(({ data }) => {
220
+ if (!data) return
221
+ const results = client.searchByImage({ imageId: data.imageId })
222
+ results.subscribe(({ data }) => {
223
+ if (data) console.log('Similar:', data.products)
347
224
  })
348
- if (result.data) console.log('Results:', result.data.products)
349
225
  })
350
-
351
- // Cleanup
352
- search.dispose()
353
226
  ```
354
227
 
355
- **Controller methods:**
356
-
357
- | Method | Description |
358
- | ---------------- | -------------------------------------------------- |
359
- | `prepare(query)` | Prepare search and cache searchId for reuse |
360
- | `execute(query)` | Execute search (uses cached searchId if available) |
361
- | `dispose()` | Cleanup abort controller |
362
-
363
- **Search parameters:**
364
-
365
- | Parameter | Type | Description |
366
- | ---------------- | ------------------------- | ------------------------------------- |
367
- | `query` | `string` | Search query |
368
- | `page` | `number` | Page number (default: 1) |
369
- | `limit` | `number` | Products per page (default: 24) |
370
- | `filters` | `unknown` | Filter criteria |
371
- | `tuning` | `LayersTuning` | Search tuning parameters |
372
- | `signal` | `AbortSignal` | External abort signal |
373
- | `dynamicLinking` | `Record<string, unknown>` | Custom dynamic linking parameters |
374
- | `params` | `Record<string, unknown>` | Additional request parameters |
375
- | `transformBody` | `(body) => body` | Custom request body mutation function |
228
+ ### Abort Signals
376
229
 
377
- **`LayersTuning`:**
378
-
379
- | Property | Type | Description |
380
- | ---------------- | -------- | ------------------------------------------- |
381
- | `textualWeight` | `number` | Weight for text-based matching (0-1) |
382
- | `visualWeight` | `number` | Weight for visual similarity matching (0-1) |
383
- | `multipleFactor` | `number` | Factor for multiple keyword matching |
384
- | `minimumMatch` | `number` | Minimum match threshold |
385
-
386
- ### `sdk.uploadImage()` - Upload Image for Search
387
-
388
- Returns a reactive signal that uploads an image and resolves to an image ID for use with `imageSearch()`. Subscribe with `effect()` like other signal-based methods.
230
+ Controllers support two levels of abort:
389
231
 
390
232
  ```typescript
391
- const fileInput = document.querySelector('input[type="file"]')
392
- const file = fileInput.files[0]
233
+ // Shared signal — cancels everything when component unmounts
234
+ const ac = new AbortController()
235
+ const search = client.search({ query: 'ring', signal: ac.signal })
393
236
 
394
- const state = sdk.uploadImage({ image: file, signal })
237
+ // Per-call signal cancels only this request
238
+ const req = new AbortController()
239
+ await search.execute({ page: 2, signal: req.signal })
395
240
 
396
- effect(() => {
397
- const { data, error, isFetching } = state.value
398
- if (isFetching) console.log('Uploading...')
399
- if (error) console.error('Upload failed:', error.message)
400
- if (data) console.log('Image ID:', data.imageId)
401
- })
241
+ // Either aborting cancels the request (they're linked internally)
242
+ ac.abort() // cancels all pending + acts like dispose()
402
243
  ```
403
244
 
404
- ### `sdk.imageSearch()` - Search by Image
405
-
406
- Returns a reactive signal that searches products using a previously uploaded image. Same signal-based pattern as `uploadImage()` and `storefront()`.
407
-
408
- ```typescript
409
- const state = sdk.imageSearch({
410
- imageId: 'uploaded-image-id',
411
- limit: 20,
412
- filters: { vendor: 'Nike' },
413
- })
245
+ Collection and blocks auto-cancel the previous request when a new `execute()` starts.
414
246
 
415
- effect(() => {
416
- const { data, error, isFetching } = state.value
417
- if (data) console.log('Similar products:', data.products)
418
- })
419
- ```
420
-
421
- **Parameters:**
422
-
423
- | Parameter | Type | Required | Description |
424
- | --------- | -------------- | -------- | ------------------------------- |
425
- | `imageId` | `string` | Yes | Image ID from `uploadImage()` |
426
- | `page` | `number` | No | Page number (default: 1) |
427
- | `limit` | `number` | No | Products per page (default: 24) |
428
- | `filters` | `unknown` | No | Filter criteria |
429
- | `tuning` | `LayersTuning` | No | Search tuning parameters |
430
- | `signal` | `AbortSignal` | No | External abort signal |
431
-
432
- ### `sdk.storefront()` - Fetch Products and Metadata
247
+ ## Product Card
433
248
 
434
- Returns a reactive signal for fetching products by their Shopify GIDs, with optional collection/page metadata.
249
+ Reactive controller for product cards with variant selection and availability logic. All derived values are computed signals that auto-update when inputs change.
435
250
 
436
251
  ```typescript
437
- const state = sdk.storefront({
438
- ids: ['gid://shopify/Product/123', 'gid://shopify/Product/456'],
439
- meta: {
440
- collection: 'shirts', // optional: fetch collection metadata
441
- page: 'about', // optional: fetch page metadata
442
- includeFilters: true, // optional: include available filters
443
- },
252
+ import { createProductCard, effect } from '@commerce-blocks/sdk'
253
+
254
+ const card = createProductCard({
255
+ product,
256
+ selectedOptions: [{ name: 'Size', value: '7' }],
257
+ breakoutOptions: [{ name: 'Stone', value: 'Ruby' }],
444
258
  })
445
259
 
260
+ // Reactive signals
446
261
  effect(() => {
447
- const { data, error, isFetching } = state.value
448
- if (data) {
449
- console.log('Products:', data.products)
450
- console.log('Collection:', data.collection)
451
- console.log('Page:', data.page)
452
- }
262
+ console.log('Variant:', card.selectedVariant.value)
263
+ console.log('Options:', card.options.value) // OptionGroup[]
264
+ console.log('Images:', card.images.value)
265
+ console.log('Price:', card.price.value) // PriceData
453
266
  })
454
- ```
455
-
456
- **Parameters:**
457
-
458
- | Parameter | Type | Required | Description |
459
- | --------------------- | ------------- | -------- | ---------------------------------------- |
460
- | `ids` | `string[]` | Yes | Product GIDs |
461
- | `meta.collection` | `string` | No | Collection handle to fetch metadata |
462
- | `meta.page` | `string` | No | Page handle to fetch metadata |
463
- | `meta.includeFilters` | `boolean` | No | Include available filters for collection |
464
- | `signal` | `AbortSignal` | No | External abort signal |
465
-
466
- ## Abort Signals
467
267
 
468
- All SDK methods accept an optional `signal` for external cancellation. This works alongside the SDK's internal abort logic (e.g., a new search call automatically cancels the previous one).
268
+ // Actions
269
+ card.selectOption({ name: 'Size', value: 'L' })
270
+ card.setSelectedOptions([{ name: 'Size', value: 'L' }]) // merge by name
271
+ card.setSelectedVariant(12345) // select by numeric variant ID
272
+ card.setCarouselPosition(3) // 1-based manual override
469
273
 
470
- ```typescript
471
- const controller = new AbortController()
472
-
473
- // Cancel from outside
474
- await search.execute({ query: 'ring', signal: controller.signal })
475
- controller.abort() // cancels the request
476
-
477
- // With autocomplete — signal acts like dispose()
478
- const autocomplete = sdk.autocomplete({ signal: controller.signal })
479
- controller.abort() // cancels debounce + pending request
274
+ card.subscribe(({ selectedVariant, options, price }) => {
275
+ // Called on any state change
276
+ })
480
277
 
481
- // With collection
482
- await collection.execute({ page: 2, signal: controller.signal })
278
+ card.dispose()
483
279
  ```
484
280
 
485
- For controllers (autocomplete, search, collection), calling a new query still auto-cancels the previous request. The external signal adds an additional cancellation path — useful for component unmount or navigation.
281
+ **Reactive state** (all `ReadonlySignal`): `variants`, `selectedVariant`, `options`, `images`, `price`, `priceRange`, `carouselPosition`, `isSelectionComplete`.
486
282
 
487
- ## Response Types
283
+ Options include availability status baked in:
488
284
 
489
285
  ```typescript
490
- // All controllers use QueryState<T> for reactive state
491
- interface QueryState<T> {
492
- data: T | null
493
- error: SdkError | null
494
- isFetching: boolean
495
- }
496
-
497
- interface CollectionResult {
498
- products: Product[]
499
- totalResults: number
500
- totalPages: number
501
- page: number
502
- resultsPerPage?: number
503
- facets: Record<string, Record<string, number>>
504
- facetRanges?: Record<string, { min: number; max: number }>
505
- priceRange?: PriceRange // Formatted min/max prices from result set
506
- attributionToken: string
507
- collection?: StorefrontCollection
508
- }
509
-
510
- interface SearchResult {
511
- products: Product[]
512
- totalResults: number
513
- totalPages: number
514
- page: number
515
- resultsPerPage?: number
516
- facets: Record<string, Record<string, number>>
517
- facetRanges?: Record<string, { min: number; max: number }>
518
- priceRange?: PriceRange // Formatted min/max prices from result set
519
- attributionToken: string
520
- }
521
-
522
- interface BlocksResult {
523
- products: Product[]
524
- totalResults: number
525
- totalPages: number
526
- page: number
527
- resultsPerPage?: number
528
- facets: Record<string, Record<string, number>>
529
- facetRanges?: Record<string, { min: number; max: number }>
530
- priceRange?: PriceRange // Formatted min/max prices from result set
531
- attributionToken: string
532
- block?: BlocksInfo // { id, title, anchor_type, strategy_type, strategy_key }
533
- }
534
-
535
- interface PriceRange {
536
- min: Price
537
- max: Price
286
+ interface OptionGroup {
287
+ name: string
288
+ values: OptionValue[]
538
289
  }
539
290
 
540
- interface Price {
541
- amount: number
542
- currencyCode: string
543
- formatted: string
291
+ interface OptionValue {
292
+ value: string
293
+ status: 'available' | 'backorderable' | 'sold-out' | 'unavailable'
294
+ selected: boolean
295
+ swatch: Swatch | null
544
296
  }
545
297
 
546
- interface StorefrontResult {
547
- products: Product[]
548
- collection?: StorefrontCollection
549
- page?: StorefrontPage
298
+ interface PriceData {
299
+ price: Price | null
300
+ compareAtPrice: Price | null
301
+ isOnSale: boolean
550
302
  }
551
- ```
552
-
553
- ## Error Handling
554
-
555
- All methods return `Result<T, E>` instead of throwing. Errors are discriminated by `_tag`:
556
303
 
557
- ```typescript
558
- const result = await collection.execute()
559
-
560
- if (result.error) {
561
- switch (result.error._tag) {
562
- case 'NetworkError':
563
- // Connection issues, timeouts, aborted requests
564
- console.log(result.error.code) // 'TIMEOUT' | 'CONNECTION_FAILED' | 'ABORTED' | ...
565
- break
566
- case 'ApiError':
567
- // Server errors, rate limits
568
- console.log(result.error.source) // 'layers' | 'storefront'
569
- console.log(result.error.status) // HTTP status code
570
- break
571
- case 'ValidationError':
572
- // Invalid parameters
573
- console.log(result.error.operation) // which method failed
574
- console.log(result.error.fields) // [{ field, code, message }]
575
- break
576
- case 'ConfigError':
577
- // SDK configuration issues
578
- console.log(result.error.field) // which config field
579
- break
580
- }
581
- } else {
582
- const data = result.data
304
+ interface PriceRangeData {
305
+ priceRange: PriceRange
306
+ compareAtPriceRange: PriceRange | null
583
307
  }
584
308
  ```
585
309
 
586
- ### Error Types
587
-
588
- **`NetworkError`** — connection failures, timeouts, aborted requests
589
-
590
- | Field | Type | Description |
591
- | -------------- | ------------------ | ------------------------------------------------------------------------------- |
592
- | `code` | `NetworkErrorCode` | `TIMEOUT`, `CONNECTION_FAILED`, `DNS_FAILED`, `SSL_ERROR`, `ABORTED`, `OFFLINE` |
593
- | `message` | `string` | Human-readable description |
594
- | `retryable` | `boolean` | Whether the request can be retried |
595
- | `retryAfterMs` | `number?` | Suggested retry delay in milliseconds |
596
-
597
- **`ApiError`** — server errors, rate limits, GraphQL errors
598
-
599
- | Field | Type | Description |
600
- | -------------- | -------------- | -------------------------------------------------- |
601
- | `code` | `ApiErrorCode` | `NOT_FOUND`, `RATE_LIMITED`, `GRAPHQL_ERROR`, etc. |
602
- | `source` | `ApiSource` | `'layers'` or `'storefront'` |
603
- | `status` | `number?` | HTTP status code |
604
- | `retryable` | `boolean` | Whether the request can be retried |
605
- | `retryAfterMs` | `number?` | Suggested retry delay |
606
-
607
- **`ValidationError`** — invalid parameters passed to SDK methods
608
-
609
- | Field | Type | Description |
610
- | ----------- | ------------------------ | ------------------------------------- |
611
- | `operation` | `string` | Which method failed (e.g. `'search'`) |
612
- | `fields` | `ValidationFieldError[]` | `[{ field, code, message }]` |
310
+ ## Filters
613
311
 
614
- **`ConfigError`** SDK configuration issues at init time
615
-
616
- | Field | Type | Description |
617
- | ---------- | --------- | --------------------------- |
618
- | `field` | `string` | Which config field is wrong |
619
- | `expected` | `string?` | What was expected |
620
-
621
- ### Error Helpers
622
-
623
- Always check `isRetryable()` before calling `getRetryDelay()`. `getRetryDelay()` returns `undefined` for non-retryable errors — it's not meant to be used standalone.
312
+ Build filters using the DSL:
624
313
 
625
314
  ```typescript
626
- import { isRetryable, getRetryDelay } from '@commerce-blocks/sdk'
627
-
628
- if (result.error && isRetryable(result.error)) {
629
- const delay = getRetryDelay(result.error) ?? 1000
630
- setTimeout(() => collection.execute(), delay)
631
- }
632
- ```
315
+ import { filter, and, or, eq, gte, lte, inValues } from '@commerce-blocks/sdk'
633
316
 
634
- ## Filtering
635
-
636
- Build filters using the DSL helpers:
637
-
638
- ```typescript
639
- import { filter, and, or, eq, inValues, gte, lte } from '@commerce-blocks/sdk'
640
-
641
- // Single filter
642
- const colorFilter = filter(eq('options.color', 'Red'))
643
-
644
- // Multiple conditions (AND)
645
- const multiFilter = filter(and(eq('options.color', 'Red'), eq('options.size', 'Medium')))
646
-
647
- // Multiple values (OR)
648
- const multiValue = filter(or(eq('vendor', 'Nike'), eq('vendor', 'Adidas')))
317
+ await collection.execute({
318
+ filters: filter(and(eq('options.color', 'Red'), eq('options.size', 'Medium'))),
319
+ })
649
320
 
650
321
  // Price range
651
- const priceFilter = filter(and(gte('price', 50), lte('price', 200)))
322
+ filter(and(gte('price', 50), lte('price', 200)))
652
323
 
653
- // Use in query
654
- await collection.execute({
655
- filters: multiFilter,
656
- })
324
+ // Multiple values
325
+ filter(or(eq('vendor', 'Nike'), eq('vendor', 'Adidas')))
657
326
  ```
658
327
 
659
- ### Filter Operators
660
-
661
- | Function | Description |
328
+ | Operator | Description |
662
329
  | ------------------------------ | ----------------------- |
663
330
  | `eq(property, value)` | Equals |
664
331
  | `notEq(property, value)` | Not equals |
@@ -671,293 +338,181 @@ await collection.execute({
671
338
  | `exists(property)` | Property exists |
672
339
  | `notExists(property)` | Property does not exist |
673
340
 
674
- ### Filter Mapping
341
+ ## Transforms and Filter Aliases
675
342
 
676
- Use `filterMap` for URL-friendly filter keys:
343
+ Configure once at init — applied automatically to all results. The `product` transform extends every `Product` with custom fields via the generic `Product<T>` type:
677
344
 
678
345
  ```typescript
679
- const sdk = createSdk({
680
- // ... other config
681
- filterMap: {
346
+ import type { Product } from '@commerce-blocks/sdk'
347
+
348
+ type MyProduct = Product<{ description: string; rating: number }>
349
+
350
+ const { data: client } = createClient({
351
+ // ...config
352
+ attributes: ['body_html'],
353
+ transforms: {
354
+ product: ({ raw }) => ({
355
+ description: raw.body_html ?? '',
356
+ rating: raw.calculated?.average_rating ?? 0,
357
+ }),
358
+ collection: (result, raw) => result,
359
+ search: (result, raw) => result,
360
+ block: (result, raw) => result,
361
+ filters: (filters) => filters,
362
+ },
363
+ filterAliases: {
682
364
  color: 'options.color',
683
365
  size: 'options.size',
684
366
  brand: { property: 'vendor', values: { nike: 'Nike', adidas: 'Adidas' } },
685
367
  },
686
368
  })
687
369
 
688
- // Now you can use simple keys
689
- await collection.execute({
690
- filters: { color: 'Red', brand: 'nike' },
691
- })
370
+ // Aliases resolve automatically
371
+ await collection.execute({ filters: { color: 'Red', brand: 'nike' } })
372
+ // Products now include description and rating from the product transform
692
373
  ```
693
374
 
694
- ## Product Card
695
-
696
- ### `createProductCard()` - Reactive Product Card Controller
375
+ ## Error Handling
697
376
 
698
- Creates a reactive controller for product cards with variant selection and availability logic. All derived values are computed signals that auto-update when inputs change.
377
+ All methods return `{ data, error }` instead of throwing. Errors are discriminated by `_tag`:
699
378
 
700
379
  ```typescript
701
- import { createProductCard, effect } from '@commerce-blocks/sdk'
702
-
703
- const card = createProductCard({
704
- product,
705
- selectedOptions: [{ name: 'Size', value: '7' }],
706
- breakoutOptions: [{ name: 'Stone', value: 'Ruby' }],
707
- })
708
-
709
- // Subscribe to reactive state
710
- effect(() => {
711
- console.log('Selected variant:', card.selectedVariant.value)
712
- console.log('Available options:', card.options.value)
713
- })
380
+ const result = await collection.execute()
714
381
 
715
- // Access reactive signals
716
- card.variants.value // Variants filtered by breakoutOptions
717
- card.selectedVariant.value // Currently selected variant
718
- card.options.value // Available options (excludes breakout option names)
719
- card.images.value // Variant image or product images
720
- card.carouselIndex.value // Image index for carousel
721
-
722
- // Mutate state via actions
723
- card.selectOption('Size', 'L') // Select a single option
724
- card.setSelectedOptions([{ name: 'Size', value: 'L' }]) // Merge options by name
725
- card.setBreakoutOptions([{ name: 'Stone', value: 'Emerald' }]) // Change breakout filter
726
-
727
- // Query methods (pure)
728
- card.getOptionValues('Size') // ['S', 'M', 'L']
729
- card.getSwatches('Color') // Swatch definitions
730
- card.isOptionAvailable('Size', 'L') // Check if selecting 'L' results in available variant
731
- card.getVariantByOptions([{ name: 'Size', value: 'L' }])
732
-
733
- // Cleanup
734
- card.dispose()
382
+ if (result.error) {
383
+ switch (result.error._tag) {
384
+ case 'NetworkError':
385
+ // code: 'TIMEOUT' | 'CONNECTION_FAILED' | 'DNS_FAILED' | 'SSL_ERROR' | 'ABORTED' | 'OFFLINE'
386
+ break
387
+ case 'ApiError':
388
+ // code: 'NOT_FOUND' | 'RATE_LIMITED' | 'UNAUTHORIZED' | ...
389
+ // status: HTTP status code, source: 'layers'
390
+ break
391
+ case 'ValidationError':
392
+ // operation: which method failed, fields: [{ field, code, message }]
393
+ break
394
+ case 'ConfigError':
395
+ // field: which config field, expected: what was expected
396
+ break
397
+ }
398
+ }
735
399
  ```
736
400
 
737
- **Parameters:**
738
-
739
- | Parameter | Type | Required | Description |
740
- | ----------------- | ----------------- | -------- | -------------------------------------------------------- |
741
- | `product` | `Product` | Yes | Product to display |
742
- | `selectedOptions` | `ProductOption[]` | No | Initially selected options |
743
- | `breakoutOptions` | `ProductOption[]` | No | Options to filter variants (auto-set from variant tiles) |
744
-
745
- **Reactive State (ReadonlySignal):**
746
-
747
- | Property | Type | Description |
748
- | ----------------- | ---------------------------------------- | --------------------------------------- |
749
- | `product` | `Product` | Original product (static) |
750
- | `variants` | `ReadonlySignal<ProductVariant[]>` | Variants matching breakoutOptions |
751
- | `selectedVariant` | `ReadonlySignal<ProductVariant \| null>` | Variant matching all selected options |
752
- | `selectedOptions` | `ReadonlySignal<ProductOption[]>` | Combined breakout + selected options |
753
- | `options` | `ReadonlySignal<RichProductOption[]>` | Available options from visible variants |
754
- | `optionNames` | `ReadonlySignal<string[]>` | Option names (excludes breakout) |
755
- | `images` | `ReadonlySignal<Image[]>` | Variant image or product images |
756
- | `carouselIndex` | `ReadonlySignal<number>` | Index of selected variant's image |
757
-
758
- **Actions:**
759
-
760
- | Method | Description |
761
- | ----------------------------- | ------------------------------- |
762
- | `selectOption(name, value)` | Select a single option by name |
763
- | `setSelectedOptions(options)` | Merge options by name |
764
- | `setBreakoutOptions(options)` | Replace breakout filter options |
765
- | `dispose()` | Cleanup controller |
766
-
767
- **Query Methods:**
768
-
769
- | Method | Returns | Description |
770
- | -------------------------------- | ------------------------ | ------------------------------------------------------ |
771
- | `getOptionValues(name)` | `string[]` | All values for an option from visible variants |
772
- | `getSwatches(name)` | `Swatch[]` | Swatch definitions for an option |
773
- | `getVariantByOptions(opts)` | `ProductVariant \| null` | Find variant by options |
774
- | `isOptionAvailable(name, value)` | `boolean` | Check if selecting option results in available variant |
775
- | `isVariantAvailable(variant)` | `boolean` | Check if variant is available for sale |
776
-
777
- ## Singleton Access
778
-
779
- After initialization, access the SDK anywhere:
401
+ Retryable errors (`TIMEOUT`, `CONNECTION_FAILED`, `RATE_LIMITED`, `SERVICE_UNAVAILABLE`) expose `retryable` and `retryAfterMs`:
780
402
 
781
403
  ```typescript
782
- import { getSdk, isInitialized } from '@commerce-blocks/sdk'
404
+ import { isRetryable } from '@commerce-blocks/sdk'
783
405
 
784
- if (isInitialized()) {
785
- const result = getSdk()
786
- if (result.data) {
787
- const sdk = result.data
788
- // Use sdk
789
- }
406
+ if (result.error && isRetryable(result.error)) {
407
+ const delay = result.error.retryAfterMs ?? 1000
408
+ setTimeout(() => collection.execute(), delay)
790
409
  }
791
410
  ```
792
411
 
793
- ## Store API
412
+ ## Cache and Storage
794
413
 
795
- The SDK exposes a reactive store for caching:
414
+ The client exposes a reactive cache:
796
415
 
797
416
  ```typescript
798
- const { store } = sdk
799
-
800
- // Product cache
801
- const product = store.products.get('gid://shopify/Product/123')
802
- const { cached, missing } = store.products.getMany(gids)
803
- store.products.set(products)
804
-
805
- // Query cache (reactive)
806
- const querySignal = store.queries.get('cache-key')
807
- store.queries.invalidate('browse:*') // invalidate by pattern
808
-
809
- // Collection metadata
810
- const meta = store.collections.get('shirts')
811
-
812
- // Page metadata
813
- const page = store.pages.get('about')
814
- store.pages.set('about', { title: 'About Us', body: '...' })
815
- store.pages.delete('about')
816
-
817
- // Persistence
818
- store.persist() // save to storage
819
- store.restore() // restore from storage
820
- store.clear() // clear all caches
821
-
822
- // Stats
823
- console.log(store.stats) // { products, queries, collections, pages }
417
+ const { cache } = client
418
+
419
+ cache.get('cache-key') // CacheEntry<QueryResult> | null
420
+ cache.invalidate('browse:*') // invalidate by pattern
421
+ cache.persist() // save to storage
422
+ cache.restore() // restore from storage
423
+ cache.clear() // clear all
424
+ cache.stats.entries // current entry count
824
425
  ```
825
426
 
826
- ## Storage Adapters
827
-
828
- Configure how the SDK persists cache data. By default, the SDK uses `localStorage` in browsers.
829
-
830
- ### Built-in Adapters
427
+ ### Storage Adapters
831
428
 
832
429
  ```typescript
833
- import { localStorageAdapter, jsonFileAdapter } from '@commerce-blocks/sdk'
430
+ import { localStorageAdapter, fileStorage } from '@commerce-blocks/sdk'
834
431
 
835
- // Browser: localStorage (returns null if unavailable)
432
+ // Browser (returns null if unavailable)
836
433
  const browserAdapter = localStorageAdapter('my-cache-key')
837
434
 
838
- // Node.js: JSON file
435
+ // Node.js
839
436
  import fs from 'fs'
840
- const nodeAdapter = jsonFileAdapter('./cache.json', fs)
437
+ const nodeAdapter = fileStorage('./cache.json', fs)
841
438
  ```
842
439
 
843
- ### Custom Adapter
844
-
845
- Implement the `StorageAdapter` interface:
440
+ Custom adapter — implement `StorageAdapter`:
846
441
 
847
442
  ```typescript
848
- interface StorageAdapter {
849
- read(): string | null
850
- write(data: string): void
851
- remove(): void
852
- }
853
-
854
- // Example: sessionStorage adapter
855
- const sessionAdapter: StorageAdapter = {
856
- read: () => sessionStorage.getItem('my-key'),
857
- write: (data) => sessionStorage.setItem('my-key', data),
858
- remove: () => sessionStorage.removeItem('my-key'),
443
+ const adapter: StorageAdapter = {
444
+ read: () => sessionStorage.getItem('key'),
445
+ write: (data) => sessionStorage.setItem('key', data),
446
+ remove: () => sessionStorage.removeItem('key'),
859
447
  }
860
448
  ```
861
449
 
862
- ## Standalone Utilities
450
+ ## Signals
863
451
 
864
- ### `search()` - Direct Layers Search
865
-
866
- For advanced use cases bypassing SDK caching. Combines `prepareSearch` + `search` into a single call.
452
+ The SDK re-exports `@preact/signals-core` primitives for reactive state:
867
453
 
868
454
  ```typescript
869
- import { layersClient, search } from '@commerce-blocks/sdk'
870
-
871
- const clientResult = layersClient({
872
- layersPublicToken: 'your-token',
873
- sorts: [{ label: 'Featured', code: 'featured' }],
874
- })
875
-
876
- if (clientResult.data) {
877
- const result = await search(clientResult.data, 'red dress', {
878
- pagination: { page: 1, limit: 20 },
879
- })
880
-
881
- if (result.data) {
882
- console.log('Raw Layers results:', result.data.results)
883
- }
884
- }
455
+ import { signal, computed, effect, batch } from '@commerce-blocks/sdk'
885
456
  ```
886
457
 
887
- ### Cache Key Generators
888
-
889
- Build cache keys for manual store operations:
458
+ Controller `state` is a `ReadonlySignal<QueryState<T>>`:
890
459
 
891
460
  ```typescript
892
- import { browseKey, searchKey, similarKey, blocksKey, productsKey } from '@commerce-blocks/sdk'
893
-
894
- browseKey('shirts', { page: 1, limit: 24 }) // '/browse/shirts?limit=24&page=1'
895
- searchKey('red dress', { page: 2 }) // '/search/red%20dress?page=2'
896
- similarKey(123, { limit: 10 }) // '/similar/123?limit=10'
897
- blocksKey('block-abc', { anchorHandle: 'gold-necklace', page: 1 })
898
- productsKey(['gid://shopify/Product/1', 'gid://shopify/Product/2'])
461
+ interface QueryState<T> {
462
+ data: T | null
463
+ error: ClientError | null
464
+ isFetching: boolean
465
+ }
899
466
  ```
900
467
 
901
- ## Testing
468
+ All controllers return the same `QueryResult` shape in `data`:
902
469
 
903
- Tests use Playwright running in a real browser context. The SDK is loaded via a test harness page and accessed through `window.SDK`.
904
-
905
- ```bash
906
- npm run test # run all tests
907
- npm run test:ui # interactive UI
908
- npx playwright test tests/client.spec.ts # single file
909
- npx playwright test tests/layers.spec.ts -g "search" # single test
470
+ ```typescript
471
+ interface QueryResult {
472
+ products: Product[]
473
+ totalResults: number
474
+ totalPages: number
475
+ page: number
476
+ facets: Record<string, Record<string, number>>
477
+ priceRange?: PriceRange
478
+ attributionToken?: string
479
+ }
910
480
  ```
911
481
 
912
- ### Test helpers
913
-
914
- - `setupSdk(page)` — navigates to the test harness and waits for `window.SDK_READY`
915
- - `inBrowser(page, fn)` — runs a function inside the browser via `page.evaluate()`
482
+ Blocks results add a `block` field with `{ id, title, anchor_type, strategy_type, strategy_key }`.
916
483
 
917
- ### Writing tests
484
+ ## Singleton Access
918
485
 
919
- Tests run SDK code inside the browser and return serializable results for assertions:
486
+ After initialization, access the client anywhere:
920
487
 
921
488
  ```typescript
922
- import { test, expect } from '@playwright/test'
923
- import { setupSdk, inBrowser } from './helpers'
924
-
925
- test.describe('SDK: feature()', () => {
926
- test.beforeEach(async ({ page }) => {
927
- await setupSdk(page)
928
- })
929
-
930
- test('does something', async ({ page }) => {
931
- const result = await inBrowser(page, async () => {
932
- const { createSdk, devConfig } = (window as any).SDK
933
- const { data: sdk } = createSdk(devConfig)
934
- if (!sdk) return { error: 'init failed' }
935
-
936
- const controller = sdk.collection({ handle: 'shirts' })
937
- const { data, error } = await controller.execute({ limit: 3 })
938
- controller.dispose()
939
-
940
- if (error) return { error: error._tag }
941
- return { count: data.products.length }
942
- })
489
+ import { getClient, isInitialized } from '@commerce-blocks/sdk'
943
490
 
944
- expect(result.error).toBeUndefined()
945
- expect(result.count).toBeGreaterThan(0)
946
- })
947
- })
491
+ if (isInitialized()) {
492
+ const { data: client } = getClient()
493
+ // Use client
494
+ }
948
495
  ```
949
496
 
950
- Key patterns:
951
-
952
- - All SDK calls happen inside `inBrowser()` — you can't access SDK objects directly in Node
953
- - Return plain objects from `inBrowser()`, not class instances or signals
954
- - Always `dispose()` controllers to prevent leaks between tests
955
- - Use `devConfig` from the test harness for real API credentials
497
+ ## Response Types
956
498
 
957
- ## Technical Details
499
+ The SDK exports all types from `@commerce-blocks/sdk`:
958
500
 
959
- - **Runtime**: Browser (ESM)
960
- - **TypeScript**: Full type definitions included
961
- - **Dependencies**: `@preact/signals-core`
962
- - **Bundle**: Tree-shakeable ES modules
963
- - **Caching**: Built-in LRU cache with configurable TTL
501
+ ```typescript
502
+ import type {
503
+ Client,
504
+ ClientConfig,
505
+ QueryState,
506
+ QueryResult,
507
+ Product,
508
+ ProductBase,
509
+ ProductVariant,
510
+ OptionGroup,
511
+ OptionValue,
512
+ PriceData,
513
+ PriceRangeData,
514
+ ClientError,
515
+ NetworkError,
516
+ ApiError,
517
+ } from '@commerce-blocks/sdk'
518
+ ```