@commerce-blocks/sdk 2.0.0-alpha.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +310 -850
  2. package/dist/index.d.ts +345 -581
  3. package/dist/index.js +1343 -2004
  4. package/package.json +2 -7
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,318 @@ 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
+ | `transforms` | `Transforms` | No | Post-process results (see below) |
55
+ | `filterAliases` | `FilterAliases` | No | URL-friendly filter key mapping |
56
+ | `cacheLimit` | `number` | No | Max entries in cache |
57
+ | `cacheLifetime` | `number` | No | TTL in milliseconds |
58
+ | `storage` | `StorageAdapter` | No | Custom storage adapter |
59
+ | `initialData` | `CacheData` | No | Pre-populate cache at init |
60
+ | `restoreCache` | `boolean` | No | Restore from storage (default: true) |
61
+
62
+ ## Controllers
63
+
64
+ All controllers (`collection`, `blocks`, `search`, `suggest`) follow the same pattern:
65
+
66
+ - **`state`** — a `ReadonlySignal<QueryState<T>>` with `{ data, error, isFetching }`
67
+ - **`execute()`** — runs the query, returns `Result<T, ClientError>`
68
+ - **`subscribe(callback)`** — reacts to state changes without importing signals
69
+ - **`dispose()`** — cleans up subscriptions and aborts pending requests
70
+
71
+ ### Subscribing to State
72
+
73
+ Three ways to consume controller state (pick one):
100
74
 
101
75
  ```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
- },
76
+ // 1. Controller subscribe — no signal import needed
77
+ const unsubscribe = controller.subscribe(({ data, error, isFetching }) => {
78
+ if (isFetching) showLoading()
79
+ if (error) showError(error.message)
80
+ if (data) render(data.products)
114
81
  })
115
82
 
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
83
+ // 2. Standalone subscribe works with any signal
84
+ import { subscribe } from '@commerce-blocks/sdk'
85
+ const unsubscribe = subscribe(controller.state, (state) => {
86
+ /* ... */
87
+ })
131
88
 
132
- | Option | Type | Description |
133
- | -------------------- | --------------------------------------- | ----------------------------------------- |
134
- | `initialData` | `{ products?, queries?, collections? }` | Pre-populate cache at init |
135
- | `restoreFromStorage` | `boolean` | Auto-restore from storage (default: true) |
89
+ // 3. Direct signal access — for custom reactivity
90
+ import { effect } from '@commerce-blocks/sdk'
91
+ effect(() => {
92
+ const { data } = controller.state.value
93
+ })
94
+ ```
136
95
 
137
- ## SDK Methods
96
+ ### Shared Query Parameters
138
97
 
139
- ### `sdk.collection()` - Browse Collections
98
+ These parameters are available on `execute()` for all controllers except suggest:
140
99
 
141
- Creates a collection controller for browsing products in a collection. Controllers expose three ways to consume results:
100
+ | Parameter | Type | Description |
101
+ | ------------------ | ------------------------- | ---------------------------------- |
102
+ | `page` | `number` | Page number (default: 1) |
103
+ | `limit` | `number` | Products per page (default: 24) |
104
+ | `filters` | `unknown` | Filter criteria |
105
+ | `signal` | `AbortSignal` | Per-call abort signal |
106
+ | `linking` | `Record<string, unknown>` | Dynamic linking parameters |
107
+ | `transformRequest` | `(body) => body` | Custom request body transformation |
142
108
 
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
109
+ ### Collection
146
110
 
147
- This pattern applies to all controllers (`collection`, `search`, `blocks`, `autocomplete`).
111
+ Browse products in a collection.
148
112
 
