@commerce-blocks/sdk 2.0.0-alpha.2 → 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 -850
  2. package/dist/index.d.ts +278 -573
  3. package/dist/index.js +1156 -1980
  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,746 +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) |
51
-
52
- ### Storefront (optional)
28
+ if (error) throw new Error(error.message)
53
29
 
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
37
 
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
-
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 three 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 (signals)**: Access `controller.state` (a `ReadonlySignal<QueryState<T>>`) directly with `effect()` from `@preact/signals-core`
144
- - **Reactive (callback)**: Use `controller.subscribe(callback)` — receives state updates without needing signals
145
- - **Imperative**: `await controller.execute()` returns `Result<T, SdkError>` directly
112
+ ### Collection
146
113
 
147
- This pattern applies to all controllers (`collection`, `search`, `blocks`, `autocomplete`).
114
+ Browse products in a collection.
148
115
 
149
116
  ```typescript
150
- const collection = sdk.collection({
117
+ const collection = client.collection({
151
118
  handle: 'shirts',
152
119
  defaultSort: 'featured', // optional, uses first sort if omitted
153
120
  })
154
121
 
155
- // Option 1: Controller subscribe() no signal import needed
156
- const unsubscribe = collection.subscribe(({ data, error, isFetching }) => {
157
- if (isFetching) console.log('Loading...')
158
- if (error) console.error('Error:', error.message)
159
- if (data) console.log('Products:', data.products)
160
- })
161
-
162
- // Option 2: Standalone subscribe() — works with any signal
163
- const unsubscribe = subscribe(collection.state, ({ data, error, isFetching }) => {
164
- // same as above
165
- })
166
-
167
- // Option 3: Direct signal access (for custom reactivity)
168
- effect(() => {
169
- const { data, error, isFetching } = collection.state.value
170
- // same as above
171
- })
172
-
173
- // Execute queries — returns Result<CollectionResult, SdkError>
174
- const result = await collection.execute() // initial load
175
- if (result.error) console.error(result.error.message)
176
- 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
177
127
 
178
- await collection.execute({ page: 2 }) // pagination
179
- await collection.execute({ sortOrderCode: 'price_asc' }) // change sort
180
- await collection.execute({ filters: { color: 'Red' } }) // with filters
181
-
182
- // Unsubscribe
183
- unsubscribe() // remove single subscription
184
- collection.dispose() // cleanup all subscriptions + abort pending requests
128
+ collection.dispose()
185
129
  ```
186
130
 
187
- The `subscribe()` method is an effect abstraction — it wraps `@preact/signals-core`'s `effect()` so you can react to state changes without importing signals directly. The callback receives the full `QueryState<T>` object (not a signal), making it easy to use in any framework.
188
-
189
- **Options:**
190
-
191
- | Parameter | Type | Required | Description |
192
- | ------------- | ------------- | -------- | ---------------------------------------------- |
193
- | `handle` | `string` | Yes | Collection URL handle |
194
- | `defaultSort` | `string` | No | Default sort code (uses first configured sort) |
195
- | `signal` | `AbortSignal` | No | Shared abort signal |
196
-
197
- **Execute parameters:**
198
-
199
- | Parameter | Type | Description |
200
- | ---------------- | ------------------------- | ------------------------------------- |
201
- | `page` | `number` | Page number (default: 1) |
202
- | `limit` | `number` | Products per page (default: 24) |
203
- | `sortOrderCode` | `string` | Sort option code |
204
- | `filters` | `unknown` | Filter criteria |
205
- | `signal` | `AbortSignal` | Per-call abort signal |
206
- | `includeMeta` | `boolean` | Fetch collection metadata |
207
- | `includeFilters` | `boolean` | Include filter counts in response |
208
- | `dynamicLinking` | `Record<string, unknown>` | Custom dynamic linking parameters |
209
- | `params` | `Record<string, unknown>` | Additional request parameters |
210
- | `transformBody` | `(body) => body` | Custom request body mutation function |
131
+ Additional `execute()` params: `sort`, `includeMeta`.
211
132
 
212
- ### `sdk.blocks()` - Product Recommendations
133
+ ### Blocks
213
134
 
214
- 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.
215
136
 
216
137
  ```typescript
217
- const blocks = sdk.blocks({
138
+ const blocks = client.blocks({
218
139
  blockId: 'block-abc123',
219
- anchorId: 'gold-necklace', // anchor by product ID or handle
220
- })
221
-
222
- // Option 1: Controller subscribe() — no signal import needed
223
- const unsubscribe = blocks.subscribe(({ data, error, isFetching }) => {
224
- if (data) {
225
- console.log('Recommendations:', data.products)
226
- console.log('Block info:', data.block) // { title, anchor_type, strategy_type, ... }
227
- }
228
- })
229
-
230
- // Option 2: Standalone subscribe() — works with any signal
231
- const unsubscribe = subscribe(blocks.state, ({ data, error, isFetching }) => {
232
- // same as above
140
+ anchor: 'gold-necklace', // product/collection ID or handle
233
141
  })
234
142
 
235
- // Option 3: Direct signal access (for custom reactivity)
236
- effect(() => {
237
- const { data, error, isFetching } = blocks.state.value
238
- // same as above
239
- })
240
-
241
- // Execute queries
242
143
  await blocks.execute()
243
- await blocks.execute({ page: 2, limit: 12 })
244
-
245
- // With cart context for personalized recommendations
246
144
  await blocks.execute({
145
+ discounts: [
146
+ {
147
+ entitled: { all: true },
148
+ discount: { type: 'PERCENTAGE', value: 10 },
149
+ },
150
+ ],
247
151
  context: {
248
152
  productsInCart: [{ productId: '123', variantId: '456', quantity: 1 }],
249
153
  geo: { country: 'US' },
250
154
  },
251
155
  })
252
156
 
253
- // With discount entitlements
254
- await blocks.execute({
255
- discountEntitlements: [{ id: 'discount-123' }],
256
- })
257
-
258
- // Unsubscribe
259
- unsubscribe() // remove single subscription
260
- blocks.dispose() // cleanup all subscriptions + abort pending requests
157
+ // result.data.block has { id, title, anchor_type, strategy_type, ... }
158
+ blocks.dispose()
261
159
  ```
