@usethrottle/catalog 0.1.0 → 1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @usethrottle/catalog
2
2
 
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 9508bce: 1.0.0: API freeze. No change from 0.1.x beyond the patches listed; from 1.0.0, breaking changes come only in a major version.
8
+
9
+ ### Patch Changes
10
+
11
+ - 22949b4: `useCart().switchCurrency` lands whole or not at all: the new cart is read back and saved before it replaces the old one, so when that read or save fails the call rejects and the old cart, its stored id and the old currency stay. Before, a failed read left the new cart's id stored and its currency kept while the call rejected.
12
+
13
+ ## 0.1.1
14
+
15
+ ### Patch Changes
16
+
17
+ - de45b49: Hooks revalidate `initialData` at once under a signed-in customer, so an anonymous SSR answer never shows list prices for a minute
18
+
3
19
  ## 0.1.0
4
20
 
5
21
  ### Minor Changes
package/README.md CHANGED
@@ -130,7 +130,7 @@ import {
130
130
  type CartStorage,
131
131
  } from '@usethrottle/catalog/react';
132
132
 
133
- // One client for the life of the app.
133
+ // One client per currency and language (see Versioning), kept for the life of the app.
134
134
  const client = createCatalogClient({
135
135
  publishableKey: process.env.NEXT_PUBLIC_CATALOG_KEY ?? '',
136
136
  customerToken: () => auth.getAccessToken(),
@@ -185,10 +185,10 @@ function ProductGrid() {
185
185
  }
186
186
  ```
187
187
 
188
- - **Hooks:** `useProducts`, `useProduct`, `useRelated`, `useFilterValues`, `useCategories`, `useCollection`, `useSuggest` (debounced 200 ms, 2 characters at least), `useReviews` and `useConfig`, each answering `{ data, error, isLoading, isValidating, refetch }` and taking `enabled`, `initialData` (for an SSR hand-off) and `staleTimeMs`.
188
+ - **Hooks:** `useProducts`, `useProduct`, `useRelated`, `useFilterValues`, `useCategories`, `useCollection`, `useSuggest` (debounced 200 ms, 2 characters at least), `useReviews` and `useConfig`, each answering `{ data, error, isLoading, isValidating, refetch }` and taking `enabled`, `initialData` (for an SSR hand-off) and `staleTimeMs`. With a signed-in customer (`customerKey` set), `initialData` is shown and revalidated at once, since a server render with the server key is anonymous; to avoid the brief list-price paint, render the server side with the customer's token.
189
189
  - **The cache** is per provider: answers are shown at once and refetched when older than 60 seconds, and calls for one key share one request. Two providers never share it.
190
- - **`customerKey`** partitions the cache by customer. Change it in the same update as the token: a change starts an empty cache and every mounted hook refetches, so one customer never sees another's prices. It is required whenever `customerToken` can return a token: `useCart` drops the cart on a sign-out or a customer change only by watching it, so without it one customer's cart stays for the next shopper. Keep one `client` for the app's life and pass `customerToken` as a function.
191
- - **`useCart`** keeps one cart per provider and runs its changes one at a time. It creates the cart on the first `add`, recreates it once if Throttle ended the session, and makes a new cart on `switchCurrency`. The cart is dropped when the signed-in customer signs out or changes, and kept on sign-in. `checkout()` uses the provider's `cartUrls` unless given URLs, and answers the checkout URL. `cartUrls` go on every cart create, so they must be absolute `https` URLs on the store's Allowed return websites, or every `add` fails; otherwise (on `http://localhost`, say) leave them out and pass the URLs to `checkout(input)`. The default `cartStorage` is memory only; key a persistent one by customer, as above: a new adapter object per customer, never one stable object that reads the current customer when called (a drop removes the outgoing customer's entry through the outgoing object, so such an adapter would delete the incoming customer's cart instead).
190
+ - **`customerKey`** partitions the cache by customer. Change it in the same update as the token: a change starts an empty cache and every mounted hook refetches, so one customer never sees another's prices. It is required whenever `customerToken` can return a token: `useCart` drops the cart on a sign-out or a customer change only by watching it, so without it one customer's cart stays for the next shopper. Keep one `client` per currency and language (see [One client per currency and language](#one-client-per-currency-and-language)) and pass `customerToken` as a function.
191
+ - **`useCart`** keeps one cart per provider and runs its changes one at a time. It creates the cart on the first `add`, recreates it once if Throttle ended the session, and makes a new cart on `switchCurrency`. A switch lands whole or not at all: if the new cart cannot be made, read back or saved, `switchCurrency` rejects and the old cart, its stored id and the old currency stay. The cart is dropped when the signed-in customer signs out or changes, and kept on sign-in. `checkout()` uses the provider's `cartUrls` unless given URLs, and answers the checkout URL. `cartUrls` go on every cart create, so they must be absolute `https` URLs on the store's Allowed return websites, or every `add` fails; otherwise (on `http://localhost`, say) leave them out and pass the URLs to `checkout(input)`. The default `cartStorage` is memory only; key a persistent one by customer, as above: a new adapter object per customer, never one stable object that reads the current customer when called (a drop removes the outgoing customer's entry through the outgoing object, so such an adapter would delete the incoming customer's cart instead).
192
192
  - **Rate limits:** a catalog `429` pauses every read of the provider until its retry time; each mounted key then refetches once.
193
193
  - **`useSubmitReview(handle)`** submits a review with one idempotency key per submission.
194
194
 
@@ -260,10 +260,84 @@ The package ships ESM and CommonJS builds. Within one format, every entry shares
260
260
 
261
261
  ## Versioning
262
262
 
263
- The package stays at `0.x` until the reference store ships with `1.0.0`. While it is `0.x`, a breaking type change is a minor release, named in the changelog. SDK 0.1.0 targets the current catalog. Against an older one, everything works except the route-miss marker behind `FeatureUnavailableError`, the OpenAPI document and the cart return URLs (an older catalog refuses them with `400 INVALID_REQUEST`).
263
+ From 1.0.0 the package follows semantic versioning: a breaking change to an exported name, a type, an error class or code, or a hook's behaviour comes only in a major version. New answer keys, new error codes in `(string & {})` positions and new optional options are minor. Deprecated names keep working for at least one major. The reference store at https://throttle-demo-store.vercel.app runs this version.
264
+
265
+ The SDK targets the current catalog. Against an older one, everything works except the route-miss marker behind `FeatureUnavailableError`, the OpenAPI document and the cart return URLs (an older catalog refuses them with `400 INVALID_REQUEST`).
264
266
 
265
267
  The OpenAPI 3.1 document of the storefront API is at https://catalog.usethrottle.dev/v1/storefront/openapi.json.
266
268
 
269
+ ### One client per currency and language
270
+
271
+ Hooks take no per-call currency or language: both come from the client. A store with a currency or language switcher keeps one client per `(currency, locale)`, memoised on both, so the client changes only when one of them does. On a currency switch, run `useCart().switchCurrency()` first and swap the client only once it resolves. The switch lands whole or not at all: when it rejects, the cart is still in the old currency, so keep the old client.
272
+
273
+ ```tsx
274
+ 'use client';
275
+ import { useMemo, useState, type ReactNode } from 'react';
276
+ import { createCatalogClient } from '@usethrottle/catalog';
277
+ import { CatalogProvider, useCart } from '@usethrottle/catalog/react';
278
+
279
+ export function Shop({ children }: { children: ReactNode }) {
280
+ const [prefs, setPrefs] = useState({ currency: 'USD', locale: 'en' });
281
+ const client = useMemo(
282
+ () =>
283
+ createCatalogClient({
284
+ publishableKey: process.env.NEXT_PUBLIC_CATALOG_KEY ?? '',
285
+ currency: prefs.currency,
286
+ locale: prefs.locale,
287
+ customerToken: () => auth.getAccessToken(),
288
+ }),
289
+ [prefs.currency, prefs.locale],
290
+ );
291
+ // Pass customerKey, cartStorage and cartUrls as in the React example.
292
+ return (
293
+ <CatalogProvider client={client}>
294
+ <CurrencyPicker
295
+ currency={prefs.currency}
296
+ onSwitched={(currency) => setPrefs((p) => ({ ...p, currency }))}
297
+ />
298
+ {children}
299
+ </CatalogProvider>
300
+ );
301
+ }
302
+
303
+ function CurrencyPicker(props: {
304
+ currency: string;
305
+ onSwitched: (currency: string) => void;
306
+ }) {
307
+ const cart = useCart();
308
+ const choose = async (currency: string) => {
309
+ await cart.switchCurrency(currency); // rejects and keeps the old cart on failure
310
+ props.onSwitched(currency); // only now swap the client
311
+ };
312
+ return (
313
+ <select
314
+ value={props.currency}
315
+ disabled={cart.pending}
316
+ onChange={(e) => void choose(e.target.value).catch(() => undefined)}
317
+ >
318
+ <option>USD</option>
319
+ <option>EUR</option>
320
+ </select>
321
+ );
322
+ }
323
+ ```
324
+
325
+ A language change needs no cart step: swap the client.
326
+
327
+ ### 1.x follow-ups
328
+
329
+ 1.0.0 freezes the surface the reference store proved; these are additive and planned for 1.x minors. Until then the reference store's code shows the workaround.
330
+
331
+ - `useTags` and `useCollections` hooks. Today: `client.tags.list()` and `client.collections.list()`, or the server client.
332
+ - A `useReviewEligibility(handle)` hook and a `ReviewEligibility` type export. Today: `useCatalog().reviews.eligibility(handle, { signal })`, typed `ReviewEligibilityAnswer`.
333
+ - A keep-previous-data option, so a hook shows its last answer while a new key loads.
334
+ - `useSuggest` names the query its `data` answers.
335
+ - A way to read the provider's `customerKey` from inside it.
336
+ - A retry or an error surface when a signed-in customer's revalidation of `initialData` fails. Today the anonymous answer (list prices) stays until the next navigation.
337
+ - A signed-in customer's open cart coming back after a sign-out and a sign-in with no guest cart. This needs an authenticated open-cart read for the customer in Throttle too.
338
+ - A first `add` whose confirming read fails rejects while the line is already in the new cart; that case will settle one consistent way.
339
+ - Fewer CORS preflights for suggestions: every query is a new URL carrying the key header, so the browser preflights each keystroke.
340
+
267
341
  ## License
268
342
 
269
343
  MIT
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-PKBMQBKP.js";
4
4
 
5
5
  // src/version.ts
6
- var SDK_VERSION = true ? "0.1.0" : "0.0.0-dev";
6
+ var SDK_VERSION = true ? "1.0.0" : "0.0.0-dev";
7
7
 
8
8
  // src/errors/catalog-error.ts
9
9
  import { ThrottleError } from "@usethrottle/errors";
@@ -3,7 +3,7 @@
3
3
  var _chunkETV4XYOVcjs = require('./chunk-ETV4XYOV.cjs');
4
4
 
5
5
  // src/version.ts
6
- var SDK_VERSION = true ? "0.1.0" : "0.0.0-dev";
6
+ var SDK_VERSION = true ? "1.0.0" : "0.0.0-dev";
7
7
 
8
8
  // src/errors/catalog-error.ts
9
9
  var _errors = require('@usethrottle/errors');
package/dist/index.cjs CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
 
12
12
 
13
- var _chunkO2OYOK2Acjs = require('./chunk-O2OYOK2A.cjs');
13
+ var _chunkZFUELHUUcjs = require('./chunk-ZFUELHUU.cjs');
14
14
  require('./chunk-ETV4XYOV.cjs');
15
15
 
16
16
 
@@ -24,4 +24,4 @@ require('./chunk-ETV4XYOV.cjs');
24
24
 
25
25
 
26
26
 
27
- exports.CartLineError = _chunkO2OYOK2Acjs.CartLineError; exports.CatalogError = _chunkO2OYOK2Acjs.CatalogError; exports.CustomerTokenInvalidError = _chunkO2OYOK2Acjs.CustomerTokenInvalidError; exports.FeatureUnavailableError = _chunkO2OYOK2Acjs.FeatureUnavailableError; exports.NetworkError = _chunkO2OYOK2Acjs.NetworkError; exports.OriginNotAllowedError = _chunkO2OYOK2Acjs.OriginNotAllowedError; exports.RateLimitedError = _chunkO2OYOK2Acjs.RateLimitedError; exports.SDK_VERSION = _chunkO2OYOK2Acjs.SDK_VERSION; exports.TimeoutError = _chunkO2OYOK2Acjs.TimeoutError; exports.createCatalogClient = _chunkO2OYOK2Acjs.createCatalogClient; exports.isCatalogError = _chunkO2OYOK2Acjs.isCatalogError;
27
+ exports.CartLineError = _chunkZFUELHUUcjs.CartLineError; exports.CatalogError = _chunkZFUELHUUcjs.CatalogError; exports.CustomerTokenInvalidError = _chunkZFUELHUUcjs.CustomerTokenInvalidError; exports.FeatureUnavailableError = _chunkZFUELHUUcjs.FeatureUnavailableError; exports.NetworkError = _chunkZFUELHUUcjs.NetworkError; exports.OriginNotAllowedError = _chunkZFUELHUUcjs.OriginNotAllowedError; exports.RateLimitedError = _chunkZFUELHUUcjs.RateLimitedError; exports.SDK_VERSION = _chunkZFUELHUUcjs.SDK_VERSION; exports.TimeoutError = _chunkZFUELHUUcjs.TimeoutError; exports.createCatalogClient = _chunkZFUELHUUcjs.createCatalogClient; exports.isCatalogError = _chunkZFUELHUUcjs.isCatalogError;
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  TimeoutError,
11
11
  createCatalogClient,
12
12
  isCatalogError
13
- } from "./chunk-PSCFHVZZ.js";
13
+ } from "./chunk-2PJXGAZF.js";
14
14
  import "./chunk-PKBMQBKP.js";
15
15
  export {
16
16
  CartLineError,
package/dist/react.cjs CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
 
13
13
 
14
- var _chunkO2OYOK2Acjs = require('./chunk-O2OYOK2A.cjs');
14
+ var _chunkZFUELHUUcjs = require('./chunk-ZFUELHUU.cjs');
15
15
  require('./chunk-ETV4XYOV.cjs');
16
16
 
17
17
  // src/react/provider.tsx
@@ -38,7 +38,7 @@ function createStore(client, options = {}) {
38
38
  let until = 0;
39
39
  let cause;
40
40
  let pauseTimer;
41
- const pausedError = () => new (0, _chunkO2OYOK2Acjs.RateLimitedError)({
41
+ const pausedError = () => new (0, _chunkZFUELHUUcjs.RateLimitedError)({
42
42
  code: _nullishCoalesce(_optionalChain([cause, 'optionalAccess', _ => _.code]), () => ( "RATE_LIMITED")),
43
43
  message: _nullishCoalesce(_optionalChain([cause, 'optionalAccess', _2 => _2.message]), () => ( "Too many requests.")),
44
44
  statusCode: 429,
@@ -49,17 +49,17 @@ function createStore(client, options = {}) {
49
49
  entry.snap = { ...entry.snap, ...patch };
50
50
  for (const listener of [...entry.subs]) listener();
51
51
  };
52
- const touch = (key, initial) => {
52
+ const touch = (key, initial, stale = false) => {
53
53
  let entry = entries.get(key);
54
54
  if (entry !== void 0) {
55
55
  entries.delete(key);
56
56
  } else {
57
57
  entry = {
58
- snap: initial === void 0 ? EMPTY : { ...EMPTY, data: initial, fetchedAt: now() },
58
+ snap: initial === void 0 ? EMPTY : { ...EMPTY, data: initial, fetchedAt: stale ? void 0 : now() },
59
59
  subs: /* @__PURE__ */ new Set()
60
60
  };
61
61
  for (const [oldKey, old] of entries) {
62
- if (entries.size < _chunkO2OYOK2Acjs.CACHE_MAX_ENTRIES) break;
62
+ if (entries.size < _chunkZFUELHUUcjs.CACHE_MAX_ENTRIES) break;
63
63
  if (old.subs.size === 0 && old.flight === void 0) {
64
64
  clearTimeout(old.timer);
65
65
  entries.delete(oldKey);
@@ -78,7 +78,7 @@ function createStore(client, options = {}) {
78
78
  else entry.indexing = false;
79
79
  }, ms);
80
80
  };
81
- function fetchKey(key, run, { force = false, staleMs = _chunkO2OYOK2Acjs.STALE_MS } = {}) {
81
+ function fetchKey(key, run, { force = false, staleMs = _chunkZFUELHUUcjs.STALE_MS } = {}) {
82
82
  const entry = touch(key);
83
83
  entry.run = run;
84
84
  if (entry.promise !== void 0 && !_optionalChain([entry, 'access', _4 => _4.flight, 'optionalAccess', _5 => _5.signal, 'access', _6 => _6.aborted]))
@@ -128,7 +128,7 @@ function createStore(client, options = {}) {
128
128
  return;
129
129
  }
130
130
  if (_optionalChain([result, 'access', _7 => _7.data, 'optionalAccess', _8 => _8.indexing]) === true) {
131
- if (!entry.indexing) later(key, entry, _chunkO2OYOK2Acjs.INDEXING_REVALIDATE_MS);
131
+ if (!entry.indexing) later(key, entry, _chunkZFUELHUUcjs.INDEXING_REVALIDATE_MS);
132
132
  } else {
133
133
  entry.indexing = false;
134
134
  }
@@ -141,7 +141,7 @@ function createStore(client, options = {}) {
141
141
  },
142
142
  (error) => {
143
143
  if (!current()) return;
144
- const retryMs = (_nullishCoalesce(error.retryAfterSeconds, () => ( _chunkO2OYOK2Acjs.RATE_LIMIT_DEFAULT_RETRY_S))) * 1e3;
144
+ const retryMs = (_nullishCoalesce(error.retryAfterSeconds, () => ( _chunkZFUELHUUcjs.RATE_LIMIT_DEFAULT_RETRY_S))) * 1e3;
145
145
  if (error.statusCode === 429 && error.code === "RATE_LIMITED")
146
146
  pause(now() + retryMs, error);
147
147
  else if (error.code === "STOREFRONT_INDEXING" && !entry.indexing)
@@ -189,10 +189,10 @@ function createStore(client, options = {}) {
189
189
  });
190
190
  };
191
191
  },
192
- get: (key, initial) => key === null ? EMPTY : _nullishCoalesce(_optionalChain([(_nullishCoalesce(entries.get(key), () => ( (initial === void 0 ? void 0 : touch(key, initial))))), 'optionalAccess', _15 => _15.snap]), () => ( EMPTY)),
193
- getServer(key, initial) {
192
+ get: (key, initial, stale) => key === null ? EMPTY : _nullishCoalesce(_optionalChain([(_nullishCoalesce(entries.get(key), () => ( (initial === void 0 ? void 0 : touch(key, initial, stale))))), 'optionalAccess', _15 => _15.snap]), () => ( EMPTY)),
193
+ getServer(key, initial, stale) {
194
194
  if (key === null || initial === void 0) return EMPTY;
195
- const entry = _nullishCoalesce(entries.get(key), () => ( touch(key, initial)));
195
+ const entry = _nullishCoalesce(entries.get(key), () => ( touch(key, initial, stale)));
196
196
  return _nullishCoalesce(entry.server, () => ( (entry.server = { ...EMPTY, data: initial })));
197
197
  },
198
198
  fetch: fetchKey,
@@ -307,10 +307,11 @@ function useQuery(route, params, run, options = {}) {
307
307
  (listener) => key === null ? unsubscribe() : store.subscribe(key, listener),
308
308
  [store, key]
309
309
  );
310
+ const stale = customerKey !== null;
310
311
  const snap = _react.useSyncExternalStore.call(void 0,
311
312
  subscribe,
312
- () => store.get(key, initial),
313
- () => store.getServer(key, initial)
313
+ () => store.get(key, initial, stale),
314
+ () => store.getServer(key, initial, stale)
314
315
  );
315
316
  const staleMs = options.staleTimeMs;
316
317
  _react.useEffect.call(void 0, () => {
@@ -388,9 +389,9 @@ function useDebounced(q, ms) {
388
389
  return _react.useSyncExternalStore.call(void 0, box.subscribe, box.get, box.get);
389
390
  }
390
391
  function useSuggest(q, options = {}) {
391
- const settled = useDebounced(q, _nullishCoalesce(options.debounceMs, () => ( _chunkO2OYOK2Acjs.SUGGEST_DEBOUNCE_MS)));
392
+ const settled = useDebounced(q, _nullishCoalesce(options.debounceMs, () => ( _chunkZFUELHUUcjs.SUGGEST_DEBOUNCE_MS)));
392
393
  const extra = defined({ limit: options.limit });
393
- const long = (value) => value.trim().length >= _chunkO2OYOK2Acjs.SUGGEST_MIN_CHARS;
394
+ const long = (value) => value.trim().length >= _chunkZFUELHUUcjs.SUGGEST_MIN_CHARS;
394
395
  return useQuery(
395
396
  "suggest",
396
397
  { q: settled, ...extra },
@@ -460,7 +461,7 @@ function newKey() {
460
461
  return out;
461
462
  }
462
463
  function refuse(message, path) {
463
- return new (0, _chunkO2OYOK2Acjs.CatalogError)({
464
+ return new (0, _chunkZFUELHUUcjs.CatalogError)({
464
465
  code: "INVALID_REQUEST",
465
466
  message,
466
467
  statusCode: 0,
@@ -473,7 +474,7 @@ var UNSAFE = /[\x00-\x20\x7f]/u;
473
474
  function isHttpUrl(value) {
474
475
  return typeof value === "string" && /^https?:\/\/[^/?#\\]/iu.test(value) && !UNSAFE.test(value);
475
476
  }
476
- var wrap = (error) => _chunkO2OYOK2Acjs.isCatalogError.call(void 0, error) ? error : new (0, _chunkO2OYOK2Acjs.CatalogError)({
477
+ var wrap = (error) => _chunkZFUELHUUcjs.isCatalogError.call(void 0, error) ? error : new (0, _chunkZFUELHUUcjs.CatalogError)({
477
478
  code: "CLIENT_ERROR",
478
479
  message: "The action failed.",
479
480
  statusCode: 0,
@@ -484,7 +485,7 @@ var wrap = (error) => _chunkO2OYOK2Acjs.isCatalogError.call(void 0, error) ? err
484
485
  function checkPause(store) {
485
486
  const until = store.pausedUntil();
486
487
  if (until > 0) {
487
- throw new (0, _chunkO2OYOK2Acjs.RateLimitedError)({
488
+ throw new (0, _chunkZFUELHUUcjs.RateLimitedError)({
488
489
  code: "RATE_LIMITED",
489
490
  message: "Too many requests.",
490
491
  statusCode: 429,
@@ -494,7 +495,7 @@ function checkPause(store) {
494
495
  }
495
496
  }
496
497
  function notePause(store, error) {
497
- if (error instanceof _chunkO2OYOK2Acjs.RateLimitedError && error.source === "catalog" && error.code === "RATE_LIMITED")
498
+ if (error instanceof _chunkZFUELHUUcjs.RateLimitedError && error.source === "catalog" && error.code === "RATE_LIMITED")
498
499
  store.pause(Date.now() + error.retryAfterSeconds * 1e3, error);
499
500
  }
500
501
  function createBox(initial) {
@@ -528,7 +529,7 @@ var ENDED = /* @__PURE__ */ new Set([
528
529
  "cart_not_open",
529
530
  "cart_not_found"
530
531
  ]);
531
- var ended = (error) => _chunkO2OYOK2Acjs.isCatalogError.call(void 0, error) && ENDED.has(error.code);
532
+ var ended = (error) => _chunkZFUELHUUcjs.isCatalogError.call(void 0, error) && ENDED.has(error.code);
532
533
  var converted = (error) => _optionalChain([error, 'access', _23 => _23.details, 'optionalAccess', _24 => _24.reason]) === "converted";
533
534
  function itemsOf(cart) {
534
535
  const items = /* @__PURE__ */ new Map();
@@ -567,7 +568,7 @@ function createController(home) {
567
568
  throttleBaseUrl === void 0 ? void 0 : { throttleBaseUrl }
568
569
  );
569
570
  };
570
- const fallbackUrl = (id) => `${(_nullishCoalesce(ctx().throttleCheckoutUrl, () => ( _chunkO2OYOK2Acjs.THROTTLE_CHECKOUT_URL))).replace(/\/+$/u, "")}/cart/${encodeURIComponent(id)}`;
571
+ const fallbackUrl = (id) => `${(_nullishCoalesce(ctx().throttleCheckoutUrl, () => ( _chunkZFUELHUUcjs.THROTTLE_CHECKOUT_URL))).replace(/\/+$/u, "")}/cart/${encodeURIComponent(id)}`;
571
572
  const live = async (signal, value) => {
572
573
  const out = await value;
573
574
  if (signal.aborted) throw signal.reason;
@@ -578,7 +579,7 @@ function createController(home) {
578
579
  if (converted(error)) reset();
579
580
  else box.set({ sessionId: null, cartUrl: null });
580
581
  }
581
- async function create(signal, options, items) {
582
+ async function create(signal, options, items, whole = false) {
582
583
  const context = ctx();
583
584
  const body = {
584
585
  items: [...items].map(([sku, quantity]) => ({ sku, quantity }))
@@ -602,10 +603,22 @@ function createController(home) {
602
603
  throw error;
603
604
  }
604
605
  const id = answer.sessionId;
605
- box.set({
606
- sessionId: id,
607
- cartUrl: isHttpUrl(answer.cartUrl) ? answer.cartUrl : fallbackUrl(id)
608
- });
606
+ const cartUrl = isHttpUrl(answer.cartUrl) ? answer.cartUrl : fallbackUrl(id);
607
+ if (whole) {
608
+ const snapshot = await live(signal, session(id).get({ signal }));
609
+ const write = context.cartStorage.set(id);
610
+ me.writing = Promise.resolve(write).catch(() => void 0);
611
+ await live(signal, write);
612
+ box.set({
613
+ sessionId: id,
614
+ cartUrl,
615
+ cart: snapshot.cart,
616
+ status: "ready",
617
+ error: void 0
618
+ });
619
+ return;
620
+ }
621
+ box.set({ sessionId: id, cartUrl });
609
622
  let unsaved;
610
623
  try {
611
624
  const write = context.cartStorage.set(id);
@@ -799,11 +812,10 @@ function createController(home) {
799
812
  reset();
800
813
  return;
801
814
  }
802
- const prior = box.get().sessionId;
803
815
  try {
804
- await create(signal, options, items);
816
+ await create(signal, options, items, true);
805
817
  } catch (error) {
806
- if (!signal.aborted && box.get().sessionId === prior) currency = before;
818
+ if (!signal.aborted) currency = before;
807
819
  throw error;
808
820
  }
809
821
  },
@@ -885,7 +897,7 @@ function useCart(options = {}) {
885
897
  { signal: callSignal }
886
898
  );
887
899
  } catch (error) {
888
- const repriced = _chunkO2OYOK2Acjs.isCatalogError.call(void 0, error) && error.code === "cart_prices_changed" ? _optionalChain([error, 'access', _37 => _37.details, 'optionalAccess', _38 => _38.cart]) : void 0;
900
+ const repriced = _chunkZFUELHUUcjs.isCatalogError.call(void 0, error) && error.code === "cart_prices_changed" ? _optionalChain([error, 'access', _37 => _37.details, 'optionalAccess', _38 => _38.cart]) : void 0;
889
901
  if (!callSignal.aborted && typeof repriced === "object" && repriced !== null)
890
902
  controller.box.set({ cart: repriced });
891
903
  throw error;
@@ -905,7 +917,7 @@ function useCart(options = {}) {
905
917
  if (out === void 0)
906
918
  throw _nullishCoalesce(met, () => ( refuse("There is no cart to check out.")));
907
919
  if (!isHttpUrl(out.checkoutUrl))
908
- throw new (0, _chunkO2OYOK2Acjs.CatalogError)({
920
+ throw new (0, _chunkZFUELHUUcjs.CatalogError)({
909
921
  code: "INVALID_RESPONSE",
910
922
  message: "Throttle answered a checkout URL that is not http or https.",
911
923
  statusCode: 200,
@@ -1003,4 +1015,4 @@ function useSubmitReview(handle) {
1003
1015
 
1004
1016
 
1005
1017
 
1006
- exports.CatalogProvider = CatalogProvider; exports.SDK_VERSION = _chunkO2OYOK2Acjs.SDK_VERSION; exports.catalogQueryKey = catalogQueryKey; exports.createMemoryCartStorage = createMemoryCartStorage; exports.useCart = useCart; exports.useCatalog = useCatalog; exports.useCategories = useCategories; exports.useCollection = useCollection; exports.useConfig = useConfig; exports.useFilterValues = useFilterValues; exports.useProduct = useProduct; exports.useProducts = useProducts; exports.useRelated = useRelated; exports.useReviews = useReviews; exports.useSubmitReview = useSubmitReview; exports.useSuggest = useSuggest;
1018
+ exports.CatalogProvider = CatalogProvider; exports.SDK_VERSION = _chunkZFUELHUUcjs.SDK_VERSION; exports.catalogQueryKey = catalogQueryKey; exports.createMemoryCartStorage = createMemoryCartStorage; exports.useCart = useCart; exports.useCatalog = useCatalog; exports.useCategories = useCategories; exports.useCollection = useCollection; exports.useConfig = useConfig; exports.useFilterValues = useFilterValues; exports.useProduct = useProduct; exports.useProducts = useProducts; exports.useRelated = useRelated; exports.useReviews = useReviews; exports.useSubmitReview = useSubmitReview; exports.useSuggest = useSuggest;
package/dist/react.d.cts CHANGED
@@ -57,7 +57,11 @@ type QueryResult<T> = {
57
57
  type QueryOptions<T> = {
58
58
  /** `false` makes no request; `data` stays `undefined`. */
59
59
  enabled?: boolean;
60
- /** Shown at once and treated as fetched at mount (SSR hand-off). */
60
+ /**
61
+ * Shown at once (SSR hand-off). Treated as fetched at mount when the
62
+ * provider has no `customerKey`; under a signed-in customer it is
63
+ * revalidated at once, since a server render is usually anonymous (P6-R51).
64
+ */
61
65
  initialData?: T;
62
66
  /** Cached data younger than this is not fetched again; default `STALE_MS`. */
63
67
  staleTimeMs?: number;
@@ -132,7 +136,12 @@ type UseCartResult = CartState & {
132
136
  }): Promise<{
133
137
  checkoutUrl: string;
134
138
  }>;
135
- /** A new cart with the current lines in `currency` (BC4); the old one is left to Throttle. */
139
+ /**
140
+ * A new cart with the current lines in `currency` (BC4); the old one is
141
+ * left to Throttle. It lands whole or not at all: if the new cart cannot be
142
+ * made, read back or saved, it rejects and the old cart, its stored id and
143
+ * the old currency stay (the unused new cart is left to Throttle).
144
+ */
136
145
  switchCurrency(currency: string): Promise<void>;
137
146
  /** Forgets the cart (no request). */
138
147
  clear(): Promise<void>;
package/dist/react.d.ts CHANGED
@@ -57,7 +57,11 @@ type QueryResult<T> = {
57
57
  type QueryOptions<T> = {
58
58
  /** `false` makes no request; `data` stays `undefined`. */
59
59
  enabled?: boolean;
60
- /** Shown at once and treated as fetched at mount (SSR hand-off). */
60
+ /**
61
+ * Shown at once (SSR hand-off). Treated as fetched at mount when the
62
+ * provider has no `customerKey`; under a signed-in customer it is
63
+ * revalidated at once, since a server render is usually anonymous (P6-R51).
64
+ */
61
65
  initialData?: T;
62
66
  /** Cached data younger than this is not fetched again; default `STALE_MS`. */
63
67
  staleTimeMs?: number;
@@ -132,7 +136,12 @@ type UseCartResult = CartState & {
132
136
  }): Promise<{
133
137
  checkoutUrl: string;
134
138
  }>;
135
- /** A new cart with the current lines in `currency` (BC4); the old one is left to Throttle. */
139
+ /**
140
+ * A new cart with the current lines in `currency` (BC4); the old one is
141
+ * left to Throttle. It lands whole or not at all: if the new cart cannot be
142
+ * made, read back or saved, it rejects and the old cart, its stored id and
143
+ * the old currency stay (the unused new cart is left to Throttle).
144
+ */
136
145
  switchCurrency(currency: string): Promise<void>;
137
146
  /** Forgets the cart (no request). */
138
147
  clear(): Promise<void>;
package/dist/react.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  SUGGEST_MIN_CHARS,
12
12
  THROTTLE_CHECKOUT_URL,
13
13
  isCatalogError
14
- } from "./chunk-PSCFHVZZ.js";
14
+ } from "./chunk-2PJXGAZF.js";
15
15
  import "./chunk-PKBMQBKP.js";
16
16
 
17
17
  // src/react/provider.tsx
@@ -49,13 +49,13 @@ function createStore(client, options = {}) {
49
49
  entry.snap = { ...entry.snap, ...patch };
50
50
  for (const listener of [...entry.subs]) listener();
51
51
  };
52
- const touch = (key, initial) => {
52
+ const touch = (key, initial, stale = false) => {
53
53
  let entry = entries.get(key);
54
54
  if (entry !== void 0) {
55
55
  entries.delete(key);
56
56
  } else {
57
57
  entry = {
58
- snap: initial === void 0 ? EMPTY : { ...EMPTY, data: initial, fetchedAt: now() },
58
+ snap: initial === void 0 ? EMPTY : { ...EMPTY, data: initial, fetchedAt: stale ? void 0 : now() },
59
59
  subs: /* @__PURE__ */ new Set()
60
60
  };
61
61
  for (const [oldKey, old] of entries) {
@@ -189,10 +189,10 @@ function createStore(client, options = {}) {
189
189
  });
190
190
  };
191
191
  },
192
- get: (key, initial) => key === null ? EMPTY : (entries.get(key) ?? (initial === void 0 ? void 0 : touch(key, initial)))?.snap ?? EMPTY,
193
- getServer(key, initial) {
192
+ get: (key, initial, stale) => key === null ? EMPTY : (entries.get(key) ?? (initial === void 0 ? void 0 : touch(key, initial, stale)))?.snap ?? EMPTY,
193
+ getServer(key, initial, stale) {
194
194
  if (key === null || initial === void 0) return EMPTY;
195
- const entry = entries.get(key) ?? touch(key, initial);
195
+ const entry = entries.get(key) ?? touch(key, initial, stale);
196
196
  return entry.server ?? (entry.server = { ...EMPTY, data: initial });
197
197
  },
198
198
  fetch: fetchKey,
@@ -307,10 +307,11 @@ function useQuery(route, params, run, options = {}) {
307
307
  (listener) => key === null ? unsubscribe() : store.subscribe(key, listener),
308
308
  [store, key]
309
309
  );
310
+ const stale = customerKey !== null;
310
311
  const snap = useSyncExternalStore(
311
312
  subscribe,
312
- () => store.get(key, initial),
313
- () => store.getServer(key, initial)
313
+ () => store.get(key, initial, stale),
314
+ () => store.getServer(key, initial, stale)
314
315
  );
315
316
  const staleMs = options.staleTimeMs;
316
317
  useEffect2(() => {
@@ -578,7 +579,7 @@ function createController(home) {
578
579
  if (converted(error)) reset();
579
580
  else box.set({ sessionId: null, cartUrl: null });
580
581
  }
581
- async function create(signal, options, items) {
582
+ async function create(signal, options, items, whole = false) {
582
583
  const context = ctx();
583
584
  const body = {
584
585
  items: [...items].map(([sku, quantity]) => ({ sku, quantity }))
@@ -602,10 +603,22 @@ function createController(home) {
602
603
  throw error;
603
604
  }
604
605
  const id = answer.sessionId;
605
- box.set({
606
- sessionId: id,
607
- cartUrl: isHttpUrl(answer.cartUrl) ? answer.cartUrl : fallbackUrl(id)
608
- });
606
+ const cartUrl = isHttpUrl(answer.cartUrl) ? answer.cartUrl : fallbackUrl(id);
607
+ if (whole) {
608
+ const snapshot = await live(signal, session(id).get({ signal }));
609
+ const write = context.cartStorage.set(id);
610
+ me.writing = Promise.resolve(write).catch(() => void 0);
611
+ await live(signal, write);
612
+ box.set({
613
+ sessionId: id,
614
+ cartUrl,
615
+ cart: snapshot.cart,
616
+ status: "ready",
617
+ error: void 0
618
+ });
619
+ return;
620
+ }
621
+ box.set({ sessionId: id, cartUrl });
609
622
  let unsaved;
610
623
  try {
611
624
  const write = context.cartStorage.set(id);
@@ -799,11 +812,10 @@ function createController(home) {
799
812
  reset();
800
813
  return;
801
814
  }
802
- const prior = box.get().sessionId;
803
815
  try {
804
- await create(signal, options, items);
816
+ await create(signal, options, items, true);
805
817
  } catch (error) {
806
- if (!signal.aborted && box.get().sessionId === prior) currency = before;
818
+ if (!signal.aborted) currency = before;
807
819
  throw error;
808
820
  }
809
821
  },
package/dist/server.cjs CHANGED
@@ -8,14 +8,14 @@
8
8
 
9
9
 
10
10
 
11
- var _chunkO2OYOK2Acjs = require('./chunk-O2OYOK2A.cjs');
11
+ var _chunkZFUELHUUcjs = require('./chunk-ZFUELHUU.cjs');
12
12
  require('./chunk-ETV4XYOV.cjs');
13
13
 
14
14
  // src/server/tags.ts
15
15
  function catalogTags(route, handle) {
16
- const tags = [_chunkO2OYOK2Acjs.CACHE_TAG, `${_chunkO2OYOK2Acjs.CACHE_TAG}:${route}`];
17
- if (handle !== void 0) tags.push(`${_chunkO2OYOK2Acjs.CACHE_TAG}:${route}:${handle}`);
18
- return tags.filter((tag) => tag.length <= _chunkO2OYOK2Acjs.TAG_MAX_CHARS);
16
+ const tags = [_chunkZFUELHUUcjs.CACHE_TAG, `${_chunkZFUELHUUcjs.CACHE_TAG}:${route}`];
17
+ if (handle !== void 0) tags.push(`${_chunkZFUELHUUcjs.CACHE_TAG}:${route}:${handle}`);
18
+ return tags.filter((tag) => tag.length <= _chunkZFUELHUUcjs.TAG_MAX_CHARS);
19
19
  }
20
20
  function decode(segment) {
21
21
  try {
@@ -72,9 +72,9 @@ function serverInit(url, init, storefront, revalidate, extraTags) {
72
72
  }
73
73
  function createServerCatalogClient(options) {
74
74
  const { next, ...rest } = options;
75
- const revalidate = _nullishCoalesce(_optionalChain([next, 'optionalAccess', _ => _.revalidate]), () => ( _chunkO2OYOK2Acjs.DEFAULT_REVALIDATE_S));
75
+ const revalidate = _nullishCoalesce(_optionalChain([next, 'optionalAccess', _ => _.revalidate]), () => ( _chunkZFUELHUUcjs.DEFAULT_REVALIDATE_S));
76
76
  const extraTags = _nullishCoalesce(_optionalChain([next, 'optionalAccess', _2 => _2.tags]), () => ( []));
77
- const storefront = `${(_nullishCoalesce(rest.baseUrl, () => ( _chunkO2OYOK2Acjs.DEFAULT_BASE_URL))).replace(/\/+$/u, "")}/v1/storefront/`;
77
+ const storefront = `${(_nullishCoalesce(rest.baseUrl, () => ( _chunkZFUELHUUcjs.DEFAULT_BASE_URL))).replace(/\/+$/u, "")}/v1/storefront/`;
78
78
  const base = rest.fetch;
79
79
  const serverFetch = (input, init = {}) => {
80
80
  const out = serverInit(
@@ -86,7 +86,7 @@ function createServerCatalogClient(options) {
86
86
  );
87
87
  return base !== void 0 ? base(input, out) : globalThis.fetch(input, out);
88
88
  };
89
- return _chunkO2OYOK2Acjs.createCatalogClient.call(void 0, {
89
+ return _chunkZFUELHUUcjs.createCatalogClient.call(void 0, {
90
90
  ...rest,
91
91
  fetch: serverFetch
92
92
  });
@@ -106,16 +106,16 @@ async function syncCatalogGeneration(input) {
106
106
 
107
107
  // src/server/paging.ts
108
108
  function bounds(opts) {
109
- const pageSize = _nullishCoalesce(opts.pageSize, () => ( _chunkO2OYOK2Acjs.LIST_PAGE_SIZE));
110
- const max = _nullishCoalesce(opts.max, () => ( _chunkO2OYOK2Acjs.LIST_MAX_HANDLES));
111
- if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > _chunkO2OYOK2Acjs.LIST_PAGE_SIZE)
109
+ const pageSize = _nullishCoalesce(opts.pageSize, () => ( _chunkZFUELHUUcjs.LIST_PAGE_SIZE));
110
+ const max = _nullishCoalesce(opts.max, () => ( _chunkZFUELHUUcjs.LIST_MAX_HANDLES));
111
+ if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > _chunkZFUELHUUcjs.LIST_PAGE_SIZE)
112
112
  throw invalid("pageSize must be a whole number from 1 to 100.");
113
113
  if (!Number.isInteger(max) || max < 0)
114
114
  throw invalid("max must be a whole number of 0 or more.");
115
115
  return { pageSize, max };
116
116
  }
117
117
  function invalid(message) {
118
- return new (0, _chunkO2OYOK2Acjs.CatalogError)({
118
+ return new (0, _chunkZFUELHUUcjs.CatalogError)({
119
119
  code: "INVALID_REQUEST",
120
120
  message,
121
121
  statusCode: 0,
@@ -207,4 +207,4 @@ async function listAllCategoryHandles(client, opts = {}) {
207
207
 
208
208
 
209
209
 
210
- exports.SDK_VERSION = _chunkO2OYOK2Acjs.SDK_VERSION; exports.catalogTags = catalogTags; exports.createServerCatalogClient = createServerCatalogClient; exports.listAllCategoryHandles = listAllCategoryHandles; exports.listAllCollectionHandles = listAllCollectionHandles; exports.listAllProductHandles = listAllProductHandles; exports.staticProductParams = staticProductParams; exports.syncCatalogGeneration = syncCatalogGeneration;
210
+ exports.SDK_VERSION = _chunkZFUELHUUcjs.SDK_VERSION; exports.catalogTags = catalogTags; exports.createServerCatalogClient = createServerCatalogClient; exports.listAllCategoryHandles = listAllCategoryHandles; exports.listAllCollectionHandles = listAllCollectionHandles; exports.listAllProductHandles = listAllProductHandles; exports.staticProductParams = staticProductParams; exports.syncCatalogGeneration = syncCatalogGeneration;
package/dist/server.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  SDK_VERSION,
9
9
  TAG_MAX_CHARS,
10
10
  createCatalogClient
11
- } from "./chunk-PSCFHVZZ.js";
11
+ } from "./chunk-2PJXGAZF.js";
12
12
  import "./chunk-PKBMQBKP.js";
13
13
 
14
14
  // src/server/tags.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usethrottle/catalog",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "description": "Storefront SDK for the Throttle Product Catalog: a typed client, React hooks, server helpers and URL helpers.",
5
5
  "license": "MIT",
6
6
  "repository": {