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

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 +199 -93
  2. package/dist/index.d.ts +141 -154
  3. package/dist/index.js +1178 -1105
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -138,29 +138,38 @@ await collection.execute({ filters: { color: 'Red' } }) // filterMap applied
138
138
 
139
139
  ### `sdk.collection()` - Browse Collections
140
140
 
141
- Creates a collection controller for browsing products in a collection. Controllers expose two ways to consume results:
141
+ Creates a collection controller for browsing products in a collection. Controllers expose three ways to consume results:
142
142
 
143
- - **Reactive**: Subscribe to `controller.state` (a `ReadonlySignal<QueryState<T>>`) via `effect()` state updates automatically on each execute
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
144
145
  - **Imperative**: `await controller.execute()` returns `Result<T, SdkError>` directly
145
146
 
146
- This pattern applies to all controllers (`collection`, `search`, `blocks`).
147
+ This pattern applies to all controllers (`collection`, `search`, `blocks`, `autocomplete`).
147
148
 
148
149
  ```typescript
149
- import { effect } from '@preact/signals-core'
150
-
151
150
  const collection = sdk.collection({
152
151
  handle: 'shirts',
153
152
  defaultSort: 'featured', // optional, uses first sort if omitted
154
153
  })
155
154
 
156
- // Subscribe to state changes
157
- effect(() => {
158
- const { data, error, isFetching } = collection.state.value
155
+ // Option 1: Controller subscribe() — no signal import needed
156
+ const unsubscribe = collection.subscribe(({ data, error, isFetching }) => {
159
157
  if (isFetching) console.log('Loading...')
160
158
  if (error) console.error('Error:', error.message)
161
159
  if (data) console.log('Products:', data.products)
162
160
  })
163
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
+
164
173
  // Execute queries — returns Result<CollectionResult, SdkError>
165
174
  const result = await collection.execute() // initial load
166
175
  if (result.error) console.error(result.error.message)
@@ -170,16 +179,20 @@ await collection.execute({ page: 2 }) // pagination
170
179
  await collection.execute({ sortOrderCode: 'price_asc' }) // change sort
171
180
  await collection.execute({ filters: { color: 'Red' } }) // with filters
172
181
 
173
- // Cleanup
174
- collection.dispose()
182
+ // Unsubscribe
183
+ unsubscribe() // remove single subscription
184
+ collection.dispose() // cleanup all subscriptions + abort pending requests
175
185
  ```
176
186
 
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
+
177
189
  **Options:**
178
190
 
179
- | Parameter | Type | Required | Description |
180
- | ------------- | -------- | -------- | ---------------------------------------------- |
181
- | `handle` | `string` | Yes | Collection URL handle |
182
- | `defaultSort` | `string` | No | Default sort code (uses first configured sort) |
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 |
183
196
 
184
197
  **Execute parameters:**
185
198
 
@@ -189,7 +202,7 @@ collection.dispose()
189
202
  | `limit` | `number` | Products per page (default: 24) |
190
203
  | `sortOrderCode` | `string` | Sort option code |
191
204
  | `filters` | `unknown` | Filter criteria |
192
- | `signal` | `AbortSignal` | External abort signal |
205
+ | `signal` | `AbortSignal` | Per-call abort signal |
193
206
  | `includeMeta` | `boolean` | Fetch collection metadata |
194
207
  | `includeFilters` | `boolean` | Include filter counts in response |
195
208
  | `dynamicLinking` | `Record<string, unknown>` | Custom dynamic linking parameters |
@@ -203,18 +216,28 @@ Creates a blocks controller for product recommendations powered by Layers blocks
203
216
  ```typescript
204
217
  const blocks = sdk.blocks({
205
218
  blockId: 'block-abc123',
206
- anchorHandle: 'gold-necklace', // anchor by product handle
219
+ anchorId: 'gold-necklace', // anchor by product ID or handle
207
220
  })
208
221
 
209
- // Subscribe to state changes
210
- effect(() => {
211
- const { data, error, isFetching } = blocks.state.value
222
+ // Option 1: Controller subscribe() — no signal import needed
223
+ const unsubscribe = blocks.subscribe(({ data, error, isFetching }) => {
212
224
  if (data) {
213
225
  console.log('Recommendations:', data.products)
214
226
  console.log('Block info:', data.block) // { title, anchor_type, strategy_type, ... }
215
227
  }
216
228
  })
217
229
 
230
+ // Option 2: Standalone subscribe() — works with any signal
231
+ const unsubscribe = subscribe(blocks.state, ({ data, error, isFetching }) => {
232
+ // same as above
233
+ })
234
+
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
+
218
241
  // Execute queries
219
242
  await blocks.execute()
220
243
  await blocks.execute({ page: 2, limit: 12 })
@@ -232,17 +255,18 @@ await blocks.execute({
232
255
  discountEntitlements: [{ id: 'discount-123' }],
233
256
  })
234
257
 
235
- // Cleanup
236
- blocks.dispose()
258
+ // Unsubscribe
259
+ unsubscribe() // remove single subscription
260
+ blocks.dispose() // cleanup all subscriptions + abort pending requests
237
261
  ```