149
113
  ```typescript
150
- const collection = sdk.collection({
114
+ const collection = client.collection({
151
115
  handle: 'shirts',
152
116
  defaultSort: 'featured', // optional, uses first sort if omitted
153
117
  })
154
118
 
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')
119
+ await collection.execute() // initial load
120
+ await collection.execute({ page: 2 }) // paginate
121
+ await collection.execute({ sort: 'price_asc' }) // change sort
122
+ await collection.execute({ filters: { color: 'Red' } })
123
+ await collection.execute({ includeMeta: true }) // fetch collection metadata
177
124
 
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
125
+ collection.dispose()
185
126
  ```
186
127
 
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 |
128
+ Additional `execute()` params: `sort`, `includeMeta`.
211
129
 
212
- ### `sdk.blocks()` - Product Recommendations
130
+ ### Blocks
213
131
 
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.
132
+ Product recommendations powered by Layers blocks. Anchored to a product, collection, or cart.
215
133
 
216
134
  ```typescript
217
- const blocks = sdk.blocks({
135
+ const blocks = client.blocks({
218
136
  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
137
+ anchor: 'gold-necklace', // product/collection ID or handle
233
138
  })
234
139
 
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
140
  await blocks.execute()
243
- await blocks.execute({ page: 2, limit: 12 })
244
-
245
- // With cart context for personalized recommendations
246
141
  await blocks.execute({
142
+ discounts: [
143
+ {
144
+ entitled: { all: true },
145
+ discount: { type: 'PERCENTAGE', value: 10 },
146
+ },
147
+ ],
247
148
  context: {
248
149
  productsInCart: [{ productId: '123', variantId: '456', quantity: 1 }],
249
150
  geo: { country: 'US' },
250
151
  },
251
152
  })
252
153
 
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
154
+ // result.data.block has { id, title, anchor_type, strategy_type, ... }
155
+ blocks.dispose()
261
156
  ```
262
157
 
263
- **Options:**
158
+ Additional `execute()` params: `discounts`, `context`.
264
159
 
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 |
160
+ ### Search
270
161
 
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.
162
+ Full-text search with facets. Options persist across calls — subsequent `execute()` calls merge with existing options.
313
163
 
314
164
  ```typescript
315
- const autocomplete = sdk.autocomplete({ debounceMs: 300 })
165
+ const search = client.search({ query: 'ring', limit: 20 })
316
166
 
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
- })
167
+ await search.execute()
168
+ await search.execute({ page: 2 }) // page persists
169
+ await search.execute({ filters: { vendor: 'Nike' } }) // filters update
327
170
 
328
- // Option 3: Direct signal access (for custom reactivity)
329
- effect(() => {
330
- const { data, isFetching, error } = autocomplete.state.value
331
- // same as above
332
- })
171
+ // Temporary override (doesn't persist for next call)
172
+ await search.execute({ query: 'shoes', temporary: true })
333
173
 
334
- // Wire to input (debounced automatically)
335
- input.addEventListener('input', (e) => {
336
- autocomplete.execute(e.target.value)
337
- })
174
+ // Prepare search (caches searchId for faster execute)
175
+ await search.prepare()
176
+ await search.execute() // uses cached searchId
338
177
 
339
- // Unsubscribe
340
- unsubscribe() // remove single subscription
341
- autocomplete.dispose() // cleanup all subscriptions, abort controller, and timers
178
+ search.dispose()
342
179
  ```
343
180
 
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:**
181
+ Additional `execute()` params: `query`, `searchId`, `tuning`, `temporary`.
352
182
 
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 |
183
+ `SearchTuning` controls matching weights: `textualWeight`, `visualWeight`, `multipleFactor`, `minimumMatch`.
358
184
 
359
- ### `sdk.search()` - Search Products
185
+ ### Suggest
360
186
 
361
- Creates a search controller for full text search. Options persist across calls subsequent `execute()` and `prepare()` calls merge with these defaults.
187
+ Predictive search with debouncing and local caching. Only full words (trailing space) are cached partial input filters cached results client-side.
362
188
 
