@namiml/sdk-core 3.5.0-dev.202611021820 → 3.5.0-dev.202611022057

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.202611021820",
109
+ NAMI_SDK_PACKAGE_VERSION: exports.NAMI_SDK_PACKAGE_VERSION = "3.5.0-dev.202611022057",
110
110
  // environments
111
111
  PRODUCTION: exports.PRODUCTION = "production", DEVELOPMENT: exports.DEVELOPMENT = "development",
112
112
  // error messages
@@ -57121,6 +57121,8 @@ function isSupportedJourney(meta) {
57121
57121
  return true;
57122
57122
  }
57123
57123
  const isJourneyCampaign = (rule) => rule.type === exports.NamiCampaignRuleType.JOURNEY;
57124
+ /** The parsed journey metadata of a journey rule; null for a placement rule or no metadata. */
57125
+ const journeyMetadataOf = (rule) => isJourneyCampaign(rule) ? parseJourneyMetadata(rule.journey) : null;
57124
57126
  /**
57125
57127
  * Whether this SDK can use the rule. Every placement rule qualifies. A journey also needs
57126
57128
  * supported metadata and a flow, because journeys are flow-only. Callers skip an unsupported
@@ -62880,6 +62882,16 @@ class JourneyEngine {
62880
62882
  recordDismiss(s, journeyId, now) {
62881
62883
  return this.withRecord(s, journeyId, { ...(s.journeys[journeyId] ?? emptyJourneyRecord()), lastDismissedAt: now.getTime() });
62882
62884
  }
62885
+ /**
62886
+ * Whether a goal signal counts now (NAM-3236): the journey was entered (an impression since
62887
+ * any earlier goal) and its `timeout_days` window, measured from entry, is still open.
62888
+ */
62889
+ isGoalOpen(record, journey, now) {
62890
+ if (record?.enteredAt == null)
62891
+ return false;
62892
+ const timeout = journey.goal?.timeout_days;
62893
+ return timeout == null || now.getTime() < record.enteredAt + timeout * DAY_MS;
62894
+ }
62883
62895
  /** Records goal completion and clears entry so a re-entry journey starts a fresh timeout. */