262
160
 
263
- **Options:**
161
+ Additional `execute()` params: `discounts`, `context`.
264
162
 
265
- | Parameter | Type | Required | Description |
266
- | ---------- | ------------- | -------- | -------------------------------------- |
267
- | `blockId` | `string` | Yes | Layers block ID |
268
- | `anchorId` | `string` | No | Anchor product/collection ID or handle |
269
- | `signal` | `AbortSignal` | No | Shared abort signal |
163
+ ### Search
270
164
 
271
- **Execute parameters:**
272
-
273
- | Parameter | Type | Description |
274
- | ---------------------- | ------------------------- | ------------------------------------- |
275
- | `page` | `number` | Page number (default: 1) |
276
- | `limit` | `number` | Products per page (default: 24) |
277
- | `filters` | `unknown` | Filter criteria |
278
- | `signal` | `AbortSignal` | External abort signal |
279
- | `discountEntitlements` | `DiscountEntitlement[]` | Discount entitlements to apply |
280
- | `context` | `BlocksContext` | Cart, geo, and custom context |
281
- | `dynamicLinking` | `Record<string, unknown>` | Custom dynamic linking parameters |
282
- | `params` | `Record<string, unknown>` | Additional request parameters |
283
- | `transformBody` | `(body) => body` | Custom request body mutation function |
284
-
285
- **`BlocksContext`:**
286
-
287
- | Property | Type | Description |
288
- | ---------------- | ---------------------------------------- | -------------------- |
289
- | `productsInCart` | `{ productId, variantId?, quantity? }[]` | Products in the cart |
290
- | `geo` | `{ country?, province?, city? }` | Geographic context |
291
- | `custom` | `Record<string, unknown>` | Custom context data |
292
-
293
- **`DiscountEntitlement`:**
294
-
295
- ```typescript
296
- interface DiscountEntitlement {
297
- entitled: {
298
- all?: boolean // Apply to all products
299
- products?: string[] // Product IDs
300
- variants?: (string | number)[] // Variant IDs
301
- collections?: string[] // Collection handles
302
- }
303
- discount: {
304
- type: 'PERCENTAGE' | 'FIXED_AMOUNT'
305
- value: number
306
- }
307
- }
308
- ```
309
-
310
- ### `sdk.autocomplete()` - Predictive Search
311
-
312
- 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.
165
+ Full-text search with facets. Options persist across calls — subsequent `execute()` calls merge with existing options.
313
166
 
314
167
  ```typescript
315
- const autocomplete = sdk.autocomplete({ debounceMs: 300 })
168
+ const search = client.search({ query: 'ring', limit: 20 })
316
169
 
317
- // Option 1: Controller subscribe() — no signal import needed
318
- const unsubscribe = autocomplete.subscribe(({ data, isFetching, error }) => {
319
- if (isFetching) console.log('Loading suggestions...')
320
- if (data) renderSuggestions(data.matchedQueries)
321
- })
322
-
323
- // Option 2: Standalone subscribe() — works with any signal
324
- const unsubscribe = subscribe(autocomplete.state, ({ data, isFetching, error }) => {
325
- // same as above
326
- })
170
+ await search.execute()
171
+ await search.execute({ page: 2 }) // page persists
172
+ await search.execute({ filters: { vendor: 'Nike' } }) // filters update
327
173
 
328
- // Option 3: Direct signal access (for custom reactivity)
329
- effect(() => {
330
- const { data, isFetching, error } = autocomplete.state.value
331
- // same as above
332
- })
174
+ // Temporary override (doesn't persist for next call)
175
+ await search.execute({ query: 'shoes', temporary: true })
333
176
 
334
- // Wire to input (debounced automatically)
335
- input.addEventListener('input', (e) => {
336
- autocomplete.execute(e.target.value)
337
- })
177
+ // Prepare search (caches searchId for faster execute)
178
+ await search.prepare()
179
+ await search.execute() // uses cached searchId
338
180
 
339
- // Unsubscribe
340
- unsubscribe() // remove single subscription
341
- autocomplete.dispose() // cleanup all subscriptions, abort controller, and timers
181
+ search.dispose()
342
182
  ```
343
183
 
344
- **Options:**
345
-
346
- | Parameter | Type | Description |
347
- | ------------ | ------------- | -------------------------------------------------------- |
348
- | `debounceMs` | `number` | Debounce delay (default: 300) |
349
- | `signal` | `AbortSignal` | Shared abort signal (acts like `dispose()` when aborted) |
350
-
351
- **Controller methods:**
184
+ Additional `execute()` params: `query`, `searchId`, `tuning`, `temporary`.
352
185
 