238
262
 
239
263
  **Options:**
240
264
 
241
- | Parameter | Type | Required | Description |
242
- | -------------- | -------- | -------- | -------------------------------- |
243
- | `blockId` | `string` | Yes | Layers block ID |
244
- | `anchorId` | `string` | No | Anchor product/collection ID |
245
- | `anchorHandle` | `string` | No | Anchor product/collection handle |
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 |
246
270
 
247
271
  **Execute parameters:**
248
272
 
@@ -290,89 +314,116 @@ Creates a standalone autocomplete controller with debounced search and local cac
290
314
  ```typescript
291
315
  const autocomplete = sdk.autocomplete({ debounceMs: 300 })
292
316
 
293
- // Subscribe to state
294
- effect(() => {
295
- const { data, isFetching, error } = autocomplete.state.value
317
+ // Option 1: Controller subscribe() — no signal import needed
318
+ const unsubscribe = autocomplete.subscribe(({ data, isFetching, error }) => {
296
319
  if (isFetching) console.log('Loading suggestions...')
297
320
  if (data) renderSuggestions(data.matchedQueries)
298
321
  })
299
322
 
323
+ // Option 2: Standalone subscribe() — works with any signal
324
+ const unsubscribe = subscribe(autocomplete.state, ({ data, isFetching, error }) => {
325
+ // same as above
326
+ })
327
+
328
+ // Option 3: Direct signal access (for custom reactivity)
329
+ effect(() => {
330
+ const { data, isFetching, error } = autocomplete.state.value
331
+ // same as above
332
+ })
333
+
300
334
  // Wire to input (debounced automatically)
301
335
  input.addEventListener('input', (e) => {
302
336
  autocomplete.execute(e.target.value)
303
337
  })
304
338
 
305
- // Cleanup
306
- autocomplete.dispose()
339
+ // Unsubscribe
340
+ unsubscribe() // remove single subscription
341
+ autocomplete.dispose() // cleanup all subscriptions, abort controller, and timers
307
342
  ```
308
343
 
309
344
  **Options:**
310
345
 
311
- | Parameter | Type | Description |
312
- | ------------ | ------------- | ---------------------------------------------------------- |
313
- | `debounceMs` | `number` | Debounce delay (default: 300) |
314
- | `signal` | `AbortSignal` | External abort signal (acts like `dispose()` when aborted) |
346
+ | Parameter | Type | Description |
347
+ | ------------ | ------------- | -------------------------------------------------------- |
348
+ | `debounceMs` | `number` | Debounce delay (default: 300) |
349
+ | `signal` | `AbortSignal` | Shared abort signal (acts like `dispose()` when aborted) |
315
350
 
316
351
  **Controller methods:**
317
352
 
318
- | Method | Description |
319
- | ---------------- | -------------------------------------------- |
320
- | `execute(query)` | Debounced predictive search for autocomplete |
321
- | `dispose()` | Cleanup abort controller and timers |
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 |
322
358
 
323
359
  ### `sdk.search()` - Search Products
324
360
 
325
- Creates a search controller for full text search with prepare/execute flow.
361
+ Creates a search controller for full text search. Options persist across calls — subsequent `execute()` and `prepare()` calls merge with these defaults.
326
362
 