62884
62896
  recordGoal(s, journeyId, now) {
62885
62897
  return this.withRecord(s, journeyId, {
@@ -63042,6 +63054,11 @@ function presentJourneyCampaign(campaign, stillValid) {
63042
63054
  return ui.presentCampaign(campaign, { customAttributes: {} });
63043
63055
  }
63044
63056
 
63057
+ let refreshHost = null;
63058
+ /** Called by `NamiRefs` when it is constructed. */
63059
+ const registerJourneyRefreshHost = (host) => {
63060
+ refreshHost = host;
63061
+ };
63045
63062
  const monotonicNow = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
63046
63063
  const liveJourneyEnvironment = {
63047
63064
  now: () => new Date(),
@@ -63056,14 +63073,21 @@ const liveJourneyEnvironment = {
63056
63073
  get: () => storageService.getJourneyInstallEvaluated(),
63057
63074
  set: () => storageService.setJourneyInstallEvaluated(),
63058
63075
  },
63076
+ refresh: () => refreshHost?.fetchCampaignsAndPaywalls() ?? Promise.resolve(),
63077
+ refreshAllowed: () => !(refreshHost?.isStartupInFlight() ?? false),
63078
+ // Only while the platform has journey rules: an org with none sees no refresh traffic, and picks
63079
+ // up newly added journeys on the next session's startup fetch. `journey_settings` does not
63080
+ // count: the backend sends it in every platform config, journeys or not.
63081
+ refreshEnabled: () => (refreshHost?.canRefreshJourneys() ?? false) && journeyCampaigns().length > 0,
63059
63082
  };
63060
- const journeyIdOf = (rule) => parseJourneyMetadata(rule.journey)?.journey_id;
63083
+ const journeyIdOf = (rule) => journeyMetadataOf(rule)?.journey_id;
63061
63084
  /**
63062
63085
  * Drives journey delivery for the `app_install` and `session_start` triggers (NAM-3233):
63063
- * evaluation, the single delay slot, the fire-time re-check, delivery and local impression
63064
- * accounting. A port of Apple's `JourneyCoordinator` (NAM-3234) minus campaign-rule refresh,
63065
- * which core gains in NAM-3236. JS is single-threaded, so there is no queue: the reset
63066
- * generation alone keeps work from before a `Nami.reset()` off the fresh state.
63086
+ * evaluation, the single delay slot, the fire-time re-check, delivery, local impression
63087
+ * accounting, and campaign-rule refresh coalescing. A port of Apple's `JourneyCoordinator`
63088
+ * (NAM-3234), plus goal recording (NAM-3236), which Apple gains in NAM-3237. JS is
63089
+ * single-threaded, so there is no queue: the reset generation alone keeps work from before a
63090
+ * `Nami.reset()` off the fresh state.
63067
63091
  *
63068
63092
  * Guards on the way to a raise:
63069
63093
  * - A winner chosen while the app is hidden starts its delay paused, a 0-second delay included,
@@ -63080,6 +63104,11 @@ class JourneyCoordinator {
63080
63104
  this.engine = new JourneyEngine();
63081
63105
  this.resetGeneration = 0;
63082
63106
  this.backgroundedAt = null;
63107
+ this.refreshInFlight = false;
63108
+ this.refreshPending = false;
63109
+ /** A purchase asked for a refresh while a startup pass was running; flushed once when it ends. */
63110
+ this.refreshDeferred = false;
63111
+ this.refreshTimer = null;
63083
63112
  this.delay = new JourneyDelayTimer(env.monotonicNow);
63084
63113
  }
63085
63114
  /**
@@ -63109,14 +63138,17 @@ class JourneyCoordinator {
63109
63138
  }
63110
63139
  journeyStateStore.startSession();
63111
63140
  this.evaluate(isInstallCandidate);
63141
+ this.startRefreshTimer();
63112
63142
  }
63113
- /** Pauses the delay, keeping the remainder, and starts measuring the session gap. */
63143
+ /** Pauses the delay, keeping the remainder, stops the refresh timer and starts measuring the session gap. */
63114
63144
  appDidEnterBackground() {
63115
63145
  this.delay.pause();
63146
+ this.stopRefreshTimer();
63116
63147
  this.backgroundedAt ??= this.env.monotonicNow();
63117
63148
  }
63118
63149
  /**
63119
- * Resumes the delay with the remaining time, including one held since a hidden start.
63150
+ * Resumes the delay with the remaining time, including one held since a hidden start, and
63151
+ * restarts the refresh timer.
63120
63152
  *
63121
63153
  * @returns whether this return begins a new session: it came at least {@link SESSION_GAP_MS}
63122
63154
  * after the background (the session boundary Apple and Android share). A foreground with no
@@ -63126,17 +63158,152 @@ class JourneyCoordinator {
63126
63158
  const hiddenAt = this.backgroundedAt;
63127
63159
  this.backgroundedAt = null;
63128
63160
  this.delay.resume();
63161
+ this.startRefreshTimer();
63129
63162
  return hiddenAt !== null && this.env.monotonicNow() - hiddenAt >= exports.SESSION_GAP_MS;
63130
63163
  }
63131
63164
  /** A placement campaign is about to be raised: a journey still waiting must not fire on top of it. */
63132
63165
  placementWillRaise() {
63133
63166
  this.delay.cancel();
63134
63167
  }
63135
- /** Cancels the delay and invalidates in-flight work (`Nami.reset()`), before the storage wipe. */
63168
+ /**
63169
+ * Cancels the delay and the refresh timer, clears refresh state and invalidates in-flight work
63170
+ * (`Nami.reset()`), before the storage wipe.
63171
+ */
63136
63172
  reset() {
63137
63173
  this.delay.cancel();
63174
+ this.stopRefreshTimer();
63138
63175
  this.backgroundedAt = null;
63139
63176
  this.resetGeneration += 1;
63177
+ this.refreshInFlight = false;
63178
+ this.refreshPending = false;
63179
+ this.refreshDeferred = false;
63180
+ }
63181
+ // Goals (NAM-3236)
63182
+ /**
63183
+ * A flow's completion step matched its condition. For a journey whose goal is
63184
+ * `flow_completed`, that is the goal; the completion PATCH that reports it is the flow's own.
63185
+ *
63186
+ * @returns whether a goal was recorded.
63187
+ */
63188
+ flowCompletionSignaled(campaign) {
63189
+ const journey = campaign ? journeyMetadataOf(campaign) : null;
63190
+ if (journey?.goal?.type !== "flow_completed" || !this.goalReached(journey))
63191
+ return false;
63192
+ this.requestRefresh();
63193
+ return true;
63194
+ }
63195
+ /**
63196
+ * A customer attribute was set. Records the goal of every journey whose `user_context_set` goal
63197
+ * watches `key`, then refreshes the rules when the value changed (journey eligibility can depend
63198
+ * on attributes) or a goal was recorded (it can unlock the next journey).
63199
+ */
63200
+ customerAttributeSet(key, changed) {
63201
+ let goal = false;
63202
+ for (const rule of this.env.journeys()) {
63203
+ const journey = journeyMetadataOf(rule);
63204
+ if (journey?.goal?.type === "user_context_set" && journey.goal.context_key === key) {
63205
+ goal = this.goalReached(journey) || goal;
63206
+ }
63207
+ }
63208
+ if (changed || goal)
63209
+ this.requestRefresh();
63210
+ }
63211
+ /**
63212
+ * Records the goal and exits the journey: `goalAchievedAt` is stamped and entry cleared, so the
63213
+ * engine rejects it (`goalAchieved`) unless it allows re-entry. Counts only while the journey is
63214
+ * entered and inside its `timeout_days` window; anything else (never shown, already achieved,
63215
+ * timed out) is ignored.
63216
+ */
63217
+ goalReached(journey) {
63218
+ const id = journey.journey_id;
63219
+ const now = this.env.now();
63220
+ if (!this.engine.isGoalOpen(journeyStateStore.snapshot().journeys[id], journey, now)) {
63221
+ logger.debug(`Journey ${id} goal ignored: not entered, already achieved or timed out`);
63222
+ return false;
63223
+ }
63224
+ journeyStateStore.update((s) => this.engine.recordGoal(s, id, now));
63225
+ logger.debug(`Journey ${id} goal achieved`);
63226
+ return true;
63227
+ }
63228
+ // Refresh
63229
+ /**
63230
+ * Re-fetches campaign rules. At most one fetch is in flight, plus one trailing fetch if more
63231
+ * requests arrive meanwhile. Never evaluates journeys by itself: a newly eligible journey fires on
63232
+ * its own next trigger.
63233
+ *
63234
+ * While a startup pass runs the request is dropped, because the pass fetches the rules itself.
63235
+ * A purchase passes `deferIfBusy` so it is not lost: it refreshes once when the pass ends.
63236
+ */
63237
+ requestRefresh(deferIfBusy = false) {
63238
+ if (!this.env.refreshEnabled())
63239
+ return;
63240
+ if (this.refreshInFlight) {
63241
+ this.refreshPending = true;
63242
+ }
63243
+ else if (this.env.refreshAllowed()) {
63244
+ this.startRefresh();
63245
+ }
63246
+ else if (deferIfBusy) {
63247
+ this.refreshDeferred = true;
63248
+ }
63249
+ }
63250
+ /** A startup or resume pass ended, either way. Flushes a refresh a purchase deferred during it. */
63251
+ startupPassFinished() {
63252
+ if (!this.refreshDeferred)
63253
+ return;
63254
+ this.refreshDeferred = false;
63255
+ if (!this.env.refreshEnabled())
63256
+ return;
63257
+ if (this.refreshInFlight) {
63258
+ this.refreshPending = true;
63259
+ }
63260
+ else {
63261
+ this.startRefresh();
63262
+ }
63263
+ }
63264
+ startRefresh() {
63265
+ this.refreshInFlight = true;
63266
+ const generation = this.resetGeneration;
63267
+ const settle = () => {
63268
+ if (this.resetGeneration !== generation)
63269
+ return;
63270
+ this.refreshInFlight = false;
63271
+ if (this.refreshPending) {
63272
+ this.refreshPending = false;
63273
+ this.requestRefresh(true);
63274
+ }
63275
+ };
63276
+ let fetch;
63277
+ try {
63278
+ fetch = this.env.refresh();
63279
+ }
63280
+ catch (error) {
63281
+ fetch = Promise.reject(error);
63282
+ }
63283
+ fetch.then(settle, (error) => {
63284
+ logger.warn(`Journey refresh failed: ${error}`);
63285
+ settle();
63286
+ });
63287
+ }
63288
+ /**
63289
+ * Starts the repeating `refresh_interval_seconds` timer, but only while the app is visible: it
63290
+ * never runs in the background. Unreferenced in Node, so it never keeps a CLI process alive.
63291
+ */
63292
+ startRefreshTimer() {
63293
+ if (this.refreshTimer !== null || !this.env.isAppVisible())
63294
+ return;
63295
+ const ms = this.env.settings().refresh_interval_seconds * 1000;
63296
+ if (!(ms > 0))
63297
+ return;
63298
+ const timer = setInterval(() => this.requestRefresh(), ms);
63299
+ timer.unref?.();
63300
+ this.refreshTimer = timer;
63301
+ }
63302
+ stopRefreshTimer() {
63303
+ if (this.refreshTimer === null)
63304
+ return;
63305
+ clearInterval(this.refreshTimer);
63306
+ this.refreshTimer = null;
63140
63307
  }
63141
63308
  // Evaluation
63142
63309
  evaluate(isInstallCandidate) {
@@ -63239,6 +63406,15 @@ class NamiRefs {
63239
63406
  this.startupFetch = Promise.resolve();
63240
63407
  // Register this instance to avoid circular dependency issues
63241
63408
  setNamiRefsInstance(this);
63409
+ registerJourneyRefreshHost(this);
63410
+ }
63411
+ /** Whether a startup or resume pass is running; journey refresh waits for it (NAM-3236). */
63412
+ isStartupInFlight() {
63413
+ return this.startupInFlight;
63414
+ }
63415
+ /** Whether configure has run with a real app platform id and startup is not disabled. */
63416
+ canRefreshJourneys() {
63417
+ return !!this.lastConfig?.appPlatformID?.trim() && !this.startupDisabled;
63242
63418
  }
63243
63419
  setInMemoryAnonymousMode(enabled) {
63244
63420
  this.inMemoryAnonymousMode = enabled;
@@ -63287,6 +63463,7 @@ class NamiRefs {
63287
63463
  }
63288
63464
  finally {
63289
63465
  this.startupInFlight = false;
63466
+ JourneyCoordinator.shared.startupPassFinished();
63290
63467
  }
63291
63468
  }
63292
63469
  /**
@@ -63349,6 +63526,7 @@ class NamiRefs {
63349
63526
  }
63350
63527
  finally {
63351
63528
  this.startupInFlight = false;
63529
+ JourneyCoordinator.shared.startupPassFinished();
63352
63530
  }
63353
63531
  }
63354
63532
  /** Everything startup runs once the platform-config step has found a usable config. */
@@ -64996,6 +65174,15 @@ let NamiPaywallManager$2 = class NamiPaywallManager {
64996
65174
  * @returns {Promise<void>} A Promise that resolves when buying SKU will be complete.
64997
65175
  */
64998
65176
  static async buySkuComplete(purchase) {
65177
+ try {
65178
+ await this.reportPurchaseComplete(purchase);
65179
+ }
65180
+ finally {
65181
+ // A host-managed purchase changes journey eligibility too: re-fetch the rules (NAM-3236).
65182
+ JourneyCoordinator.shared.requestRefresh(true);
65183
+ }
65184
+ }
65185
+ static async reportPurchaseComplete(purchase) {
64999
65186
  // clear loading indicator
65000
65187
  PaywallState.setPurchaseInProgress(false);
65001
65188
  if (NamiFlowManager$2.instance.flowOpen) {
@@ -65920,6 +66107,7 @@ let NamiCustomerManager$2 = class NamiCustomerManager {
65920
66107
  if (!this.instance.isSDKInitialized) {
65921
66108
  throw new SDKNotInitializedError();
65922
66109
  }
66110
+ const changed = storageService.getCustomerAttribute(key) !== value;
65923
66111
  storageService.setCustomerAttribute(key, value);
65924
66112
  if (!hasCapability(exports.Capabilities.PAYWALL_PERSONALIZATION)) {
65925
66113
  logger.warn(`The customer attribute ${key} is not available for using on a paywall, because your account's plan does not include paywall personalization. Contact support@nami.ml for details.`);
@@ -65931,12 +66119,19 @@ let NamiCustomerManager$2 = class NamiCustomerManager {
65931
66119
  if (NamiFlowManager$2.instance.flowOpen) {
65932
66120
  const currentFlow = NamiFlowManager$2.instance.currentFlow;
65933
66121
  if (currentFlow) {
66122
+ // An open journey flow whose `user_context_set` goal watches this key reports the goal
66123
+ // through its completion step (NAM-3236), before the lifecycle can move it off the page.
66124
+ const goal = currentFlow.campaign ? journeyMetadataOf(currentFlow.campaign)?.goal : null;
66125
+ if (goal?.type === "user_context_set" && goal.context_key === key) {
66126
+ currentFlow.completionSignal("context_set");
66127
+ }
65934
66128
  const currentStep = currentFlow.currentFlowStep;
65935
66129
  if (currentStep) {
65936
66130
  currentFlow.executeLifecycle(currentStep, NamiReservedActions.TAG_UPDATE);
65937
66131
  }
65938
66132
  }
65939
66133
  }
66134
+ JourneyCoordinator.shared.customerAttributeSet(key, changed);
65940
66135
  }
65941
66136
  /**
65942
66137
  * Retrieves the value for a given key in the on-device customer attribute key/value store.
@@ -66398,6 +66593,9 @@ class NamiFlow extends BasicNamiFlow {
66398
66593
  }
66399
66594
  if (ctx.resultSent)
66400
66595
  return; // R4: one completion per impression
66596
+ // A journey's `flow_completed` goal is met here, whether or not an impression is bound yet
66597
+ // (anonymous mode never binds one); the PATCH below is what reports it (NAM-3236).
66598
+ JourneyCoordinator.shared.flowCompletionSignaled(this.campaign);
66401
66599
  if (!ctx.impressionId) {
66402
66600
  if (!ctx.pending.includes(condition))
66403
66601
  ctx.pending.push(condition);
package/dist/index.d.ts CHANGED
@@ -1030,7 +1030,9 @@ interface NamiFlowStep {
1030
1030
  * plain string on `completion_step.condition`; this union is the SDK-side
1031
1031
  * vocabulary the engine acts on.
1032
1032
  */
1033
- type NamiFlowCompletionCondition = 'page_view' | 'purchase_success' | 'login_success' | 'push_authorized' | 'location_authorized' | 'tracking_authorized' | 'form_submitted';
1033
+ type NamiFlowCompletionCondition = 'page_view' | 'purchase_success' | 'login_success' | 'push_authorized' | 'location_authorized' | 'tracking_authorized' | 'form_submitted'
1034
+ /** A journey's `user_context_set` goal: its `goal.context_key` attribute was set (NAM-3236). */
1035
+ | 'context_set';
1034
1036
  /**
1035
1037
  * Every value the SDK may PATCH as `completion_result`: the positive
1036
1038
  * conditions plus the two non-completion outcomes recorded when a flow
@@ -3681,6 +3683,10 @@ declare class NamiRefs {
3681
3683
  private startupInFlight;
3682
3684
  private startupFetch;
3683
3685
  constructor();
3686
+ /** Whether a startup or resume pass is running; journey refresh waits for it (NAM-3236). */
3687
+ isStartupInFlight(): boolean;
3688
+ /** Whether configure has run with a real app platform id and startup is not disabled. */
3689
+ canRefreshJourneys(): boolean;
3684
3690
  setInMemoryAnonymousMode(enabled: boolean): void;
3685
3691
  isInMemoryAnonymousMode(): boolean;
3686
3692
  isAnonymousMode(): boolean;
@@ -4543,6 +4549,7 @@ declare class NamiPaywallManager$1 {
4543
4549
  * @returns {Promise<void>} A Promise that resolves when buying SKU will be complete.
4544
4550
  */
4545
4551
  static buySkuComplete(purchase: NamiPurchaseSuccess): Promise<void>;
4552
+ private static reportPurchaseComplete;
4546
4553
  /**
4547
4554
  * Notify the NamiPaywallManager that purchase flow handled by you is cancelled.
4548
4555
  * Used to disable product purchase-in-progress loading indicators
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.202611021820",
107
+ NAMI_SDK_PACKAGE_VERSION = "3.5.0-dev.202611022057",
108
108
  // environments
109
109
  PRODUCTION = "production", DEVELOPMENT = "development",
110
110
  // error messages
@@ -57119,6 +57119,8 @@ function isSupportedJourney(meta) {
57119
57119
  return true;
57120
57120
  }
57121
57121
  const isJourneyCampaign = (rule) => rule.type === NamiCampaignRuleType.JOURNEY;
57122
+ /** The parsed journey metadata of a journey rule; null for a placement rule or no metadata. */
57123
+ const journeyMetadataOf = (rule) => isJourneyCampaign(rule) ? parseJourneyMetadata(rule.journey) : null;
57122
57124
  /**
57123
57125
  * Whether this SDK can use the rule. Every placement rule qualifies. A journey also needs
57124
57126
  * supported metadata and a flow, because journeys are flow-only. Callers skip an unsupported
@@ -62878,6 +62880,16 @@ class JourneyEngine {
62878
62880
  recordDismiss(s, journeyId, now) {
62879
62881
  return this.withRecord(s, journeyId, { ...(s.journeys[journeyId] ?? emptyJourneyRecord()), lastDismissedAt: now.getTime() });
62880
62882
  }
62883
+ /**
62884
+ * Whether a goal signal counts now (NAM-3236): the journey was entered (an impression since
62885
+ * any earlier goal) and its `timeout_days` window, measured from entry, is still open.
62886
+ */
62887
+ isGoalOpen(record, journey, now) {
62888
+ if (record?.enteredAt == null)
62889
+ return false;
62890
+ const timeout = journey.goal?.timeout_days;
62891
+ return timeout == null || now.getTime() < record.enteredAt + timeout * DAY_MS;
62892
+ }
62881
62893
  /** Records goal completion and clears entry so a re-entry journey starts a fresh timeout. */
62882
62894
  recordGoal(s, journeyId, now) {
62883
62895
  return this.withRecord(s, journeyId, {
@@ -63040,6 +63052,11 @@ function presentJourneyCampaign(campaign, stillValid) {
63040
63052
  return ui.presentCampaign(campaign, { customAttributes: {} });
63041
63053
  }
63042
63054
 
63055
+ let refreshHost = null;
63056
+ /** Called by `NamiRefs` when it is constructed. */
63057
+ const registerJourneyRefreshHost = (host) => {
63058
+ refreshHost = host;
63059
+ };
63043
63060
  const monotonicNow = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
63044
63061
  const liveJourneyEnvironment = {
63045
63062
  now: () => new Date(),
@@ -63054,14 +63071,21 @@ const liveJourneyEnvironment = {
63054
63071
  get: () => storageService.getJourneyInstallEvaluated(),
63055
63072
  set: () => storageService.setJourneyInstallEvaluated(),
63056
63073
  },
63074
+ refresh: () => refreshHost?.fetchCampaignsAndPaywalls() ?? Promise.resolve(),
63075
+ refreshAllowed: () => !(refreshHost?.isStartupInFlight() ?? false),
63076
+ // Only while the platform has journey rules: an org with none sees no refresh traffic, and picks
63077
+ // up newly added journeys on the next session's startup fetch. `journey_settings` does not
63078
+ // count: the backend sends it in every platform config, journeys or not.
63079
+ refreshEnabled: () => (refreshHost?.canRefreshJourneys() ?? false) && journeyCampaigns().length > 0,
63057
63080
  };
63058
- const journeyIdOf = (rule) => parseJourneyMetadata(rule.journey)?.journey_id;
63081
+ const journeyIdOf = (rule) => journeyMetadataOf(rule)?.journey_id;
63059
63082
  /**
63060
63083
  * Drives journey delivery for the `app_install` and `session_start` triggers (NAM-3233):
63061
- * evaluation, the single delay slot, the fire-time re-check, delivery and local impression
63062
- * accounting. A port of Apple's `JourneyCoordinator` (NAM-3234) minus campaign-rule refresh,
63063
- * which core gains in NAM-3236. JS is single-threaded, so there is no queue: the reset
63064
- * generation alone keeps work from before a `Nami.reset()` off the fresh state.
63084
+ * evaluation, the single delay slot, the fire-time re-check, delivery, local impression
63085
+ * accounting, and campaign-rule refresh coalescing. A port of Apple's `JourneyCoordinator`
63086
+ * (NAM-3234), plus goal recording (NAM-3236), which Apple gains in NAM-3237. JS is
63087
+ * single-threaded, so there is no queue: the reset generation alone keeps work from before a
63088
+ * `Nami.reset()` off the fresh state.
63065
63089
  *
63066
63090
  * Guards on the way to a raise:
63067
63091
  * - A winner chosen while the app is hidden starts its delay paused, a 0-second delay included,
@@ -63078,6 +63102,11 @@ class JourneyCoordinator {
63078
63102
  this.engine = new JourneyEngine();
63079
63103
  this.resetGeneration = 0;
63080
63104
  this.backgroundedAt = null;
63105
+ this.refreshInFlight = false;
63106
+ this.refreshPending = false;
63107
+ /** A purchase asked for a refresh while a startup pass was running; flushed once when it ends. */
63108
+ this.refreshDeferred = false;
63109
+ this.refreshTimer = null;
63081
63110
  this.delay = new JourneyDelayTimer(env.monotonicNow);
63082
63111
  }
63083
63112
  /**
@@ -63107,14 +63136,17 @@ class JourneyCoordinator {
63107
63136
  }
63108
63137
  journeyStateStore.startSession();
63109
63138
  this.evaluate(isInstallCandidate);
63139
+ this.startRefreshTimer();
63110
63140
  }
63111
- /** Pauses the delay, keeping the remainder, and starts measuring the session gap. */
63141
+ /** Pauses the delay, keeping the remainder, stops the refresh timer and starts measuring the session gap. */
63112
63142
  appDidEnterBackground() {
63113
63143
  this.delay.pause();
63144
+ this.stopRefreshTimer();
63114
63145
  this.backgroundedAt ??= this.env.monotonicNow();
63115
63146
  }
63116
63147
  /**
63117
- * Resumes the delay with the remaining time, including one held since a hidden start.
63148
+ * Resumes the delay with the remaining time, including one held since a hidden start, and
63149
+ * restarts the refresh timer.
63118
63150
  *
63119
63151
  * @returns whether this return begins a new session: it came at least {@link SESSION_GAP_MS}
63120
63152
  * after the background (the session boundary Apple and Android share). A foreground with no
@@ -63124,17 +63156,152 @@ class JourneyCoordinator {
63124
63156
  const hiddenAt = this.backgroundedAt;
63125
63157
  this.backgroundedAt = null;
63126
63158
  this.delay.resume();
63159
+ this.startRefreshTimer();
63127
63160
  return hiddenAt !== null && this.env.monotonicNow() - hiddenAt >= SESSION_GAP_MS;
63128
63161
  }
63129
63162
  /** A placement campaign is about to be raised: a journey still waiting must not fire on top of it. */
63130
63163
  placementWillRaise() {
63131
63164
  this.delay.cancel();
63132
63165
  }
63133
- /** Cancels the delay and invalidates in-flight work (`Nami.reset()`), before the storage wipe. */
63166
+ /**
63167
+ * Cancels the delay and the refresh timer, clears refresh state and invalidates in-flight work
63168
+ * (`Nami.reset()`), before the storage wipe.
63169
+ */
63134
63170
  reset() {
63135
63171
  this.delay.cancel();
63172
+ this.stopRefreshTimer();
63136
63173
  this.backgroundedAt = null;
63137
63174
  this.resetGeneration += 1;
63175
+ this.refreshInFlight = false;
63176
+ this.refreshPending = false;
63177
+ this.refreshDeferred = false;
63178
+ }
63179
+ // Goals (NAM-3236)
63180
+ /**
63181
+ * A flow's completion step matched its condition. For a journey whose goal is
63182
+ * `flow_completed`, that is the goal; the completion PATCH that reports it is the flow's own.
63183
+ *
63184
+ * @returns whether a goal was recorded.
63185
+ */
63186
+ flowCompletionSignaled(campaign) {
63187
+ const journey = campaign ? journeyMetadataOf(campaign) : null;
63188
+ if (journey?.goal?.type !== "flow_completed" || !this.goalReached(journey))
63189
+ return false;
63190
+ this.requestRefresh();
63191
+ return true;
63192
+ }
63193
+ /**
63194
+ * A customer attribute was set. Records the goal of every journey whose `user_context_set` goal
63195
+ * watches `key`, then refreshes the rules when the value changed (journey eligibility can depend
63196
+ * on attributes) or a goal was recorded (it can unlock the next journey).
63197
+ */
63198
+ customerAttributeSet(key, changed) {
63199
+ let goal = false;
63200
+ for (const rule of this.env.journeys()) {
63201
+ const journey = journeyMetadataOf(rule);
63202
+ if (journey?.goal?.type === "user_context_set" && journey.goal.context_key === key) {
63203
+ goal = this.goalReached(journey) || goal;
63204
+ }
63205
+ }
63206
+ if (changed || goal)
63207
+ this.requestRefresh();
63208
+ }
63209
+ /**
63210
+ * Records the goal and exits the journey: `goalAchievedAt` is stamped and entry cleared, so the
63211
+ * engine rejects it (`goalAchieved`) unless it allows re-entry. Counts only while the journey is
63212
+ * entered and inside its `timeout_days` window; anything else (never shown, already achieved,
63213
+ * timed out) is ignored.
63214
+ */
63215
+ goalReached(journey) {
63216
+ const id = journey.journey_id;
63217
+ const now = this.env.now();
63218
+ if (!this.engine.isGoalOpen(journeyStateStore.snapshot().journeys[id], journey, now)) {
63219
+ logger.debug(`Journey ${id} goal ignored: not entered, already achieved or timed out`);
63220
+ return false;
63221
+ }
63222
+ journeyStateStore.update((s) => this.engine.recordGoal(s, id, now));
63223
+ logger.debug(`Journey ${id} goal achieved`);
63224
+ return true;
63225
+ }
63226
+ // Refresh
63227
+ /**
63228
+ * Re-fetches campaign rules. At most one fetch is in flight, plus one trailing fetch if more
63229
+ * requests arrive meanwhile. Never evaluates journeys by itself: a newly eligible journey fires on
63230
+ * its own next trigger.
63231
+ *
63232
+ * While a startup pass runs the request is dropped, because the pass fetches the rules itself.
63233
+ * A purchase passes `deferIfBusy` so it is not lost: it refreshes once when the pass ends.
63234
+ */
63235
+ requestRefresh(deferIfBusy = false) {
63236
+ if (!this.env.refreshEnabled())
63237
+ return;
63238
+ if (this.refreshInFlight) {
63239
+ this.refreshPending = true;
63240
+ }
63241
+ else if (this.env.refreshAllowed()) {
63242
+ this.startRefresh();
63243
+ }
63244
+ else if (deferIfBusy) {
63245
+ this.refreshDeferred = true;
63246
+ }
63247
+ }
63248
+ /** A startup or resume pass ended, either way. Flushes a refresh a purchase deferred during it. */
63249
+ startupPassFinished() {
63250
+ if (!this.refreshDeferred)
63251
+ return;
63252
+ this.refreshDeferred = false;
63253
+ if (!this.env.refreshEnabled())
63254
+ return;
63255
+ if (this.refreshInFlight) {
63256
+ this.refreshPending = true;
63257
+ }
63258
+ else {
63259
+ this.startRefresh();
63260
+ }
63261
+ }
63262
+ startRefresh() {
63263
+ this.refreshInFlight = true;
63264
+ const generation = this.resetGeneration;
63265
+ const settle = () => {
63266
+ if (this.resetGeneration !== generation)
63267
+ return;
63268
+ this.refreshInFlight = false;
63269
+ if (this.refreshPending) {
63270
+ this.refreshPending = false;
63271
+ this.requestRefresh(true);
63272
+ }
63273
+ };
63274
+ let fetch;
63275
+ try {
63276
+ fetch = this.env.refresh();
63277
+ }
63278
+ catch (error) {
63279
+ fetch = Promise.reject(error);
63280
+ }
63281
+ fetch.then(settle, (error) => {
63282
+ logger.warn(`Journey refresh failed: ${error}`);
63283
+ settle();
63284
+ });
63285
+ }
63286
+ /**
63287
+ * Starts the repeating `refresh_interval_seconds` timer, but only while the app is visible: it
63288
+ * never runs in the background. Unreferenced in Node, so it never keeps a CLI process alive.
63289
+ */
63290
+ startRefreshTimer() {
63291
+ if (this.refreshTimer !== null || !this.env.isAppVisible())
63292
+ return;
63293
+ const ms = this.env.settings().refresh_interval_seconds * 1000;
63294
+ if (!(ms > 0))
63295
+ return;
63296
+ const timer = setInterval(() => this.requestRefresh(), ms);
63297
+ timer.unref?.();
63298
+ this.refreshTimer = timer;
63299
+ }
63300
+ stopRefreshTimer() {
63301
+ if (this.refreshTimer === null)
63302
+ return;
63303
+ clearInterval(this.refreshTimer);
63304
+ this.refreshTimer = null;
63138
63305
  }
63139
63306
  // Evaluation
63140
63307
  evaluate(isInstallCandidate) {
@@ -63237,6 +63404,15 @@ class NamiRefs {
63237
63404
  this.startupFetch = Promise.resolve();
63238
63405
  // Register this instance to avoid circular dependency issues
63239
63406
  setNamiRefsInstance(this);
63407
+ registerJourneyRefreshHost(this);
63408
+ }
63409
+ /** Whether a startup or resume pass is running; journey refresh waits for it (NAM-3236). */
63410
+ isStartupInFlight() {
63411
+ return this.startupInFlight;
63412
+ }
63413
+ /** Whether configure has run with a real app platform id and startup is not disabled. */
63414
+ canRefreshJourneys() {
63415
+ return !!this.lastConfig?.appPlatformID?.trim() && !this.startupDisabled;
63240
63416
  }
63241
63417
  setInMemoryAnonymousMode(enabled) {
63242
63418
  this.inMemoryAnonymousMode = enabled;
@@ -63285,6 +63461,7 @@ class NamiRefs {
63285
63461
  }
63286
63462
  finally {
63287
63463
  this.startupInFlight = false;
63464
+ JourneyCoordinator.shared.startupPassFinished();
63288
63465
  }
63289
63466
  }
63290
63467
  /**
@@ -63347,6 +63524,7 @@ class NamiRefs {
63347
63524
  }
63348
63525
  finally {
63349
63526
  this.startupInFlight = false;
63527
+ JourneyCoordinator.shared.startupPassFinished();
63350
63528
  }
63351
63529
  }
63352
63530
  /** Everything startup runs once the platform-config step has found a usable config. */
@@ -64994,6 +65172,15 @@ let NamiPaywallManager$2 = class NamiPaywallManager {
64994
65172
  * @returns {Promise<void>} A Promise that resolves when buying SKU will be complete.
64995
65173
  */
64996
65174
  static async buySkuComplete(purchase) {
65175
+ try {
65176
+ await this.reportPurchaseComplete(purchase);
65177
+ }
65178
+ finally {
65179
+ // A host-managed purchase changes journey eligibility too: re-fetch the rules (NAM-3236).
65180
+ JourneyCoordinator.shared.requestRefresh(true);
65181
+ }
65182
+ }
65183
+ static async reportPurchaseComplete(purchase) {
64997
65184
  // clear loading indicator
64998
65185
  PaywallState.setPurchaseInProgress(false);
64999
65186
  if (NamiFlowManager$2.instance.flowOpen) {
@@ -65918,6 +66105,7 @@ let NamiCustomerManager$2 = class NamiCustomerManager {
65918
66105
  if (!this.instance.isSDKInitialized) {
65919
66106
  throw new SDKNotInitializedError();
65920
66107
  }
66108
+ const changed = storageService.getCustomerAttribute(key) !== value;
65921
66109
  storageService.setCustomerAttribute(key, value);
65922
66110
  if (!hasCapability(Capabilities.PAYWALL_PERSONALIZATION)) {
65923
66111
  logger.warn(`The customer attribute ${key} is not available for using on a paywall, because your account's plan does not include paywall personalization. Contact support@nami.ml for details.`);
@@ -65929,12 +66117,19 @@ let NamiCustomerManager$2 = class NamiCustomerManager {
65929
66117
  if (NamiFlowManager$2.instance.flowOpen) {
65930
66118
  const currentFlow = NamiFlowManager$2.instance.currentFlow;
65931
66119
  if (currentFlow) {
66120
+ // An open journey flow whose `user_context_set` goal watches this key reports the goal
66121
+ // through its completion step (NAM-3236), before the lifecycle can move it off the page.
66122
+ const goal = currentFlow.campaign ? journeyMetadataOf(currentFlow.campaign)?.goal : null;
66123
+ if (goal?.type === "user_context_set" && goal.context_key === key) {
66124
+ currentFlow.completionSignal("context_set");
66125
+ }
65932
66126
  const currentStep = currentFlow.currentFlowStep;
65933
66127
  if (currentStep) {
65934
66128
  currentFlow.executeLifecycle(currentStep, NamiReservedActions.TAG_UPDATE);
65935
66129
  }
65936
66130
  }
65937
66131
  }
66132
+ JourneyCoordinator.shared.customerAttributeSet(key, changed);
65938
66133
  }
65939
66134
  /**
65940
66135
  * Retrieves the value for a given key in the on-device customer attribute key/value store.
@@ -66396,6 +66591,9 @@ class NamiFlow extends BasicNamiFlow {
66396
66591
  }
66397
66592
  if (ctx.resultSent)
66398
66593
  return; // R4: one completion per impression
66594
+ // A journey's `flow_completed` goal is met here, whether or not an impression is bound yet
66595
+ // (anonymous mode never binds one); the PATCH below is what reports it (NAM-3236).
66596
+ JourneyCoordinator.shared.flowCompletionSignaled(this.campaign);
66399
66597
  if (!ctx.impressionId) {
66400
66598
  if (!ctx.pending.includes(condition))
66401
66599
  ctx.pending.push(condition);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@namiml/sdk-core",
3
- "version": "3.5.0-dev.202611021820",
3
+ "version": "3.5.0-dev.202611022057",
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",