353
- | Method | Description |
354
- | ---------------- | -------------------------------------------------------- |
355
- | `execute(query)` | Debounced predictive search for autocomplete |
356
- | `subscribe(cb)` | Subscribe to state changes, returns unsubscribe function |
357
- | `dispose()` | Cleanup all subscriptions, abort controller and timers |
186
+ `SearchTuning` controls matching weights: `textualWeight`, `visualWeight`, `multipleFactor`, `minimumMatch`.
358
187
 
359
- ### `sdk.search()` - Search Products
188
+ ### Suggest
360
189
 
361
- Creates a search controller for full text search. Options persist across calls subsequent `execute()` and `prepare()` calls merge with these defaults.
190
+ Predictive search with debouncing and local caching. Only full words (trailing space) are cached partial input filters cached results client-side.
362
191
 
363
192
  ```typescript
364
- // Initialize with base options
365
- const search = sdk.search({ query: 'ring', limit: 20 })
366
-
367
- // Option 1: Controller subscribe() — no signal import needed
368
- const unsubscribe = search.subscribe(({ data, isFetching, error }) => {
369
- if (isFetching) console.log('Searching...')
370
- if (data) renderResults(data.products)
371
- })
193
+ const suggest = client.suggest({ debounce: 300 })
372
194
 
373
- // Option 2: Standalone subscribe() works with any signal
374
- const unsubscribe = subscribe(search.state, ({ data, isFetching, error }) => {
375
- // same as above
195
+ suggest.subscribe(({ data }) => {
196
+ if (data) renderSuggestions(data.matchedQueries)
376
197
  })
377
198
 
378
- // Option 3: Direct signal access (for custom reactivity)
379
- effect(() => {
380
- const { data, isFetching, error } = search.state.value
381
- // same as above
199
+ input.addEventListener('input', (e) => {
200
+ suggest.execute(e.target.value) // debounced automatically
382
201
  })
383
202
 
384
- // Execute with initial options
385
- await search.execute()
386
-
387
- // Override and persist new options
388
- await search.execute({ page: 2 }) // page=2 persists
389
- await search.execute({ filters: { vendor: 'Adidas' } }) // filters updated
390
-
391
- // Temporary override without persisting
392
- await search.execute({ query: 'shoes', transient: true }) // query stays 'ring' for next call
393
-
394
- // Prepare search (optional, caches searchId for faster execute)
395
- await search.prepare()
396
- await search.execute() // uses cached searchId
397
-
398
- // Unsubscribe
399
- unsubscribe()
400
- search.dispose()
203
+ suggest.dispose()
401
204
  ```
402
205
 
403
- **SearchQuery parameters** (used for init, execute, and prepare):
404
-
405
- | Parameter | Type | Description |
406
- | ---------------- | ------------------------- | --------------------------------------------- |
407
- | `query` | `string` | Search query (required for first execute) |
408
- | `searchId` | `string` | Use specific searchId (skips prepare) |
409
- | `page` | `number` | Page number (default: 1) |
410
- | `limit` | `number` | Products per page (default: 24) |
411
- | `filters` | `unknown` | Filter criteria |
412
- | `tuning` | `LayersTuning` | Search tuning parameters |
413
- | `signal` | `AbortSignal` | Abort signal (shared at init, per-call later) |
414
- | `dynamicLinking` | `Record<string, unknown>` | Custom dynamic linking parameters |
415
- | `params` | `Record<string, unknown>` | Additional request parameters |
416
- | `transformBody` | `(body) => body` | Custom request body mutation function |
417
- | `transient` | `boolean` | If true, overrides don't persist |
418
-
419
- **Controller methods:**
420
-
421
- | Method | Description |
422
- | ----------------- | -------------------------------------------------------- |
423
- | `prepare(query?)` | Prepare search and cache searchId for reuse |
424
- | `execute(query?)` | Execute search (uses cached searchId if available) |
425
- | `subscribe(cb)` | Subscribe to state changes, returns unsubscribe function |
426
- | `dispose()` | Cleanup all subscriptions and abort pending requests |
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()`) |
427
212
 
428
- **`LayersTuning`:**
213
+ ### Image Search
429
214
 
430
- | Property | Type | Description |
431
- | ---------------- | -------- | ------------------------------------------- |
432
- | `textualWeight` | `number` | Weight for text-based matching (0-1) |
433
- | `visualWeight` | `number` | Weight for visual similarity matching (0-1) |
434
- | `multipleFactor` | `number` | Factor for multiple keyword matching |
435
- | `minimumMatch` | `number` | Minimum match threshold |
436
-
437
- ### `sdk.uploadImage()` - Upload Image for Search
438
-
439
- 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.
215
+ Upload an image, then search by it.
440
216
 
441
217
  ```typescript
442
- const fileInput = document.querySelector('input[type="file"]')
443
- const file = fileInput.files[0]
444
-
445
- const state = sdk.uploadImage({ image: file, signal })
446
-
447
- // Option 1: Standalone subscribe() — works with any signal
448
- const unsubscribe = subscribe(state, ({ data, error, isFetching }) => {
449
- if (isFetching) console.log('Uploading...')
450
- if (error) console.error('Upload failed:', error.message)
451
- if (data) console.log('Image ID:', data.imageId)
452
- })
453
-
454
- // Option 2: Direct signal access (for custom reactivity)
455
- effect(() => {
456
- const { data, error, isFetching } = state.value
457
- // same as above
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)
224
+ })
458
225
  })