327
363
  ```typescript
328
- const search = sdk.search()
364
+ // Initialize with base options
365
+ const search = sdk.search({ query: 'ring', limit: 20 })
329
366
 
330
- // Subscribe to state
331
- effect(() => {
332
- const { data, isFetching, error } = search.state.value
367
+ // Option 1: Controller subscribe() — no signal import needed
368
+ const unsubscribe = search.subscribe(({ data, isFetching, error }) => {
333
369
  if (isFetching) console.log('Searching...')
334
370
  if (data) renderResults(data.products)
335
371
  })
336
372
 
337
- // Prepare search (optional, caches searchId for reuse)
338
- await search.prepare({ query: 'ring' })
373
+ // Option 2: Standalone subscribe() works with any signal
374
+ const unsubscribe = subscribe(search.state, ({ data, isFetching, error }) => {
375
+ // same as above
376
+ })
339
377
 
340
- // Execute search with pagination and filters
341
- form.addEventListener('submit', async (e) => {
342
- e.preventDefault()
343
- const result = await search.execute({
344
- query: inputValue,
345
- limit: 20,
346
- filters: { color: 'Blue' },
347
- })
348
- if (result.data) console.log('Results:', result.data.products)
378
+ // Option 3: Direct signal access (for custom reactivity)
379
+ effect(() => {
380
+ const { data, isFetching, error } = search.state.value
381
+ // same as above
349
382
  })
350
383
 
351
- // Cleanup
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()
352
400
  search.dispose()
353
401
  ```
354
402
 
355
- **Controller methods:**
356
-
357
- | Method | Description |
358
- | ---------------- | -------------------------------------------------- |
359
- | `prepare(query)` | Prepare search and cache searchId for reuse |
360
- | `execute(query)` | Execute search (uses cached searchId if available) |
361
- | `dispose()` | Cleanup abort controller |
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 |
362
418
 
363
- **Search parameters:**
419
+ **Controller methods:**
364
420
 
365
- | Parameter | Type | Description |
366
- | ---------------- | ------------------------- | ------------------------------------- |
367
- | `query` | `string` | Search query |
368
- | `page` | `number` | Page number (default: 1) |
369
- | `limit` | `number` | Products per page (default: 24) |
370
- | `filters` | `unknown` | Filter criteria |
371
- | `tuning` | `LayersTuning` | Search tuning parameters |
372
- | `signal` | `AbortSignal` | External abort signal |
373
- | `dynamicLinking` | `Record<string, unknown>` | Custom dynamic linking parameters |
374
- | `params` | `Record<string, unknown>` | Additional request parameters |
375
- | `transformBody` | `(body) => body` | Custom request body mutation function |
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 |
376
427
 
377
428
  **`LayersTuning`:**
378
429
 
@@ -393,12 +444,18 @@ const file = fileInput.files[0]
393
444
 
394
445
  const state = sdk.uploadImage({ image: file, signal })
395
446
 
