@namiml/sdk-core 3.5.0-dev.202610272352 → 3.5.0-dev.202610311618

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.cjs CHANGED
@@ -106,7 +106,7 @@ const {
106
106
  // version — stamped by scripts/version.sh
107
107
  NAMI_SDK_VERSION: exports.NAMI_SDK_VERSION = "3.5.0",
108
108
  // full package version including dev suffix — stamped by scripts/version.sh
109
- NAMI_SDK_PACKAGE_VERSION: exports.NAMI_SDK_PACKAGE_VERSION = "3.5.0-dev.202610272352",
109
+ NAMI_SDK_PACKAGE_VERSION: exports.NAMI_SDK_PACKAGE_VERSION = "3.5.0-dev.202610311618",
110
110
  // environments
111
111
  PRODUCTION: exports.PRODUCTION = "production", DEVELOPMENT: exports.DEVELOPMENT = "development",
112
112
  // error messages
@@ -3110,7 +3110,11 @@ class PaywallBodyCache {
3110
3110
  if (ttl === null)
3111
3111
  return;
3112
3112
  const expired = [...this.loadIndex().values()].filter((meta) => isExpired(meta, ttl, now));
3113
+ if (expired.length === 0)
3114
+ return;
3113
3115
  this.dropPersisted(expired.map((meta) => meta.url));
3116
+ const oldestAge = ageSeconds(Math.min(...expired.map((meta) => meta.savedAt)), now);
3117
+ logger.debug(`Paywall body cache: evicted ${expired.length} expired entries (campaign_cache_ttl=${ttl}s, oldest_age=${oldestAge}s)`);
3114
3118
  }
3115
3119
  /**
3116
3120
  * The entry stored under this exact URL, or null when there is none, it is past `ttl`, or its
@@ -3124,6 +3128,7 @@ class PaywallBodyCache {
3124
3128
  return null;
3125
3129
  if (ttl !== null && ttl > 0 && isExpired(meta, ttl, now)) {
3126
3130
  this.dropPersisted([url]);
3131
+ logger.debug(`Paywall body cache: evicted expired entry on read (campaign_cache_ttl=${ttl}s, age=${ageSeconds(meta.savedAt, now)}s): ${url}`);
3127
3132
  return null;
3128
3133
  }
3129
3134
  const body = this.read(PAYWALL_BODY_KEY_PREFIX + url);
@@ -3168,6 +3173,30 @@ class PaywallBodyCache {
3168
3173
  const exists = this.memory ? this.memory.has(url) : this.loadIndex().has(url);
3169
3174
  return exists ? false : this.store(url, contentHash, body, now);
3170
3175
  }
3176
+ /**
3177
+ * Restarts the TTL window of a resolution's `content_hash` hits: the latest `campaign_rules` still
3178
+ * names each exact URL, and a `content_hash` is a pure function of the body, so the stored body is
3179
+ * still the current one. Rewrites only the metadata key (the body is untouched) and skips absent
3180
+ * entries. A renew that does not land keeps the old `savedAt`, which is harmless. Nothing is
3181
+ * renewed under `null` (never ages) or `0` (memory only). Returns the number of entries renewed.
3182
+ */
3183
+ renew(urls, ttl, now = Date.now()) {
3184
+ if (ttl === null || ttl <= 0 || this.memory)
3185
+ return 0;
3186
+ const index = this.loadIndex();
3187
+ let renewed = 0;
3188
+ for (const url of urls) {
3189
+ const meta = index.get(url);
3190
+ if (!meta)
3191
+ continue;
3192
+ const fresh = { ...meta, savedAt: now };
3193
+ if (this.write(PAYWALL_BODY_META_KEY_PREFIX + url, JSON.stringify(fresh))) {
3194
+ index.set(url, fresh);
3195
+ renewed += 1;
3196
+ }
3197
+ }
3198
+ return renewed;
3199
+ }
3171
3200
  /** Deletes one entry — used for an undecodable body. Not eviction. */
3172
3201
  remove(url) {
3173
3202
  if (this.memory)
@@ -3186,7 +3215,11 @@ class PaywallBodyCache {
3186
3215
  const unnamed = metas.filter((meta) => !currentUrls.has(meta.url)).map((meta) => meta.url);
3187
3216
  const kept = metas.filter((meta) => currentUrls.has(meta.url)).sort((a, b) => a.savedAt - b.savedAt);
3188
3217
  const overCap = kept.slice(0, Math.max(0, kept.length - this.maxEntries)).map((meta) => meta.url);
3218
+ if (unnamed.length === 0 && overCap.length === 0)
3219
+ return;
3189
3220
  [...unnamed, ...overCap].forEach((url) => this.remove(url));
3221
+ logger.debug(`Paywall body cache: evicted ${unnamed.length} entries no longer named by campaign_rules, ` +
3222
+ `${overCap.length} over the ${this.maxEntries}-entry cap`);
3190
3223
  }
3191
3224
  /** Empties the store — persisted and in-memory entries alike. Called by `Nami.reset()`. */
3192
3225
  purgeAll() {
@@ -3259,6 +3292,10 @@ class PaywallBodyCache {
3259
3292
  function isExpired(meta, ttlSeconds, now) {
3260
3293
  return (now - meta.savedAt) / 1000 >= ttlSeconds;
3261
3294
  }
3295
+ /** Whole seconds since `savedAt` (epoch ms), as the cross-SDK eviction logs print it. */
3296
+ function ageSeconds(savedAt, now) {
3297
+ return Math.floor((now - savedAt) / 1000);
3298
+ }
3262
3299
  function parseMeta(raw) {
3263
3300
  if (raw === null)
3264
3301
  return null;
@@ -58362,6 +58399,7 @@ class PaywallRepository {
58362
58399
  cacheAfterFetchFailure: 0,
58363
58400
  failed: 0,
58364
58401
  retryCount: 0,
58402
+ renewableUrls: [],
58365
58403
  };
58366
58404
  const resolvedUrls = new Map();
58367
58405
  resolutions.forEach((resolution, i) => {
@@ -58371,8 +58409,11 @@ class PaywallRepository {
58371
58409
  return;
58372
58410
  }
58373
58411
  resolvedUrls.set(resolution.paywall, uniqueUrls[i]);
58374
- if (resolution.paywall.fetchSource === "cacheHit")
58412
+ if (resolution.paywall.fetchSource === "cacheHit") {
58375
58413
  outcome.cacheHits += 1;
58414
+ if (contentHashOf(uniqueUrls[i]))
58415
+ outcome.renewableUrls.push(uniqueUrls[i]);
58416
+ }
58376
58417
  else if (resolution.paywall.fetchSource === "cacheAfterFetchFailure")
58377
58418
  outcome.cacheAfterFetchFailure += 1;
58378
58419
  else
@@ -58384,6 +58425,16 @@ class PaywallRepository {
58384
58425
  }
58385
58426
  return outcome;
58386
58427
  }
58428
+ /**
58429
+ * Sliding expiry (§ 2.2 → TTL): under `campaign_cache_ttl > 0`, renews the `savedAt` of every
58430
+ * `content_hash` hit in one batch, so an entry expires only once no resolution has named it for a
58431
+ * full TTL. Un-hashed URLs never renew — nothing vouches that their stored body is current. The
58432
+ * caller runs this after reconcile, and only after a resolution that ran on `campaign_rules` from
58433
+ * the network, so a stale stored copy of the rules can never keep entries alive.
58434
+ */
58435
+ renewCacheHits(outcome) {
58436
+ this.bodyCache.renew(outcome.renewableUrls, getCampaignCacheTtl());
58437
+ }
58387
58438
  /**
58388
58439
  * Seeds the body cache from the initial-config bundle (§ 2.2 "Seeding"): every **hashed** bundled
58389
58440
  * URL whose paywall id matches a bundled body is stored under that exact URL unless an entry
@@ -62675,6 +62726,10 @@ class NamiRefs {
62675
62726
  `campaigns=${campaignRulesDuration}ms, paywalls=${paywallsDuration}ms` +
62676
62727
  `${outcome ? `, retry_count=${outcome.retryCount}` : ""}`);
62677
62728
  }
62729
+ // After reconcile and the telemetry line, in one batch. `outcome` is set only when the rules
62730
+ // came from the network, so a stored copy of them never renews an entry.
62731
+ if (outcome)
62732
+ paywallRepo.renewCacheHits(outcome);
62678
62733
  return campaignRepo.finalizeCampaignRules(rawCampaigns, paywalls);
62679
62734
  }
62680
62735
  reRenderPaywall() {
package/dist/index.d.ts CHANGED
@@ -3862,12 +3862,15 @@ declare class EntitlementRepository {
3862
3862
  * 2026-09-28, NFL Web Prod's largest set is 66 bodies / 2.1 MB, well inside localStorage's ~5 MB;
3863
3863
  * a second copy beside an aggregate would have doubled that. Under `campaign_cache_ttl == 0`
3864
3864
  * entries are held in memory instead. Every storage failure degrades to "not cached".
3865
+ *
3866
+ * Cache hits on `content_hash` URLs renew their entries (`renew`), so an entry expires only once
3867
+ * no resolution has named it for a full TTL (sliding expiry, NAM-5368). Every eviction is logged.
3865
3868
  */
3866
3869
  type PaywallBodyMeta = {
3867
3870
  url: string;
3868
3871
  /** The URL's `content_hash`, absent for an un-hashed URL. */
3869
3872
  contentHash?: string;
3870
- /** Epoch milliseconds at which this entry was stored. */
3873
+ /** Epoch milliseconds at which this entry was stored, or last renewed by a cache hit. */
3871
3874
  savedAt: number;
3872
3875
  };
3873
3876
  type PaywallBodyEntry = {
@@ -3904,6 +3907,14 @@ declare class PaywallBodyCache {
3904
3907
  store(url: string, contentHash: string | undefined, body: string, now?: number): boolean;
3905
3908
  /** Stores a bundled body unless an entry already exists — a body the SDK fetched always wins. */
3906
3909
  seedIfAbsent(url: string, contentHash: string | undefined, body: string, now?: number): boolean;
3910
+ /**
3911
+ * Restarts the TTL window of a resolution's `content_hash` hits: the latest `campaign_rules` still
3912
+ * names each exact URL, and a `content_hash` is a pure function of the body, so the stored body is
3913
+ * still the current one. Rewrites only the metadata key (the body is untouched) and skips absent
3914
+ * entries. A renew that does not land keeps the old `savedAt`, which is harmless. Nothing is
3915
+ * renewed under `null` (never ages) or `0` (memory only). Returns the number of entries renewed.
3916
+ */
3917
+ renew(urls: string[], ttl: number | null, now?: number): number;
3907
3918
  /** Deletes one entry — used for an undecodable body. Not eviction. */
3908
3919
  remove(url: string): void;
3909
3920
  /**
@@ -3933,6 +3944,8 @@ type PaywallResolutionOutcome = {
3933
3944
  cacheAfterFetchFailure: number;
3934
3945
  failed: number;
3935
3946
  retryCount: number;
3947
+ /** Hit-row URLs that carry a `content_hash`: the entries `renewCacheHits` renews. */
3948
+ renewableUrls: string[];
3936
3949
  };
3937
3950
  declare class PaywallRepository {
3938
3951
  private readonly bodyCache;
@@ -3959,6 +3972,14 @@ declare class PaywallRepository {
3959
3972
  * after a complete `campaign_rules` fetch.
3960
3973
  */
3961
3974
  resolvePaywallsByUrls(urls: string[]): Promise<PaywallResolutionOutcome>;
3975
+ /**
3976
+ * Sliding expiry (§ 2.2 → TTL): under `campaign_cache_ttl > 0`, renews the `savedAt` of every
3977
+ * `content_hash` hit in one batch, so an entry expires only once no resolution has named it for a
3978
+ * full TTL. Un-hashed URLs never renew — nothing vouches that their stored body is current. The
3979
+ * caller runs this after reconcile, and only after a resolution that ran on `campaign_rules` from
3980
+ * the network, so a stale stored copy of the rules can never keep entries alive.
3981
+ */
3982
+ renewCacheHits(outcome: PaywallResolutionOutcome): void;
3962
3983
  /**
3963
3984
  * Seeds the body cache from the initial-config bundle (§ 2.2 "Seeding"): every **hashed** bundled
3964
3985
  * URL whose paywall id matches a bundled body is stored under that exact URL unless an entry
package/dist/index.mjs CHANGED
@@ -104,7 +104,7 @@ const {
104
104
  // version — stamped by scripts/version.sh
105
105
  NAMI_SDK_VERSION = "3.5.0",
106
106
  // full package version including dev suffix — stamped by scripts/version.sh
107
- NAMI_SDK_PACKAGE_VERSION = "3.5.0-dev.202610272352",
107
+ NAMI_SDK_PACKAGE_VERSION = "3.5.0-dev.202610311618",
108
108
  // environments
109
109
  PRODUCTION = "production", DEVELOPMENT = "development",
110
110
  // error messages
@@ -3108,7 +3108,11 @@ class PaywallBodyCache {
3108
3108
  if (ttl === null)
3109
3109
  return;
3110
3110
  const expired = [...this.loadIndex().values()].filter((meta) => isExpired(meta, ttl, now));
3111
+ if (expired.length === 0)
3112
+ return;
3111
3113
  this.dropPersisted(expired.map((meta) => meta.url));
3114
+ const oldestAge = ageSeconds(Math.min(...expired.map((meta) => meta.savedAt)), now);
3115
+ logger.debug(`Paywall body cache: evicted ${expired.length} expired entries (campaign_cache_ttl=${ttl}s, oldest_age=${oldestAge}s)`);
3112
3116
  }
3113
3117
  /**
3114
3118
  * The entry stored under this exact URL, or null when there is none, it is past `ttl`, or its
@@ -3122,6 +3126,7 @@ class PaywallBodyCache {
3122
3126
  return null;
3123
3127
  if (ttl !== null && ttl > 0 && isExpired(meta, ttl, now)) {
3124
3128
  this.dropPersisted([url]);
3129
+ logger.debug(`Paywall body cache: evicted expired entry on read (campaign_cache_ttl=${ttl}s, age=${ageSeconds(meta.savedAt, now)}s): ${url}`);
3125
3130
  return null;
3126
3131
  }
3127
3132
  const body = this.read(PAYWALL_BODY_KEY_PREFIX + url);
@@ -3166,6 +3171,30 @@ class PaywallBodyCache {
3166
3171
  const exists = this.memory ? this.memory.has(url) : this.loadIndex().has(url);
3167
3172
  return exists ? false : this.store(url, contentHash, body, now);
3168
3173
  }
3174
+ /**
3175
+ * Restarts the TTL window of a resolution's `content_hash` hits: the latest `campaign_rules` still
3176
+ * names each exact URL, and a `content_hash` is a pure function of the body, so the stored body is
3177
+ * still the current one. Rewrites only the metadata key (the body is untouched) and skips absent
3178
+ * entries. A renew that does not land keeps the old `savedAt`, which is harmless. Nothing is
3179
+ * renewed under `null` (never ages) or `0` (memory only). Returns the number of entries renewed.
3180
+ */
3181
+ renew(urls, ttl, now = Date.now()) {
3182
+ if (ttl === null || ttl <= 0 || this.memory)
3183
+ return 0;
3184
+ const index = this.loadIndex();
3185
+ let renewed = 0;
3186
+ for (const url of urls) {
3187
+ const meta = index.get(url);
3188
+ if (!meta)
3189
+ continue;
3190
+ const fresh = { ...meta, savedAt: now };
3191
+ if (this.write(PAYWALL_BODY_META_KEY_PREFIX + url, JSON.stringify(fresh))) {
3192
+ index.set(url, fresh);
3193
+ renewed += 1;
3194
+ }
3195
+ }
3196
+ return renewed;
3197
+ }
3169
3198
  /** Deletes one entry — used for an undecodable body. Not eviction. */
3170
3199
  remove(url) {
3171
3200
  if (this.memory)
@@ -3184,7 +3213,11 @@ class PaywallBodyCache {
3184
3213
  const unnamed = metas.filter((meta) => !currentUrls.has(meta.url)).map((meta) => meta.url);
3185
3214
  const kept = metas.filter((meta) => currentUrls.has(meta.url)).sort((a, b) => a.savedAt - b.savedAt);
3186
3215
  const overCap = kept.slice(0, Math.max(0, kept.length - this.maxEntries)).map((meta) => meta.url);
3216
+ if (unnamed.length === 0 && overCap.length === 0)
3217
+ return;
3187
3218
  [...unnamed, ...overCap].forEach((url) => this.remove(url));
3219
+ logger.debug(`Paywall body cache: evicted ${unnamed.length} entries no longer named by campaign_rules, ` +
3220
+ `${overCap.length} over the ${this.maxEntries}-entry cap`);
3188
3221
  }
3189
3222
  /** Empties the store — persisted and in-memory entries alike. Called by `Nami.reset()`. */
3190
3223
  purgeAll() {
@@ -3257,6 +3290,10 @@ class PaywallBodyCache {
3257
3290
  function isExpired(meta, ttlSeconds, now) {
3258
3291
  return (now - meta.savedAt) / 1000 >= ttlSeconds;
3259
3292
  }
3293
+ /** Whole seconds since `savedAt` (epoch ms), as the cross-SDK eviction logs print it. */
3294
+ function ageSeconds(savedAt, now) {
3295
+ return Math.floor((now - savedAt) / 1000);
3296
+ }
3260
3297
  function parseMeta(raw) {
3261
3298
  if (raw === null)
3262
3299
  return null;
@@ -58360,6 +58397,7 @@ class PaywallRepository {
58360
58397
  cacheAfterFetchFailure: 0,
58361
58398
  failed: 0,
58362
58399
  retryCount: 0,
58400
+ renewableUrls: [],
58363
58401
  };
58364
58402
  const resolvedUrls = new Map();
58365
58403
  resolutions.forEach((resolution, i) => {
@@ -58369,8 +58407,11 @@ class PaywallRepository {
58369
58407
  return;
58370
58408
  }
58371
58409
  resolvedUrls.set(resolution.paywall, uniqueUrls[i]);
58372
- if (resolution.paywall.fetchSource === "cacheHit")
58410
+ if (resolution.paywall.fetchSource === "cacheHit") {
58373
58411
  outcome.cacheHits += 1;
58412
+ if (contentHashOf(uniqueUrls[i]))
58413
+ outcome.renewableUrls.push(uniqueUrls[i]);
58414
+ }
58374
58415
  else if (resolution.paywall.fetchSource === "cacheAfterFetchFailure")
58375
58416
  outcome.cacheAfterFetchFailure += 1;
58376
58417
  else
@@ -58382,6 +58423,16 @@ class PaywallRepository {
58382
58423
  }
58383
58424
  return outcome;
58384
58425
  }
58426
+ /**
58427
+ * Sliding expiry (§ 2.2 → TTL): under `campaign_cache_ttl > 0`, renews the `savedAt` of every
58428
+ * `content_hash` hit in one batch, so an entry expires only once no resolution has named it for a
58429
+ * full TTL. Un-hashed URLs never renew — nothing vouches that their stored body is current. The
58430
+ * caller runs this after reconcile, and only after a resolution that ran on `campaign_rules` from
58431
+ * the network, so a stale stored copy of the rules can never keep entries alive.
58432
+ */
58433
+ renewCacheHits(outcome) {
58434
+ this.bodyCache.renew(outcome.renewableUrls, getCampaignCacheTtl());
58435
+ }
58385
58436
  /**
58386
58437
  * Seeds the body cache from the initial-config bundle (§ 2.2 "Seeding"): every **hashed** bundled
58387
58438
  * URL whose paywall id matches a bundled body is stored under that exact URL unless an entry
@@ -62673,6 +62724,10 @@ class NamiRefs {
62673
62724
  `campaigns=${campaignRulesDuration}ms, paywalls=${paywallsDuration}ms` +
62674
62725
  `${outcome ? `, retry_count=${outcome.retryCount}` : ""}`);
62675
62726
  }
62727
+ // After reconcile and the telemetry line, in one batch. `outcome` is set only when the rules
62728
+ // came from the network, so a stored copy of them never renews an entry.
62729
+ if (outcome)
62730
+ paywallRepo.renewCacheHits(outcome);
62676
62731
  return campaignRepo.finalizeCampaignRules(rawCampaigns, paywalls);
62677
62732
  }
62678
62733
  reRenderPaywall() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@namiml/sdk-core",
3
- "version": "3.5.0-dev.202610272352",
3
+ "version": "3.5.0-dev.202610311618",
4
4
  "description": "Platform-agnostic core for the Nami SDK — business logic, API, types, and state management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",