459
226
  ```
460
227
 
461
- ### `sdk.imageSearch()` - Search by Image
228
+ ### Abort Signals
462
229
 
463
- Returns a reactive signal that searches products using a previously uploaded image. Same signal-based pattern as `uploadImage()` and `storefront()`.
230
+ Controllers support two levels of abort:
464
231
 
465
232
  ```typescript
466
- const state = sdk.imageSearch({
467
- imageId: 'uploaded-image-id',
468
- limit: 20,
469
- filters: { vendor: 'Nike' },
470
- })
233
+ // Shared signal — cancels everything when component unmounts
234
+ const ac = new AbortController()
235
+ const search = client.search({ query: 'ring', signal: ac.signal })
471
236
 
472
- // Option 1: Standalone subscribe() works with any signal
473
- const unsubscribe = subscribe(state, ({ data, error, isFetching }) => {
474
- if (data) console.log('Similar products:', data.products)
475
- })
237
+ // Per-call signalcancels only this request
238
+ const req = new AbortController()
239
+ await search.execute({ page: 2, signal: req.signal })
476
240
 
477
- // Option 2: Direct signal access (for custom reactivity)
478
- effect(() => {
479
- const { data, error, isFetching } = state.value
480
- // same as above
481
- })
241
+ // Either aborting cancels the request (they're linked internally)
242
+ ac.abort() // cancels all pending + acts like dispose()
482
243
  ```
483
244
 
484
- **Parameters:**
485
-
486
- | Parameter | Type | Required | Description |
487
- | --------- | -------------- | -------- | ------------------------------- |
488
- | `imageId` | `string` | Yes | Image ID from `uploadImage()` |
489
- | `page` | `number` | No | Page number (default: 1) |
490
- | `limit` | `number` | No | Products per page (default: 24) |
491
- | `filters` | `unknown` | No | Filter criteria |
492
- | `tuning` | `LayersTuning` | No | Search tuning parameters |
493
- | `signal` | `AbortSignal` | No | External abort signal |
245
+ Collection and blocks auto-cancel the previous request when a new `execute()` starts.
494
246
 
495
- ### `sdk.storefront()` - Fetch Products and Metadata
247
+ ## Product Card
496
248
 
497
- 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.
498
250
 
499
251
  ```typescript
500
- const state = sdk.storefront({
501
- ids: ['gid://shopify/Product/123', 'gid://shopify/Product/456'],
502
- meta: {
503
- collection: 'shirts', // optional: fetch collection metadata
504
- page: 'about', // optional: fetch page metadata
505
- includeFilters: true, // optional: include available filters
506
- },
507
- })
252
+ import { createProductCard, effect } from '@commerce-blocks/sdk'
508
253
 