363
189
  ```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
- })
190
+ const suggest = client.suggest({ debounce: 300 })
372
191
 
373
- // Option 2: Standalone subscribe() works with any signal
374
- const unsubscribe = subscribe(search.state, ({ data, isFetching, error }) => {
375
- // same as above
192
+ suggest.subscribe(({ data }) => {
193
+ if (data) renderSuggestions(data.matchedQueries)
376
194
  })
377
195
 
378
- // Option 3: Direct signal access (for custom reactivity)
379
- effect(() => {
380
- const { data, isFetching, error } = search.state.value
381
- // same as above
196
+ input.addEventListener('input', (e) => {
197
+ suggest.execute(e.target.value) // debounced automatically
382
198
  })
383
199
 
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()
200
+ suggest.dispose()
401
201
  ```
402
202
 
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 |
203
+ | Option | Type | Description |
204
+ | ------------------- | ------------- | ------------------------------------------- |
205
+ | `debounce` | `number` | Debounce delay in ms (default: 300) |
206
+ | `excludeInputQuery` | `boolean` | Remove user's input from suggestions |
207
+ | `excludeQueries` | `string[]` | Custom strings to filter from suggestions |
208
+ | `signal` | `AbortSignal` | Shared abort signal (acts like `dispose()`) |
427
209
 
428
- **`LayersTuning`:**
210
+ ### Image Search
429
211
 
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.
212
+ Upload an image, then search by it.
440
213
 
441
214
  ```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
215
+ const upload = client.uploadImage({ image: file })
216
+ upload.subscribe(({ data }) => {
217
+ if (!data) return
218
+ const results = client.searchByImage({ imageId: data.imageId })
219
+ results.subscribe(({ data }) => {
220
+ if (data) console.log('Similar:', data.products)
221
+ })
458
222
  })
459
223
  ```
460
224
 
461
- ### `sdk.imageSearch()` - Search by Image
225
+ ### Abort Signals
462
226
 
463
- Returns a reactive signal that searches products using a previously uploaded image. Same signal-based pattern as `uploadImage()` and `storefront()`.
227
+ Controllers support two levels of abort:
464
228
 
465
229
  ```typescript
466
- const state = sdk.imageSearch({
467
- imageId: 'uploaded-image-id',
468
- limit: 20,
469
- filters: { vendor: 'Nike' },
470
- })
230
+ // Shared signal — cancels everything when component unmounts
231
+ const ac = new AbortController()
232
+ const search = client.search({ query: 'ring', signal: ac.signal })
471
233
 
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
- })
234
+ // Per-call signalcancels only this request
235
+ const req = new AbortController()
236
+ await search.execute({ page: 2, signal: req.signal })
476
237
 
477
- // Option 2: Direct signal access (for custom reactivity)
478
- effect(() => {
479
- const { data, error, isFetching } = state.value
480
- // same as above
481
- })
238
+ // Either aborting cancels the request (they're linked internally)
239
+ ac.abort() // cancels all pending + acts like dispose()
482
240
  ```
483
241
 
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 |
242
+ Collection and blocks auto-cancel the previous request when a new `execute()` starts.
494
243
 
495
- ### `sdk.storefront()` - Fetch Products and Metadata
244
+ ## Product Card
496
245
 
497
- Returns a reactive signal for fetching products by their Shopify GIDs, with optional collection/page metadata.
246
+ Reactive controller for product cards with variant selection and availability logic. All derived values are computed signals that auto-update when inputs change.
498
247
 
