@cross-deck/web 1.0.1 → 1.1.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/dist/index.d.mts CHANGED
@@ -276,6 +276,14 @@ interface Diagnostics {
276
276
  entitlements: {
277
277
  count: number;
278
278
  lastUpdated: number;
279
+ /**
280
+ * True when the durable cache is knowingly serving older-than-
281
+ * trustworthy data — the last refresh attempt failed (Crossdeck
282
+ * unreachable) or last-known-good has aged past the staleness
283
+ * window. The cache still serves last-known-good; this makes the
284
+ * staleness observable instead of a silent unbounded window.
285
+ */
286
+ stale: boolean;
279
287
  /**
280
288
  * Cumulative count of listener invocations that threw. Swallowed
281
289
  * inside the cache (a buggy consumer must not crash the SDK) but
@@ -300,43 +308,53 @@ interface Diagnostics {
300
308
  }
301
309
 
302
310
  /**
303
- * Local cache of active entitlements so isEntitled() can answer
304
- * synchronously after the first read. Cache is updated:
305
- * - On successful getEntitlements()
306
- * - On successful purchase()
307
- * - Manually via setFromList() (used by callers that batch updates)
311
+ * Durable last-known-good cache of the customer's entitlements.
308
312
  *
309
- * The cache holds only ACTIVE entitlements inactive ones are excluded
310
- * by the backend before they hit us. isEntitled returns false for
311
- * anything not in the set.
313
+ * This cache is NOT a second source of truth. Crossdeck remains the
314
+ * only source; this is the SDK's local copy of what the server last
315
+ * told us a cache that doesn't forget during a network partition.
312
316
  *
313
- * Reactive listener API
314
- * ---------------------
315
- * `subscribe(listener)` registers a callback that fires every time the
316
- * cache mutates (setFromList or clear). This is the foundation for the
317
- * `useEntitlement` React hook in `@cross-deck/web/react` and any other
318
- * framework binding consumers need: SwiftUI's `@Observable`, Vue's
319
- * `ref()`, Solid's signals, etc.
317
+ * Durability contract (the RevenueCat model):
318
+ * - Every successful server read is persisted to device storage
319
+ * (localStorage, via the SDK's storage adapter).
320
+ * - On SDK boot the cache hydrates from storage synchronously, so
321
+ * isEntitled() answers correctly from the very first call — there
322
+ * is no cold-start window where a returning Pro customer reads as
323
+ * free.
324
+ * - When the server is unreachable, the SDK keeps serving the last
325
+ * entitlements it successfully fetched. A failed refresh never
326
+ * reaches setFromList(), so it cannot clear the cache; only a
327
+ * SUCCESSFUL fetch replaces it. An outage can never fail a paying
328
+ * customer down to free.
329
+ * - Staleness alone never returns false. Each entitlement is honoured
330
+ * against its OWN validUntil instead — a time-based trial expiry
331
+ * still applies even mid-partition, a still-valid Pro entitlement
332
+ * rides the outage out.
333
+ * - Staleness is VISIBLE, not silent. validUntil covers time-based
334
+ * expiry; it does NOT cover an event-based revoke (chargeback,
335
+ * refund, fraud) — that has no validUntil, so the cache would keep
336
+ * serving a revoked customer through an outage. Serving them is the
337
+ * right trade (don't lock real payers out), but unbounded-and-
338
+ * invisible is the bug. So: once a refresh ATTEMPT fails (or the
339
+ * data ages past staleAfterMs) the cache is marked stale —
340
+ * isStale / freshness are surfaced in diagnostics(). It keeps
341
+ * serving last-known-good; the staleness is just no longer hidden.
320
342
  *
321
- * Why we need it: isEntitled() is a sync cache read — but if a React
322
- * component calls it in a render path, React has no way to know when
323
- * the cache populates asynchronously after `getEntitlements()` lands.
324
- * Without a subscribe API the component shows the empty-cache result
325
- * forever (until something else triggers a re-render). With it, the
326
- * binding can re-render when the data actually arrives.
343
+ * The cache is wiped only on reset() (logout) and on an identity switch
344
+ * never by a TTL.
327
345
  *
328
- * Listener semantics:
329
- * - Fired AFTER the cache has been mutated (listener sees fresh state)
330
- * - Fire-and-forget: thrown errors in a listener don't crash the SDK
331
- * (they're swallowed; the next listener still runs)
332
- * - The unsubscribe function returned from subscribe() is idempotent
333
- * - Listeners are NOT fired on subscribe caller is expected to
334
- * read current state synchronously from isEntitled()/list() if it
335
- * wants the initial render to reflect cached data
336
- *
337
- * Thread / re-entrancy safety: this is a synchronous in-memory Set with
338
- * no I/O. The async paths that update it are serialised through the
339
- * SDK's request queue callers won't see torn reads.
346
+ * Reactive listener API
347
+ * ---------------------
348
+ * `subscribe(listener)` registers a callback fired every time the cache
349
+ * mutates (setFromList or clear) — the foundation for the
350
+ * `useEntitlement` React hook and other framework bindings. Semantics:
351
+ * - Fired AFTER the mutation, so the listener sees fresh state.
352
+ * - Fire-and-forget: a throwing listener is swallowed (and counted)
353
+ * so a buggy consumer can't crash the SDK or other listeners.
354
+ * - Unsubscribe is idempotent.
355
+ * - Listeners are NOT fired on subscribe a caller that wants the
356
+ * initial state reads isEntitled() / list() synchronously, which
357
+ * work from boot thanks to hydration above.
340
358
  */
341
359
 
342
360
  type EntitlementsListener = (entitlements: PublicEntitlement[]) => void;
@@ -723,8 +741,12 @@ declare class CrossdeckClient {
723
741
  */
724
742
  getEntitlements(): Promise<PublicEntitlement[]>;
725
743
  /**
726
- * Synchronous read from the local cache. Returns false if the cache
727
- * has never been populated (call getEntitlements first to warm it).
744
+ * Synchronous read from the durable local cache answers from
745
+ * last-known-good. The cache hydrates from device storage on boot and
746
+ * survives a Crossdeck outage, so a returning paying customer reads
747
+ * true even before the session's first network round-trip. Returns
748
+ * false only for a genuinely new install that has never completed a
749
+ * getEntitlements(), or for an entitlement past its own validUntil.
728
750
  */
729
751
  isEntitled(key: string): boolean;
730
752
  /** Snapshot of the local entitlement cache. */
@@ -955,7 +977,7 @@ declare class MemoryStorage implements KeyValueStorage {
955
977
  * fetch shim, no transitive deps.
956
978
  */
957
979
  declare const SDK_NAME = "@cross-deck/web";
958
- declare const SDK_VERSION = "1.0.0";
980
+ declare const SDK_VERSION = "1.1.0";
959
981
  declare const DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
960
982
 
961
983
  /**
package/dist/index.d.ts CHANGED
@@ -276,6 +276,14 @@ interface Diagnostics {
276
276
  entitlements: {
277
277
  count: number;
278
278
  lastUpdated: number;
279
+ /**
280
+ * True when the durable cache is knowingly serving older-than-
281
+ * trustworthy data — the last refresh attempt failed (Crossdeck
282
+ * unreachable) or last-known-good has aged past the staleness
283
+ * window. The cache still serves last-known-good; this makes the
284
+ * staleness observable instead of a silent unbounded window.
285
+ */
286
+ stale: boolean;
279
287
  /**
280
288
  * Cumulative count of listener invocations that threw. Swallowed
281
289
  * inside the cache (a buggy consumer must not crash the SDK) but
@@ -300,43 +308,53 @@ interface Diagnostics {
300
308
  }
301
309
 
302
310
  /**
303
- * Local cache of active entitlements so isEntitled() can answer
304
- * synchronously after the first read. Cache is updated:
305
- * - On successful getEntitlements()
306
- * - On successful purchase()
307
- * - Manually via setFromList() (used by callers that batch updates)
311
+ * Durable last-known-good cache of the customer's entitlements.
308
312
  *
309
- * The cache holds only ACTIVE entitlements inactive ones are excluded
310
- * by the backend before they hit us. isEntitled returns false for
311
- * anything not in the set.
313
+ * This cache is NOT a second source of truth. Crossdeck remains the
314
+ * only source; this is the SDK's local copy of what the server last
315
+ * told us a cache that doesn't forget during a network partition.
312
316
  *
313
- * Reactive listener API
314
- * ---------------------
315
- * `subscribe(listener)` registers a callback that fires every time the
316
- * cache mutates (setFromList or clear). This is the foundation for the
317
- * `useEntitlement` React hook in `@cross-deck/web/react` and any other
318
- * framework binding consumers need: SwiftUI's `@Observable`, Vue's
319
- * `ref()`, Solid's signals, etc.
317
+ * Durability contract (the RevenueCat model):
318
+ * - Every successful server read is persisted to device storage
319
+ * (localStorage, via the SDK's storage adapter).
320
+ * - On SDK boot the cache hydrates from storage synchronously, so
321
+ * isEntitled() answers correctly from the very first call — there
322
+ * is no cold-start window where a returning Pro customer reads as
323
+ * free.
324
+ * - When the server is unreachable, the SDK keeps serving the last
325
+ * entitlements it successfully fetched. A failed refresh never
326
+ * reaches setFromList(), so it cannot clear the cache; only a
327
+ * SUCCESSFUL fetch replaces it. An outage can never fail a paying
328
+ * customer down to free.
329
+ * - Staleness alone never returns false. Each entitlement is honoured
330
+ * against its OWN validUntil instead — a time-based trial expiry
331
+ * still applies even mid-partition, a still-valid Pro entitlement
332
+ * rides the outage out.
333
+ * - Staleness is VISIBLE, not silent. validUntil covers time-based
334
+ * expiry; it does NOT cover an event-based revoke (chargeback,
335
+ * refund, fraud) — that has no validUntil, so the cache would keep
336
+ * serving a revoked customer through an outage. Serving them is the
337
+ * right trade (don't lock real payers out), but unbounded-and-
338
+ * invisible is the bug. So: once a refresh ATTEMPT fails (or the
339
+ * data ages past staleAfterMs) the cache is marked stale —
340
+ * isStale / freshness are surfaced in diagnostics(). It keeps
341
+ * serving last-known-good; the staleness is just no longer hidden.
320
342
  *
321
- * Why we need it: isEntitled() is a sync cache read — but if a React
322
- * component calls it in a render path, React has no way to know when
323
- * the cache populates asynchronously after `getEntitlements()` lands.
324
- * Without a subscribe API the component shows the empty-cache result
325
- * forever (until something else triggers a re-render). With it, the
326
- * binding can re-render when the data actually arrives.
343
+ * The cache is wiped only on reset() (logout) and on an identity switch
344
+ * never by a TTL.
327
345
  *
328
- * Listener semantics:
329
- * - Fired AFTER the cache has been mutated (listener sees fresh state)
330
- * - Fire-and-forget: thrown errors in a listener don't crash the SDK
331
- * (they're swallowed; the next listener still runs)
332
- * - The unsubscribe function returned from subscribe() is idempotent
333
- * - Listeners are NOT fired on subscribe caller is expected to
334
- * read current state synchronously from isEntitled()/list() if it
335
- * wants the initial render to reflect cached data
336
- *
337
- * Thread / re-entrancy safety: this is a synchronous in-memory Set with
338
- * no I/O. The async paths that update it are serialised through the
339
- * SDK's request queue callers won't see torn reads.
346
+ * Reactive listener API
347
+ * ---------------------
348
+ * `subscribe(listener)` registers a callback fired every time the cache
349
+ * mutates (setFromList or clear) — the foundation for the
350
+ * `useEntitlement` React hook and other framework bindings. Semantics:
351
+ * - Fired AFTER the mutation, so the listener sees fresh state.
352
+ * - Fire-and-forget: a throwing listener is swallowed (and counted)
353
+ * so a buggy consumer can't crash the SDK or other listeners.
354
+ * - Unsubscribe is idempotent.
355
+ * - Listeners are NOT fired on subscribe a caller that wants the
356
+ * initial state reads isEntitled() / list() synchronously, which
357
+ * work from boot thanks to hydration above.
340
358
  */
341
359
 
342
360
  type EntitlementsListener = (entitlements: PublicEntitlement[]) => void;
@@ -723,8 +741,12 @@ declare class CrossdeckClient {
723
741
  */
724
742
  getEntitlements(): Promise<PublicEntitlement[]>;
725
743
  /**
726
- * Synchronous read from the local cache. Returns false if the cache
727
- * has never been populated (call getEntitlements first to warm it).
744
+ * Synchronous read from the durable local cache answers from
745
+ * last-known-good. The cache hydrates from device storage on boot and
746
+ * survives a Crossdeck outage, so a returning paying customer reads
747
+ * true even before the session's first network round-trip. Returns
748
+ * false only for a genuinely new install that has never completed a
749
+ * getEntitlements(), or for an entitlement past its own validUntil.
728
750
  */
729
751
  isEntitled(key: string): boolean;
730
752
  /** Snapshot of the local entitlement cache. */
@@ -955,7 +977,7 @@ declare class MemoryStorage implements KeyValueStorage {
955
977
  * fetch shim, no transitive deps.
956
978
  */
957
979
  declare const SDK_NAME = "@cross-deck/web";
958
- declare const SDK_VERSION = "1.0.0";
980
+ declare const SDK_VERSION = "1.1.0";
959
981
  declare const DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
960
982
 
961
983
  /**