509
- // Option 1: Standalone subscribe() — works with any signal
510
- const unsubscribe = subscribe(state, ({ data, error, isFetching }) => {
511
- if (data) {
512
- console.log('Products:', data.products)
513
- console.log('Collection:', data.collection)
514
- console.log('Page:', data.page)
515
- }
254
+ const card = createProductCard({
255
+ product,
256
+ selectedOptions: [{ name: 'Size', value: '7' }],
257
+ breakoutOptions: [{ name: 'Stone', value: 'Ruby' }],
516
258
  })
517
259
 
518
- // Option 2: Direct signal access (for custom reactivity)
260
+ // Reactive signals
519
261
  effect(() => {
520
- const { data, error, isFetching } = state.value
521
- // same as above
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
522
266
  })
523
- ```
524
-
525
- **Parameters:**
526
-
527
- | Parameter | Type | Required | Description |
528
- | --------------------- | ------------- | -------- | ---------------------------------------- |
529
- | `ids` | `string[]` | Yes | Product GIDs |
530
- | `meta.collection` | `string` | No | Collection handle to fetch metadata |
531
- | `meta.page` | `string` | No | Page handle to fetch metadata |
532
- | `meta.includeFilters` | `boolean` | No | Include available filters for collection |
533
- | `signal` | `AbortSignal` | No | External abort signal |
534
-
535
- ## Abort Signals
536
-
537
- Controllers support two levels of abort signals:
538
-
539
- 1. **Shared signal** — passed at initialization, affects all operations
540
- 2. **Per-call signal** — passed to `execute()` or `prepare()`, affects only that call
541
-
542
- Both signals are linked internally using `linkedAbort()` — when either signal aborts, the request cancels. This allows component-level cancellation (shared) alongside request-level cancellation (per-call).
543
-
544
- ```typescript
545
- // Shared signal at init — cancels everything when component unmounts
546
- const componentController = new AbortController()
547
267
 
548
- const search = sdk.search({
549
- query: 'ring',
550
- signal: componentController.signal, // shared: affects all operations
551
- })
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
552
273
 
553
- const autocomplete = sdk.autocomplete({
554
- signal: componentController.signal, // shared: acts like dispose() when aborted
274
+ card.subscribe(({ selectedVariant, options, price }) => {
275
+ // Called on any state change
555
276
  })
556
277
 
557
- // Per-call signal — cancels only this specific request
558
- const requestController = new AbortController()
559
- await search.execute({ page: 2, signal: requestController.signal })
560
- requestController.abort() // cancels only this request
561
-
562
- // Shared signal abort cancels everything
563
- componentController.abort() // cancels all pending requests + acts like dispose()
278
+ card.dispose()
564
279
  ```
565
280
 
566
- **How signals compose:**
567
-
568
- | Scenario | Behavior |
569
- | -------------------- | ----------------------------------------- |
570
- | Only shared signal | All requests use shared signal |
571
- | Only per-call signal | That request uses per-call signal |
572
- | Both signals | Request cancels if either aborts |
573
- | Neither signal | Controller manages its own internal abort |
574
-
575
- **Auto-cancellation:**
281
+ **Reactive state** (all `ReadonlySignal`): `variants`, `selectedVariant`, `options`, `images`, `price`, `priceRange`, `carouselPosition`, `isSelectionComplete`.
576
282
 
577
- Controllers that don't support concurrent requests (collection, blocks) automatically abort the previous request when a new one starts. The abort signals add external cancellation paths on top of this built-in behavior.
578
-
579
- ## Response Types
283
+ Options include availability status baked in:
580
284
 
581
285
  ```typescript
582
- // All controllers use QueryState<T> for reactive state
583
- interface QueryState<T> {
584
- data: T | null
585
- error: SdkError | null
586
- isFetching: boolean
286
+ interface OptionGroup {
287
+ name: string
288
+ values: OptionValue[]
587
289
  }
588
290
 
589
- interface CollectionResult {
590
- products: Product[]
591
- totalResults: number
592
- totalPages: number
593
- page: number
594
- resultsPerPage?: number
595
- facets: Record<string, Record<string, number>>
596
- facetRanges?: Record<string, { min: number; max: number }>
597
- priceRange?: PriceRange // Formatted min/max prices from result set
598
- attributionToken: string
599
- collection?: StorefrontCollection
291
+ interface OptionValue {
292
+ value: string
293
+ status: 'available' | 'backorderable' | 'sold-out' | 'unavailable'
294
+ selected: boolean
295
+ swatch: Swatch | null
600
296
  }
601
297
 
602
- interface SearchResult {
603
- products: Product[]
604
- totalResults: number
605
- totalPages: number
606
- page: number
607
- resultsPerPage?: number
608
- facets: Record<string, Record<string, number>>
609
- facetRanges?: Record<string, { min: number; max: number }>
610
- priceRange?: PriceRange // Formatted min/max prices from result set
611
- attributionToken: string
612
- }
613
-
614
- interface BlocksResult {
615
- products: Product[]
616
- totalResults: number
617
- totalPages: number
618
- page: number
619
- resultsPerPage?: number
620
- facets: Record<string, Record<string, number>>
621
- facetRanges?: Record<string, { min: number; max: number }>
622
- priceRange?: PriceRange // Formatted min/max prices from result set
623
- attributionToken: string
624
- block?: BlocksInfo // { id, title, anchor_type, strategy_type, strategy_key }
298
+ interface PriceData {
299
+ price: Price | null
300
+ compareAtPrice: Price | null
301
+ isOnSale: boolean
625
302
  }
626
303
 
627
- interface PriceRange {
628
- min: Price
629
- max: Price
630
- }
631
-
632
- interface Price {
633
- amount: number
634
- currencyCode: string
635
- formatted: string
636
- }
637
-
638
- interface StorefrontResult {
639
- products: Product[]
640
- collection?: StorefrontCollection
641
- page?: StorefrontPage
304
+ interface PriceRangeData {
305
+ priceRange: PriceRange
306
+ compareAtPriceRange: PriceRange | null
642
307
  }
643
308
  ```
644
309
 
645
- ## Error Handling
310
+ ## Filters
646
311
 
647
- All methods return `Result<T, E>` instead of throwing. Errors are discriminated by `_tag`:
312
+ Build filters using the DSL:
648
313
 
649
314
  ```typescript
650
- const result = await collection.execute()
651
-
652
- if (result.error) {
653
- switch (result.error._tag) {
654
- case 'NetworkError':
655
- // Connection issues, timeouts, aborted requests
656
- console.log(result.error.code) // 'TIMEOUT' | 'CONNECTION_FAILED' | 'ABORTED' | ...
657
- break
658
- case 'ApiError':
659
- // Server errors, rate limits
660
- console.log(result.error.source) // 'layers' | 'storefront'
661
- console.log(result.error.status) // HTTP status code
662
- break
663
- case 'ValidationError':
664
- // Invalid parameters
665
- console.log(result.error.operation) // which method failed
666
- console.log(result.error.fields) // [{ field, code, message }]
667
- break
668
- case 'ConfigError':
669
- // SDK configuration issues
670
- console.log(result.error.field) // which config field
671
- break
672
- }
673
- } else {
674
- const data = result.data
675
- }
676
- ```
677
-
678
- ### Error Types
679
-
680
- **`NetworkError`** — connection failures, timeouts, aborted requests
681
-
682
- | Field | Type | Description |
683
- | -------------- | ------------------ | ------------------------------------------------------------------------------- |
684
- | `code` | `NetworkErrorCode` | `TIMEOUT`, `CONNECTION_FAILED`, `DNS_FAILED`, `SSL_ERROR`, `ABORTED`, `OFFLINE` |
685
- | `message` | `string` | Human-readable description |
686
- | `retryable` | `boolean` | Whether the request can be retried |
687
- | `retryAfterMs` | `number?` | Suggested retry delay in milliseconds |
688
-
689
- **`ApiError`** — server errors, rate limits, GraphQL errors
690
-
691
- | Field | Type | Description |
692
- | -------------- | -------------- | -------------------------------------------------- |
693
- | `code` | `ApiErrorCode` | `NOT_FOUND`, `RATE_LIMITED`, `GRAPHQL_ERROR`, etc. |
694
- | `source` | `ApiSource` | `'layers'` or `'storefront'` |
695
- | `status` | `number?` | HTTP status code |
696
- | `retryable` | `boolean` | Whether the request can be retried |
697
- | `retryAfterMs` | `number?` | Suggested retry delay |
698
-
699
- **`ValidationError`** — invalid parameters passed to SDK methods
700
-
701
- | Field | Type | Description |
702
- | ----------- | ------------------------ | ------------------------------------- |
703
- | `operation` | `string` | Which method failed (e.g. `'search'`) |
704
- | `fields` | `ValidationFieldError[]` | `[{ field, code, message }]` |
315
+ import { filter, and, or, eq, gte, lte, inValues } from '@commerce-blocks/sdk'
705
316
 
706
- **`ConfigError`** — SDK configuration issues at init time
707
-
708
- | Field | Type | Description |
709
- | ---------- | --------- | --------------------------- |
710
- | `field` | `string` | Which config field is wrong |
711
- | `expected` | `string?` | What was expected |
712
-
713
- ### Error Helpers
714
-
715
- Always check `isRetryable()` before calling `getRetryDelay()`. `getRetryDelay()` returns `undefined` for non-retryable errors — it's not meant to be used standalone.
716
-
717
- ```typescript
718
- import { isRetryable, getRetryDelay } from '@commerce-blocks/sdk'
719
-
720
- if (result.error && isRetryable(result.error)) {
721
- const delay = getRetryDelay(result.error) ?? 1000
722
- setTimeout(() => collection.execute(), delay)
723
- }
724
- ```
725
-
726
- ## Filtering
727
-
728
- Build filters using the DSL helpers:
729
-
730
- ```typescript
731
- import { filter, and, or, eq, inValues, gte, lte } from '@commerce-blocks/sdk'
732
-
733
- // Single filter
734
- const colorFilter = filter(eq('options.color', 'Red'))
735
-
736
- // Multiple conditions (AND)
737
- const multiFilter = filter(and(eq('options.color', 'Red'), eq('options.size', 'Medium')))
738
-
739
- // Multiple values (OR)
740
- 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
+ })
741
320
 
742
321
  // Price range
743
- const priceFilter = filter(and(gte('price', 50), lte('price', 200)))
322
+ filter(and(gte('price', 50), lte('price', 200)))
744
323
 
745
- // Use in query
746
- await collection.execute({
747
- filters: multiFilter,
748
- })
324
+ // Multiple values
325
+ filter(or(eq('vendor', 'Nike'), eq('vendor', 'Adidas')))
749
326
  ```
750
327
 
751
- ### Filter Operators
752
-
753
- | Function | Description |
328
+ | Operator | Description |
754
329
  | ------------------------------ | ----------------------- |
755
330
  | `eq(property, value)` | Equals |
756
331
  | `notEq(property, value)` | Not equals |
@@ -763,293 +338,181 @@ await collection.execute({
763
338
  | `exists(property)` | Property exists |
764
339
  | `notExists(property)` | Property does not exist |
765
340
 
766
- ### Filter Mapping
341
+ ## Transforms and Filter Aliases
767
342
 
768
- 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:
769
344
 
770
345
  ```typescript
771
- const sdk = createSdk({
772
- // ... other config
773
- 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: {
774
364
  color: 'options.color',
775
365
  size: 'options.size',
776
366
  brand: { property: 'vendor', values: { nike: 'Nike', adidas: 'Adidas' } },
777
367
  },
778
368
  })
779
369
 
780
- // Now you can use simple keys
781
- await collection.execute({
782
- filters: { color: 'Red', brand: 'nike' },
783
- })
370
+ // Aliases resolve automatically
371
+ await collection.execute({ filters: { color: 'Red', brand: 'nike' } })
372
+ // Products now include description and rating from the product transform
784
373
  ```
785
374
 
786
- ## Product Card
787
-
788
- ### `createProductCard()` - Reactive Product Card Controller
375
+ ## Error Handling
789
376
 
790
- 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`:
791
378
 
792
379
  ```typescript
793
- import { createProductCard, effect } from '@commerce-blocks/sdk'
794
-
795
- const card = createProductCard({
796
- product,
797
- selectedOptions: [{ name: 'Size', value: '7' }],
798
- breakoutOptions: [{ name: 'Stone', value: 'Ruby' }],
799
- })
800
-
801
- // Subscribe to reactive state
802
- effect(() => {
803
- console.log('Selected variant:', card.selectedVariant.value)
804
- console.log('Available options:', card.options.value)
805
- })
380
+ const result = await collection.execute()
806
381
 
807
- // Access reactive signals
808
- card.variants.value // Variants filtered by breakoutOptions
809
- card.selectedVariant.value // Currently selected variant
810
- card.options.value // Available options (excludes breakout option names)
811
- card.images.value // Variant image or product images
812
- card.carouselIndex.value // Image index for carousel
813
-
814
- // Mutate state via actions
815
- card.selectOption('Size', 'L') // Select a single option
816
- card.setSelectedOptions([{ name: 'Size', value: 'L' }]) // Merge options by name
817
- card.setBreakoutOptions([{ name: 'Stone', value: 'Emerald' }]) // Change breakout filter
818
-
819
- // Query methods (pure)
820
- card.getOptionValues('Size') // ['S', 'M', 'L']
821
- card.getSwatches('Color') // Swatch definitions
822
- card.isOptionAvailable('Size', 'L') // Check if selecting 'L' results in available variant
823
- card.getVariantByOptions([{ name: 'Size', value: 'L' }])
824
-
825
- // Unsubscribe
826
- 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
+ }
827
399
  ```
828
400
 
829
- **Parameters:**
830
-
831
- | Parameter | Type | Required | Description |
832
- | ----------------- | ----------------- | -------- | -------------------------------------------------------- |
833
- | `product` | `Product` | Yes | Product to display |
834
- | `selectedOptions` | `ProductOption[]` | No | Initially selected options |
835
- | `breakoutOptions` | `ProductOption[]` | No | Options to filter variants (auto-set from variant tiles) |
836
-
837
- **Reactive State (ReadonlySignal):**
838
-
839
- | Property | Type | Description |
840
- | ----------------- | ---------------------------------------- | --------------------------------------- |
841
- | `product` | `Product` | Original product (static) |
842
- | `variants` | `ReadonlySignal<ProductVariant[]>` | Variants matching breakoutOptions |
843
- | `selectedVariant` | `ReadonlySignal<ProductVariant \| null>` | Variant matching all selected options |
844
- | `selectedOptions` | `ReadonlySignal<ProductOption[]>` | Combined breakout + selected options |
845
- | `options` | `ReadonlySignal<RichProductOption[]>` | Available options from visible variants |
846
- | `optionNames` | `ReadonlySignal<string[]>` | Option names (excludes breakout) |
847
- | `images` | `ReadonlySignal<Image[]>` | Variant image or product images |
848
- | `carouselIndex` | `ReadonlySignal<number>` | Index of selected variant's image |
849
-
850
- **Actions:**
851
-
852
- | Method | Description |
853
- | ----------------------------- | ------------------------------- |
854
- | `selectOption(name, value)` | Select a single option by name |
855
- | `setSelectedOptions(options)` | Merge options by name |
856
- | `setBreakoutOptions(options)` | Replace breakout filter options |
857
- | `dispose()` | Cleanup controller |
858
-
859
- **Query Methods:**
860
-
861
- | Method | Returns | Description |
862
- | -------------------------------- | ------------------------ | ------------------------------------------------------ |
863
- | `getOptionValues(name)` | `string[]` | All values for an option from visible variants |
864
- | `getSwatches(name)` | `Swatch[]` | Swatch definitions for an option |
865
- | `getVariantByOptions(opts)` | `ProductVariant \| null` | Find variant by options |
866
- | `isOptionAvailable(name, value)` | `boolean` | Check if selecting option results in available variant |
867
- | `isVariantAvailable(variant)` | `boolean` | Check if variant is available for sale |
868
-
869
- ## Singleton Access
870
-
871
- After initialization, access the SDK anywhere:
401
+ Retryable errors (`TIMEOUT`, `CONNECTION_FAILED`, `RATE_LIMITED`, `SERVICE_UNAVAILABLE`) expose `retryable` and `retryAfterMs`:
872
402
 
873
403
  ```typescript
874
- import { getSdk, isInitialized } from '@commerce-blocks/sdk'
404
+ import { isRetryable } from '@commerce-blocks/sdk'
875
405
 
876
- if (isInitialized()) {
877
- const result = getSdk()
878
- if (result.data) {
879
- const sdk = result.data
880
- // Use sdk
881
- }
406
+ if (result.error && isRetryable(result.error)) {
407
+ const delay = result.error.retryAfterMs ?? 1000
408
+ setTimeout(() => collection.execute(), delay)
882
409
  }
883
410
  ```
884
411
 
885
- ## Store API
412
+ ## Cache and Storage
886
413
 
887
- The SDK exposes a reactive store for caching:
414
+ The client exposes a reactive cache:
888
415
 
889
416
  ```typescript
890
- const { store } = sdk
891
-
892
- // Product cache
893
- const product = store.products.get('gid://shopify/Product/123')
894
- const { cached, missing } = store.products.getMany(gids)
895
- store.products.set(products)
896
-
897
- // Query cache (reactive)
898
- const querySignal = store.queries.get('cache-key')
899
- store.queries.invalidate('browse:*') // invalidate by pattern
900
-
901
- // Collection metadata
902
- const meta = store.collections.get('shirts')
903
-
904
- // Page metadata
905
- const page = store.pages.get('about')
906
- store.pages.set('about', { title: 'About Us', body: '...' })
907
- store.pages.delete('about')
908
-
909
- // Persistence
910
- store.persist() // save to storage
911
- store.restore() // restore from storage
912
- store.clear() // clear all caches
913
-
914
- // Stats
915
- 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
916
425
  ```
917
426
 
918
- ## Storage Adapters
919
-
920
- Configure how the SDK persists cache data. By default, the SDK uses `localStorage` in browsers.
921
-
922
- ### Built-in Adapters
427
+ ### Storage Adapters
923
428
 
924
429
  ```typescript
925
- import { localStorageAdapter, jsonFileAdapter } from '@commerce-blocks/sdk'
430
+ import { localStorageAdapter, fileStorage } from '@commerce-blocks/sdk'
926
431
 
927
- // Browser: localStorage (returns null if unavailable)
432
+ // Browser (returns null if unavailable)
928
433
  const browserAdapter = localStorageAdapter('my-cache-key')
929
434
 
930
- // Node.js: JSON file
435
+ // Node.js
931
436
  import fs from 'fs'
932
- const nodeAdapter = jsonFileAdapter('./cache.json', fs)
437
+ const nodeAdapter = fileStorage('./cache.json', fs)
933
438
  ```
934
439
 
935
- ### Custom Adapter
936
-
937
- Implement the `StorageAdapter` interface:
440
+ Custom adapter — implement `StorageAdapter`:
938
441
 
939
442
  ```typescript
940
- interface StorageAdapter {
941
- read(): string | null
942
- write(data: string): void
943
- remove(): void
944
- }
945
-
946
- // Example: sessionStorage adapter
947
- const sessionAdapter: StorageAdapter = {
948
- read: () => sessionStorage.getItem('my-key'),
949
- write: (data) => sessionStorage.setItem('my-key', data),
950
- 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'),
951
447
  }
952
448
  ```
953
449
 
954
- ## Standalone Utilities
450
+ ## Signals
955
451
 
956
- ### `search()` - Direct Layers Search
957
-
958
- 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:
959
453
 
960
454
  ```typescript
961
- import { layersClient, search } from '@commerce-blocks/sdk'
962
-
963
- const clientResult = layersClient({
964
- layersPublicToken: 'your-token',
965
- sorts: [{ label: 'Featured', code: 'featured' }],
966
- })
967
-
968
- if (clientResult.data) {
969
- const result = await search(clientResult.data, 'red dress', {
970
- pagination: { page: 1, limit: 20 },
971
- })
972
-
973
- if (result.data) {
974
- console.log('Raw Layers results:', result.data.results)
975
- }
976
- }
455
+ import { signal, computed, effect, batch } from '@commerce-blocks/sdk'
977
456
  ```
978
457
 
979
- ### Cache Key Generators
980
-
981
- Build cache keys for manual store operations:
458
+ Controller `state` is a `ReadonlySignal<QueryState<T>>`:
982
459
 
983
460
  ```typescript
984
- import { browseKey, searchKey, similarKey, blocksKey, productsKey } from '@commerce-blocks/sdk'
985
-
986
- browseKey('shirts', { page: 1, limit: 24 }) // '/browse/shirts?limit=24&page=1'
987
- searchKey('red dress', { page: 2 }) // '/search/red%20dress?page=2'
988
- similarKey(123, { limit: 10 }) // '/similar/123?limit=10'
989
- blocksKey('block-abc', { anchorId: 'gold-necklace', page: 1 })
990
- 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
+ }
991
466
  ```
992
467
 
993
- ## Testing
468
+ All controllers return the same `QueryResult` shape in `data`:
994
469
 
995
- Tests use Playwright running in a real browser context. The SDK is loaded via a test harness page and accessed through `window.SDK`.
996
-
997
- ```bash
998
- npm run test # run all tests
999
- npm run test:ui # interactive UI
1000
- npx playwright test tests/client.spec.ts # single file
1001
- 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
+ }
1002
480
  ```
1003
481
 
1004
- ### Test helpers
482
+ Blocks results add a `block` field with `{ id, title, anchor_type, strategy_type, strategy_key }`.
1005
483
 
1006
- - `setupSdk(page)` — navigates to the test harness and waits for `window.SDK_READY`
1007
- - `inBrowser(page, fn)` — runs a function inside the browser via `page.evaluate()`
1008
-
1009
- ### Writing tests
484
+ ## Singleton Access
1010
485
 
1011
- Tests run SDK code inside the browser and return serializable results for assertions:
486
+ After initialization, access the client anywhere:
1012
487
 
1013
488
  ```typescript
1014
- import { test, expect } from '@playwright/test'
1015
- import { setupSdk, inBrowser } from './helpers'
1016
-
1017
- test.describe('SDK: feature()', () => {
1018
- test.beforeEach(async ({ page }) => {
1019
- await setupSdk(page)
1020
- })
1021
-
1022
- test('does something', async ({ page }) => {
1023
- const result = await inBrowser(page, async () => {
1024
- const { createSdk, devConfig } = (window as any).SDK
1025
- const { data: sdk } = createSdk(devConfig)
1026
- if (!sdk) return { error: 'init failed' }
1027
-
1028
- const controller = sdk.collection({ handle: 'shirts' })
1029
- const { data, error } = await controller.execute({ limit: 3 })
1030
- controller.dispose()
489
+ import { getClient, isInitialized } from '@commerce-blocks/sdk'
1031
490
 
1032
- if (error) return { error: error._tag }
1033
- return { count: data.products.length }
1034
- })
1035
-
1036
- expect(result.error).toBeUndefined()
1037
- expect(result.count).toBeGreaterThan(0)
1038
- })
1039
- })
491
+ if (isInitialized()) {
492
+ const { data: client } = getClient()
493
+ // Use client
494
+ }
1040
495
  ```
1041
496
 
1042
- Key patterns:
1043
-
1044
- - All SDK calls happen inside `inBrowser()` — you can't access SDK objects directly in Node
1045
- - Return plain objects from `inBrowser()`, not class instances or signals
1046
- - Always `dispose()` controllers to prevent leaks between tests
1047
- - Use `devConfig` from the test harness for real API credentials
497
+ ## Response Types
1048
498
 
1049
- ## Technical Details
499
+ The SDK exports all types from `@commerce-blocks/sdk`:
1050
500
 
1051
- - **Runtime**: Browser (ESM)
1052
- - **TypeScript**: Full type definitions included
1053
- - **Dependencies**: `@preact/signals-core`
1054
- - **Bundle**: Tree-shakeable ES modules
1055
- - **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
+ ```