499
248
  ```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
- })
249
+ import { createProductCard, effect } from '@commerce-blocks/sdk'
508
250
 
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
- }
251
+ const card = createProductCard({
252
+ product,
253
+ selectedOptions: [{ name: 'Size', value: '7' }],
254
+ breakoutOptions: [{ name: 'Stone', value: 'Ruby' }],
516
255
  })
517
256
 
518
- // Option 2: Direct signal access (for custom reactivity)
257
+ // Reactive signals
519
258
  effect(() => {
520
- const { data, error, isFetching } = state.value
521
- // same as above
259
+ console.log('Variant:', card.selectedVariant.value)
260
+ console.log('Options:', card.options.value) // OptionGroup[]
261
+ console.log('Images:', card.images.value)
262
+ console.log('Price:', card.price.value) // PriceData
522
263
  })
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
264
 
548
- const search = sdk.search({
549
- query: 'ring',
550
- signal: componentController.signal, // shared: affects all operations
551
- })
265
+ // Actions
266
+ card.selectOption({ name: 'Size', value: 'L' })
267
+ card.setSelectedOptions([{ name: 'Size', value: 'L' }]) // merge by name
268
+ card.setSelectedVariant(12345) // select by numeric variant ID
269
+ card.setCarouselPosition(3) // 1-based manual override
552
270
 
553
- const autocomplete = sdk.autocomplete({
554
- signal: componentController.signal, // shared: acts like dispose() when aborted
271
+ card.subscribe(({ selectedVariant, options, price }) => {
272
+ // Called on any state change
555
273
  })
556
274
 
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()
275
+ card.dispose()
564
276
  ```
565
277
 
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:**
278
+ **Reactive state** (all `ReadonlySignal`): `variants`, `selectedVariant`, `options`, `images`, `price`, `priceRange`, `carouselPosition`, `isSelectionComplete`.
576
279
 
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
280
+ Options include availability status baked in:
580
281
 
581
282
  ```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
283
+ interface OptionGroup {
284
+ name: string
285
+ values: OptionValue[]
587
286
  }
588
287
 
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
288
+ interface OptionValue {
289
+ value: string
290
+ status: 'available' | 'backorderable' | 'sold-out' | 'unavailable'
291
+ selected: boolean
292
+ swatch: Swatch | null
600
293
  }
601
294
 
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 }
295
+ interface PriceData {
296
+ price: Price | null
297
+ compareAtPrice: Price | null
298
+ isOnSale: boolean
625
299
  }
626
300
 
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
301
+ interface PriceRangeData {
302
+ priceRange: PriceRange
303
+ compareAtPriceRange: PriceRange | null
642
304
  }
643
305
  ```
644
306
 
645
- ## Error Handling
307
+ ## Filters
646
308
 
647
- All methods return `Result<T, E>` instead of throwing. Errors are discriminated by `_tag`:
309
+ Build filters using the DSL:
648
310
 
649
311
  ```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 }]` |
312
+ import { filter, and, or, eq, gte, lte, inValues } from '@commerce-blocks/sdk'
705
313
 
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')))
314
+ await collection.execute({
315
+ filters: filter(and(eq('options.color', 'Red'), eq('options.size', 'Medium'))),
316
+ })
741
317
 
742
318
  // Price range
743
- const priceFilter = filter(and(gte('price', 50), lte('price', 200)))
319
+ filter(and(gte('price', 50), lte('price', 200)))
744
320
 
745
- // Use in query
746
- await collection.execute({
747
- filters: multiFilter,
748
- })
321
+ // Multiple values
322
+ filter(or(eq('vendor', 'Nike'), eq('vendor', 'Adidas')))
749
323
  ```
750
324
 
751
- ### Filter Operators
752
-
753
- | Function | Description |
325
+ | Operator | Description |
754
326
  | ------------------------------ | ----------------------- |
755
327
  | `eq(property, value)` | Equals |
756
328
  | `notEq(property, value)` | Not equals |
@@ -763,293 +335,181 @@ await collection.execute({
763
335
  | `exists(property)` | Property exists |
764
336
  | `notExists(property)` | Property does not exist |
765
337
 
766
- ### Filter Mapping
338
+ ## Transforms and Filter Aliases
767
339
 
768
- Use `filterMap` for URL-friendly filter keys:
340
+ 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
341
 
770
342
  ```typescript