396
- effect(() => {
397
- const { data, error, isFetching } = state.value
447
+ // Option 1: Standalone subscribe() works with any signal
448
+ const unsubscribe = subscribe(state, ({ data, error, isFetching }) => {
398
449
  if (isFetching) console.log('Uploading...')
399
450
  if (error) console.error('Upload failed:', error.message)
400
451
  if (data) console.log('Image ID:', data.imageId)
401
452
  })
453
+
454
+ // Option 2: Direct signal access (for custom reactivity)
455
+ effect(() => {
456
+ const { data, error, isFetching } = state.value
457
+ // same as above
458
+ })
402
459
  ```
403
460
 
404
461
  ### `sdk.imageSearch()` - Search by Image
@@ -412,9 +469,15 @@ const state = sdk.imageSearch({
412
469
  filters: { vendor: 'Nike' },
413
470
  })
414
471
 
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
+ })
476
+
477
+ // Option 2: Direct signal access (for custom reactivity)
415
478
  effect(() => {
416
479
  const { data, error, isFetching } = state.value
417
- if (data) console.log('Similar products:', data.products)
480
+ // same as above
418
481
  })
419
482
  ```
420
483
 
@@ -443,14 +506,20 @@ const state = sdk.storefront({
443
506
  },
444
507
  })
445
508
 
446
- effect(() => {
447
- const { data, error, isFetching } = state.value
509
+ // Option 1: Standalone subscribe() works with any signal
510
+ const unsubscribe = subscribe(state, ({ data, error, isFetching }) => {
448
511
  if (data) {
449
512
  console.log('Products:', data.products)
450
513
  console.log('Collection:', data.collection)
451
514
  console.log('Page:', data.page)
452
515
  }
453
516
  })
517
+
518
+ // Option 2: Direct signal access (for custom reactivity)
519
+ effect(() => {
520
+ const { data, error, isFetching } = state.value
521
+ // same as above
522
+ })
454
523
  ```
455
524
 
456
525
  **Parameters:**
@@ -465,24 +534,47 @@ effect(() => {
465
534
 
466
535
  ## Abort Signals
467
536
 
468
- All SDK methods accept an optional `signal` for external cancellation. This works alongside the SDK's internal abort logic (e.g., a new search call automatically cancels the previous one).
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).
469
543
 
470
544
  ```typescript
471
- const controller = new AbortController()
545
+ // Shared signal at init — cancels everything when component unmounts
546
+ const componentController = new AbortController()
547
+
548
+ const search = sdk.search({
549
+ query: 'ring',
550
+ signal: componentController.signal, // shared: affects all operations
551
+ })
472
552
 
473
- // Cancel from outside
474
- await search.execute({ query: 'ring', signal: controller.signal })
475
- controller.abort() // cancels the request
553
+ const autocomplete = sdk.autocomplete({
554
+ signal: componentController.signal, // shared: acts like dispose() when aborted
555
+ })
476
556
 
477
- // With autocompletesignal acts like dispose()
478
- const autocomplete = sdk.autocomplete({ signal: controller.signal })
479
- controller.abort() // cancels debounce + pending request
557
+ // Per-call signalcancels 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
480
561
 
481
- // With collection
482
- await collection.execute({ page: 2, signal: controller.signal })
562
+ // Shared signal abort cancels everything
563
+ componentController.abort() // cancels all pending requests + acts like dispose()
483
564
  ```
484
565
 
485
- For controllers (autocomplete, search, collection), calling a new query still auto-cancels the previous request. The external signal adds an additional cancellation path — useful for component unmount or navigation.
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:**
576
+
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.
486
578
 
487
579
  ## Response Types
488
580
 
@@ -502,6 +594,7 @@ interface CollectionResult {
502
594
  resultsPerPage?: number
503
595
  facets: Record<string, Record<string, number>>
504
596
  facetRanges?: Record<string, { min: number; max: number }>
597
+ priceRange?: PriceRange // Formatted min/max prices from result set
505
598
  attributionToken: string
506
599
  collection?: StorefrontCollection
507
600
  }
@@ -514,6 +607,7 @@ interface SearchResult {
514
607
  resultsPerPage?: number
515
608
  facets: Record<string, Record<string, number>>
516
609
  facetRanges?: Record<string, { min: number; max: number }>
610
+ priceRange?: PriceRange // Formatted min/max prices from result set
517
611
  attributionToken: string
518
612
  }
519
613
 
@@ -525,10 +619,22 @@ interface BlocksResult {
525
619
  resultsPerPage?: number
526
620
  facets: Record<string, Record<string, number>>
527
621
  facetRanges?: Record<string, { min: number; max: number }>
622
+ priceRange?: PriceRange // Formatted min/max prices from result set
528
623
  attributionToken: string
529
624
  block?: BlocksInfo // { id, title, anchor_type, strategy_type, strategy_key }
530
625
  }
531
626
 
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
+
532
638
  interface StorefrontResult {
533
639
  products: Product[]
534
640
  collection?: StorefrontCollection
@@ -716,7 +822,7 @@ card.getSwatches('Color') // Swatch definitions
716
822
  card.isOptionAvailable('Size', 'L') // Check if selecting 'L' results in available variant
717
823
  card.getVariantByOptions([{ name: 'Size', value: 'L' }])
718
824
 
719
- // Cleanup
825
+ // Unsubscribe
720
826
  card.dispose()
721
827
  ```
722
828
 
@@ -880,7 +986,7 @@ import { browseKey, searchKey, similarKey, blocksKey, productsKey } from '@comme
880
986
  browseKey('shirts', { page: 1, limit: 24 }) // '/browse/shirts?limit=24&page=1'
881
987
  searchKey('red dress', { page: 2 }) // '/search/red%20dress?page=2'
882
988
  similarKey(123, { limit: 10 }) // '/similar/123?limit=10'
883
- blocksKey('block-abc', { anchorHandle: 'gold-necklace', page: 1 })
989
+ blocksKey('block-abc', { anchorId: 'gold-necklace', page: 1 })
884
990
  productsKey(['gid://shopify/Product/1', 'gid://shopify/Product/2'])
885
991
  ```
886
992