771
- const sdk = createSdk({
772
- // ... other config
773
- filterMap: {
343
+ import type { Product } from '@commerce-blocks/sdk'
344
+
345
+ type MyProduct = Product<{ description: string; rating: number }>
346
+
347
+ const { data: client } = createClient({
348
+ // ...config
349
+ attributes: ['body_html'],
350
+ transforms: {
351
+ product: ({ raw }) => ({
352
+ description: raw.body_html ?? '',
353
+ rating: raw.calculated?.average_rating ?? 0,
354
+ }),
355
+ collection: (result, raw) => result,
356
+ search: (result, raw) => result,
357
+ block: (result, raw) => result,
358
+ filters: (filters) => filters,
359
+ },
360
+ filterAliases: {
774
361
  color: 'options.color',
775
362
  size: 'options.size',
776
363
  brand: { property: 'vendor', values: { nike: 'Nike', adidas: 'Adidas' } },
777
364
  },
778
365
  })
779
366
 
780
- // Now you can use simple keys
781
- await collection.execute({
782
- filters: { color: 'Red', brand: 'nike' },
783
- })
367
+ // Aliases resolve automatically
368
+ await collection.execute({ filters: { color: 'Red', brand: 'nike' } })
369
+ // Products now include description and rating from the product transform
784
370
  ```
785
371
 
786
- ## Product Card
787
-
788
- ### `createProductCard()` - Reactive Product Card Controller
372
+ ## Error Handling
789
373
 
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.
374
+ All methods return `{ data, error }` instead of throwing. Errors are discriminated by `_tag`:
791
375
 
792
376
  ```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
- })
377
+ const result = await collection.execute()
806
378
 
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()
379
+ if (result.error) {
380
+ switch (result.error._tag) {
381
+ case 'NetworkError':
382
+ // code: 'TIMEOUT' | 'CONNECTION_FAILED' | 'DNS_FAILED' | 'SSL_ERROR' | 'ABORTED' | 'OFFLINE'
383
+ break
384
+ case 'ApiError':
385
+ // code: 'NOT_FOUND' | 'RATE_LIMITED' | 'UNAUTHORIZED' | ...
386
+ // status: HTTP status code, source: 'layers'
387
+ break
388
+ case 'ValidationError':
389
+ // operation: which method failed, fields: [{ field, code, message }]
390
+ break
391
+ case 'ConfigError':
392
+ // field: which config field, expected: what was expected
393
+ break
394
+ }
395
+ }
827
396
  ```
828
397
 
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:
398
+ `isRetryable` classifies errors by tag, code, and status — use it standalone or as a `shouldRetry` predicate:
872
399
 
873
400
  ```typescript
874
- import { getSdk, isInitialized } from '@commerce-blocks/sdk'
401
+ import { isRetryable } from '@commerce-blocks/sdk'
875
402
 
876
- if (isInitialized()) {
877
- const result = getSdk()
878
- if (result.data) {
879
- const sdk = result.data
880
- // Use sdk
881
- }
403
+ if (result.error && isRetryable(result.error)) {
404
+ const delay = result.error.retryAfterMs ?? 1000
405
+ setTimeout(() => collection.execute(), delay)
882
406
  }
883
407
  ```
884
408
 
885
- ## Store API
409
+ ## Cache and Storage
886
410
 
887
- The SDK exposes a reactive store for caching:
411
+ The client exposes a reactive cache:
888
412
 
889
413
  ```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 }
414
+ const { cache } = client
415
+
416
+ cache.get('cache-key') // CacheEntry<QueryResult> | null
417
+ cache.invalidate('browse') // invalidate keys containing 'browse'
418
+ cache.persist() // save to storage
419
+ cache.restore() // restore from storage
420
+ cache.clear() // clear all
421
+ cache.stats.entries // current entry count
916
422
  ```
917
423
 
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
424
+ ### Storage Adapters
923
425
 
924
426
  ```typescript
925
- import { localStorageAdapter, jsonFileAdapter } from '@commerce-blocks/sdk'
427
+ import { localStorageAdapter, fileStorage } from '@commerce-blocks/sdk'
926
428
 
927
- // Browser: localStorage (returns null if unavailable)
429
+ // Browser (returns null if unavailable)
928
430
  const browserAdapter = localStorageAdapter('my-cache-key')
929
431
 
930
- // Node.js: JSON file
432
+ // Node.js
931
433
  import fs from 'fs'
932
- const nodeAdapter = jsonFileAdapter('./cache.json', fs)
434
+ const nodeAdapter = fileStorage('./cache.json', fs)
933
435
  ```
934
436
 
935
- ### Custom Adapter
936
-
937
- Implement the `StorageAdapter` interface:
437
+ Custom adapter — implement `StorageAdapter`:
938
438
 
939
439
  ```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'),
440
+ const adapter: StorageAdapter = {
441
+ read: () => sessionStorage.getItem('key'),
442
+ write: (data) => sessionStorage.setItem('key', data),
443
+ remove: () => sessionStorage.removeItem('key'),
951
444
  }
952
445
  ```
953
446
 
954
- ## Standalone Utilities
447
+ ## Signals
955
448
 
956
- ### `search()` - Direct Layers Search
957
-
958
- For advanced use cases bypassing SDK caching. Combines `prepareSearch` + `search` into a single call.
449
+ The SDK re-exports `@preact/signals-core` primitives for reactive state:
959
450
 
960
451
  ```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
- }
452
+ import { signal, computed, effect, batch } from '@commerce-blocks/sdk'
977
453
  ```
978
454
 
979
- ### Cache Key Generators
980
-
981
- Build cache keys for manual store operations:
455
+ Controller `state` is a `ReadonlySignal<QueryState<T>>`:
982
456
 
983
457
  ```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'])
458
+ interface QueryState<T> {
459
+ data: T | null
460
+ error: ClientError | null
461
+ isFetching: boolean
462
+ }
991
463
  ```
992
464
 
993
- ## Testing
465
+ All controllers return the same `QueryResult` shape in `data`:
994
466
 
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
467
+ ```typescript
468
+ interface QueryResult {
469
+ products: Product[]
470
+ totalResults: number
471
+ totalPages: number
472
+ page: number
473
+ facets: Record<string, Record<string, number>>
474
+ priceRange?: PriceRange
475
+ attributionToken?: string
476
+ }
1002
477
  ```
1003
478
 
1004
- ### Test helpers
479
+ Blocks results add a `block` field with `{ id, title, anchor_type, strategy_type, strategy_key }`.
1005
480
 
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
481
+ ## Singleton Access
1010
482
 
1011
- Tests run SDK code inside the browser and return serializable results for assertions:
483
+ After initialization, access the client anywhere:
1012
484
 
1013
485
  ```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()
486
+ import { getClient, isInitialized } from '@commerce-blocks/sdk'
1031
487
 
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
- })
488
+ if (isInitialized()) {
489
+ const { data: client } = getClient()
490
+ // Use client
491
+ }
1040
492
  ```
1041
493
 
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
494
+ ## Response Types
1048
495
 
1049
- ## Technical Details
496
+ The SDK exports all types from `@commerce-blocks/sdk`:
1050
497
 
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
498
+ ```typescript
499
+ import type {
500
+ Client,
501
+ ClientConfig,
502
+ QueryState,
503
+ QueryResult,
504
+ Product,
505
+ ProductBase,
506
+ ProductVariant,
507
+ OptionGroup,
508
+ OptionValue,
509
+ PriceData,
510
+ PriceRangeData,
511
+ ClientError,
512
+ NetworkError,
513
+ ApiError,
514
+ } from '@commerce-blocks/sdk'
515
+ ```