@funnelsgrove/runtime 0.5.0 → 0.7.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.
@@ -10,6 +10,7 @@ type FunnelRuntimeConfigBoundaryProps = {
10
10
  funnelVersionId?: string;
11
11
  children: ReactNode;
12
12
  loadingFallback?: ReactNode;
13
+ errorFallback?: ReactNode;
13
14
  disabled?: boolean;
14
15
  };
15
16
  export declare function FunnelRuntimeConfigBoundary({ disabled, ...props }: FunnelRuntimeConfigBoundaryProps): import("react/jsx-runtime").JSX.Element;
@@ -24,10 +24,10 @@ const toError = (value) => (value instanceof Error ? value : new Error('Unable t
24
24
  * snapshot is deeply frozen and stays fixed for the lifetime of this boundary,
25
25
  * so one visitor can never switch experiments or prices mid-session.
26
26
  *
27
- * Missing, invalid, or temporarily unavailable publications fail open to the
28
- * generated build-time config by exposing `config: null`.
27
+ * A published funnel never falls back to generated pricing or experiment
28
+ * config: the version-bound artifact is its single source of truth.
29
29
  */
30
- function ActiveFunnelRuntimeConfigBoundary({ funnelId, funnelVersionId, children, loadingFallback = null, }) {
30
+ function ActiveFunnelRuntimeConfigBoundary({ funnelId, funnelVersionId, children, loadingFallback = null, errorFallback = null, }) {
31
31
  const [state, setState] = useState({
32
32
  status: 'loading',
33
33
  config: null,
@@ -35,7 +35,7 @@ function ActiveFunnelRuntimeConfigBoundary({ funnelId, funnelVersionId, children
35
35
  });
36
36
  useEffect(() => {
37
37
  let active = true;
38
- loadFunnelRuntimeConfig(funnelId)
38
+ loadFunnelRuntimeConfig(funnelId, { versionId: funnelVersionId })
39
39
  .then((published) => resolvePublishedFunnelRuntimeConfig(published, {
40
40
  expectedFunnelVersionId: funnelVersionId,
41
41
  }))
@@ -57,6 +57,9 @@ function ActiveFunnelRuntimeConfigBoundary({ funnelId, funnelVersionId, children
57
57
  if (state.status === 'loading') {
58
58
  return loadingFallback;
59
59
  }
60
+ if (state.status === 'fallback') {
61
+ return errorFallback;
62
+ }
60
63
  return (_jsx(FunnelRuntimeConfigContext.Provider, { value: contextValue, children: children }));
61
64
  }
62
65
  export function FunnelRuntimeConfigBoundary(_a) {
@@ -15,7 +15,7 @@ type FunnelRouteExperimentVariantDefinition = {
15
15
  };
16
16
  type FunnelPricingExperimentVariantDefinition = {
17
17
  variantKey?: string;
18
- offerSetKey: string;
18
+ offerSetId: string;
19
19
  label?: string;
20
20
  trafficPercent: number;
21
21
  };
@@ -101,6 +101,8 @@ export type OfferSetRuntimePlanOverrides = {
101
101
  };
102
102
  export type OfferSetRuntimeInput<TPlan extends Record<string, unknown>> = {
103
103
  offerSets: readonly GeneratedOfferSet[];
104
+ offerSetId?: string;
105
+ /** @deprecated Prefer the immutable offerSetId. */
104
106
  offerSetKey?: string;
105
107
  baseCatalogsByMode?: Partial<Record<RuntimeMode, Record<string, TPlan>>>;
106
108
  fallbackDiscounts?: readonly OfferSetRuntimeDiscount[];
@@ -116,7 +118,7 @@ export type OfferSetRuntimeResult<TPlan extends Record<string, unknown>> = {
116
118
  getPlanCatalog: (mode: RuntimeMode) => Record<string, TPlan & OfferSetRuntimePlanOverrides>;
117
119
  getDiscountList: (mode: RuntimeMode) => readonly OfferSetRuntimeDiscount[];
118
120
  };
119
- export declare const findGeneratedOfferSet: (offerSets: readonly GeneratedOfferSet[], offerSetKey?: string) => GeneratedOfferSet | null;
121
+ export declare const findGeneratedOfferSet: (offerSets: readonly GeneratedOfferSet[], offerSetKey?: string, offerSetId?: string) => GeneratedOfferSet | null;
120
122
  export declare const applyGeneratedOfferSetToPlanCatalog: <TPlan extends Record<string, unknown>>(baseCatalog: Record<string, TPlan>, mode: RuntimeMode, offerSet: GeneratedOfferSet | null) => Record<string, TPlan & OfferSetRuntimePlanOverrides>;
121
123
  export declare const buildGeneratedOfferSetDiscountList: (offerSet: GeneratedOfferSet | null, mode: RuntimeMode, fallbackDiscounts?: readonly OfferSetRuntimeDiscount[]) => readonly OfferSetRuntimeDiscount[];
122
124
  export declare const resolveGeneratedOfferSetRuntime: <TPlan extends Record<string, unknown> = Record<string, unknown>>(input: OfferSetRuntimeInput<TPlan>) => OfferSetRuntimeResult<TPlan>;
@@ -58,10 +58,10 @@ const getSortedItemsByKind = (offerSet, kind) => {
58
58
  return leftPosition - rightPosition;
59
59
  })) !== null && _a !== void 0 ? _a : []);
60
60
  };
61
- export const findGeneratedOfferSet = (offerSets, offerSetKey) => {
62
- var _a, _b;
61
+ export const findGeneratedOfferSet = (offerSets, offerSetKey, offerSetId) => {
62
+ var _a, _b, _c;
63
63
  const activeOfferSets = offerSets.filter((offerSet) => offerSet.isActive !== false);
64
- return ((_b = (_a = activeOfferSets.find((offerSet) => offerSet.key === offerSetKey)) !== null && _a !== void 0 ? _a : activeOfferSets[0]) !== null && _b !== void 0 ? _b : null);
64
+ return ((_c = (_b = (_a = activeOfferSets.find((offerSet) => offerSet.id === offerSetId)) !== null && _a !== void 0 ? _a : activeOfferSets.find((offerSet) => offerSet.key === offerSetKey)) !== null && _b !== void 0 ? _b : activeOfferSets[0]) !== null && _c !== void 0 ? _c : null);
65
65
  };
66
66
  const resolveCatalogKey = (baseCatalog, funnelPlanKey) => {
67
67
  var _a;
@@ -248,7 +248,7 @@ export const buildGeneratedOfferSetDiscountList = (offerSet, mode, fallbackDisco
248
248
  };
249
249
  export const resolveGeneratedOfferSetRuntime = (input) => {
250
250
  var _a, _b, _c, _d, _e, _f;
251
- const offerSet = findGeneratedOfferSet(input.offerSets, input.offerSetKey);
251
+ const offerSet = findGeneratedOfferSet(input.offerSets, input.offerSetKey, input.offerSetId);
252
252
  const baseCatalogsByMode = {
253
253
  test: (_b = (_a = input.baseCatalogsByMode) === null || _a === void 0 ? void 0 : _a.test) !== null && _b !== void 0 ? _b : {},
254
254
  live: (_d = (_c = input.baseCatalogsByMode) === null || _c === void 0 ? void 0 : _c.live) !== null && _d !== void 0 ? _d : {},
@@ -4,7 +4,8 @@ export type FunnelRuntimeConfig = {
4
4
  schemaVersion: number;
5
5
  funnelId: string;
6
6
  projectId: string;
7
- plans: unknown[];
7
+ /** Present only on historical schema-v1 artifacts. */
8
+ plans?: unknown[];
8
9
  offerSets: unknown[];
9
10
  experiments: unknown[];
10
11
  };
@@ -31,6 +32,7 @@ export declare const resolvePublishedFunnelRuntimeConfig: (published: PublishedF
31
32
  export declare const loadFunnelRuntimeConfig: (funnelId: string, options?: {
32
33
  fetcher?: RuntimeConfigFetcher;
33
34
  basePath?: string;
35
+ versionId?: string;
34
36
  }) => Promise<PublishedFunnelRuntimeConfig>;
35
37
  export declare const clearFunnelRuntimeConfigInflight: () => void;
36
38
  export {};
@@ -25,38 +25,39 @@ const deepFreeze = (value) => {
25
25
  }
26
26
  return value;
27
27
  };
28
- const getPlanById = (plans, projectBillingPlanId) => {
28
+ const getLegacyPlanById = (plans, projectBillingPlanId) => {
29
29
  var _a;
30
30
  return (projectBillingPlanId
31
31
  ? (_a = plans.find((plan) => pickString(plan, 'id') === projectBillingPlanId)) !== null && _a !== void 0 ? _a : null
32
32
  : null);
33
33
  };
34
- const getProviderMapping = (plan, mode) => {
34
+ const getLegacyProviderMapping = (plan, mode) => {
35
35
  var _a;
36
36
  return ((_a = readRecordArray(plan === null || plan === void 0 ? void 0 : plan.providerMappings)
37
37
  .find((mapping) => (pickString(mapping, 'environment') === mode
38
38
  && pickBoolean(mapping, 'isActive', 'is_active') !== false))) !== null && _a !== void 0 ? _a : null);
39
39
  };
40
- const buildPlanModeConfig = (item, plan, mode) => {
40
+ const buildPlanModeConfig = (item, mode, legacyPlan = null) => {
41
41
  var _a, _b;
42
42
  const metadata = isRecord(item.metadata) ? item.metadata : {};
43
43
  const metadataMappings = isRecord(metadata.providerMappingsByMode)
44
44
  ? metadata.providerMappingsByMode
45
45
  : {};
46
46
  const metadataMapping = isRecord(metadataMappings[mode]) ? metadataMappings[mode] : {};
47
- const planMapping = getProviderMapping(plan, mode);
47
+ const legacyMapping = getLegacyProviderMapping(legacyPlan, mode);
48
48
  const prefix = mode === 'test' ? 'test' : 'live';
49
49
  const providerPlanId = (pickString(item, `${prefix}ProviderPlanId`, `${prefix}_provider_plan_id`)
50
50
  || pickString(metadataMapping, 'providerPlanId')
51
- || (planMapping ? pickString(planMapping, 'providerPlanId', 'provider_plan_id') : null));
51
+ || (legacyMapping ? pickString(legacyMapping, 'providerPlanId', 'provider_plan_id') : null));
52
52
  if (!providerPlanId) {
53
53
  return null;
54
54
  }
55
- const amountMinor = (_a = pickNumber(metadataMapping, 'amountCents')) !== null && _a !== void 0 ? _a : (planMapping ? pickNumber(planMapping, 'amountMinor', 'amount_minor') : null);
55
+ const amountMinor = (_a = pickNumber(metadataMapping, 'amountCents')) !== null && _a !== void 0 ? _a : (legacyMapping ? pickNumber(legacyMapping, 'amountMinor', 'amount_minor') : null);
56
56
  const interval = pickString(metadataMapping, 'billingInterval')
57
- || (planMapping ? pickString(planMapping, 'interval') : null);
58
- const intervalCount = (_b = pickNumber(metadataMapping, 'billingIntervalCount')) !== null && _b !== void 0 ? _b : (planMapping ? pickNumber(planMapping, 'intervalCount', 'interval_count') : null);
59
- const currency = planMapping ? pickString(planMapping, 'currency') : null;
57
+ || (legacyMapping ? pickString(legacyMapping, 'interval') : null);
58
+ const intervalCount = (_b = pickNumber(metadataMapping, 'billingIntervalCount')) !== null && _b !== void 0 ? _b : (legacyMapping ? pickNumber(legacyMapping, 'intervalCount', 'interval_count') : null);
59
+ const currency = pickString(metadataMapping, 'currency')
60
+ || (legacyMapping ? pickString(legacyMapping, 'currency') : null);
60
61
  const amountMajor = amountMinor === null ? null : amountMinor / 100;
61
62
  const priceLabel = pickString(metadataMapping, 'priceLabel') || (amountMajor === null
62
63
  ? null
@@ -82,7 +83,7 @@ const buildPlanModeConfig = (item, plan, mode) => {
82
83
  }).format(amountMajor)} today` })), (interval ? { billingInterval: interval } : {})), (intervalCount === null ? {} : { billingIntervalCount: intervalCount }));
83
84
  };
84
85
  const normalizePublishedOfferSets = (config) => {
85
- const plans = readRecordArray(config.plans);
86
+ const legacyPlans = readRecordArray(config.plans);
86
87
  return readRecordArray(config.offerSets).map((offerSet) => {
87
88
  var _a;
88
89
  const key = pickString(offerSet, 'key');
@@ -97,15 +98,15 @@ const normalizePublishedOfferSets = (config) => {
97
98
  throw new Error(`Published runtime offer set "${key}" has an item without a plan key`);
98
99
  }
99
100
  const projectBillingPlanId = pickString(item, 'projectBillingPlanId', 'project_billing_plan_id');
100
- const plan = getPlanById(plans, projectBillingPlanId);
101
- const testMapping = buildPlanModeConfig(item, plan, 'test');
102
- const liveMapping = buildPlanModeConfig(item, plan, 'live');
101
+ const legacyPlan = getLegacyPlanById(legacyPlans, projectBillingPlanId);
102
+ const testMapping = buildPlanModeConfig(item, 'test', legacyPlan);
103
+ const liveMapping = buildPlanModeConfig(item, 'live', legacyPlan);
103
104
  const title = pickString(item, 'title', 'displayNameOverride', 'display_name_override')
104
105
  || pickString(metadata, 'title', 'publicPlanName', 'public_plan_name')
105
- || (plan ? pickString(plan, 'displayName', 'display_name') : null);
106
+ || (legacyPlan ? pickString(legacyPlan, 'displayName', 'display_name') : null);
106
107
  const description = pickString(item, 'description')
107
108
  || pickString(metadata, 'description')
108
- || (plan ? pickString(plan, 'description') : null);
109
+ || (legacyPlan ? pickString(legacyPlan, 'description') : null);
109
110
  return Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ funnelPlanKey }, (pickString(metadata, 'runtimePlanKey', 'planKey', 'billingPlanKey')
110
111
  ? { runtimePlanKey: pickString(metadata, 'runtimePlanKey', 'planKey', 'billingPlanKey') }
111
112
  : {})), (projectBillingPlanId ? { projectBillingPlanId } : {})), (pickString(item, 'testProviderPlanId', 'test_provider_plan_id')
@@ -135,7 +136,7 @@ const normalizePublishedOfferSets = (config) => {
135
136
  return Object.assign(Object.assign({}, (pickString(offerSet, 'id') ? { id: pickString(offerSet, 'id') } : {})), { key, isActive: (_a = pickBoolean(offerSet, 'isActive', 'is_active')) !== null && _a !== void 0 ? _a : true, metadata: isRecord(offerSet.metadata) ? offerSet.metadata : {}, items });
136
137
  });
137
138
  };
138
- const normalizePublishedExperiments = (config, publishedAt) => readRecordArray(config.experiments).map((experiment) => {
139
+ const normalizePublishedExperiments = (config, publishedAt, offerSets) => readRecordArray(config.experiments).map((experiment) => {
139
140
  const conversionConfig = isRecord(experiment.conversionConfig)
140
141
  ? experiment.conversionConfig
141
142
  : isRecord(experiment.conversion_config)
@@ -171,16 +172,26 @@ const normalizePublishedExperiments = (config, publishedAt) => readRecordArray(c
171
172
  || pickString(experiment, 'startedAt', 'started_at')
172
173
  || publishedAt).slice(0, 10);
173
174
  if (type === 'pricing') {
174
- const configuredKeys = Array.isArray(conversionConfig.offerSetKeys)
175
- ? conversionConfig.offerSetKeys.map(readString).filter((value) => Boolean(value))
175
+ const directIds = normalizedVariants.map((variant, index) => (pickString(variants[index] || {}, 'offerSetId', 'offer_set_id')));
176
+ const legacyKeys = Array.isArray(conversionConfig.offerSetKeys)
177
+ ? conversionConfig.offerSetKeys.map(readString)
176
178
  : [
177
179
  pickString(conversionConfig, 'controlOfferSetKey'),
178
180
  pickString(conversionConfig, 'variantOfferSetKey'),
179
- ].filter((value) => Boolean(value));
180
- if (configuredKeys.length !== normalizedVariants.length) {
181
+ ];
182
+ const configuredIds = directIds.map((offerSetId, index) => {
183
+ var _a, _b;
184
+ if (offerSetId)
185
+ return offerSetId;
186
+ const legacyKey = legacyKeys[index];
187
+ return legacyKey
188
+ ? (_b = (_a = offerSets.find((offerSet) => offerSet.key === legacyKey)) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : null
189
+ : null;
190
+ });
191
+ if (configuredIds.some((offerSetId) => !offerSetId)) {
181
192
  throw new Error(`Published pricing experiment "${id}" does not map every variant to an offer set`);
182
193
  }
183
- const pricingVariants = normalizedVariants.map((variant, index) => (Object.assign(Object.assign({ variantKey: variant.variantKey }, (variant.label ? { label: variant.label } : {})), { trafficPercent: variant.trafficPercent, offerSetKey: configuredKeys[index] })));
194
+ const pricingVariants = normalizedVariants.map((variant, index) => (Object.assign(Object.assign({ variantKey: variant.variantKey }, (variant.label ? { label: variant.label } : {})), { trafficPercent: variant.trafficPercent, offerSetId: configuredIds[index] })));
184
195
  return {
185
196
  id,
186
197
  name,
@@ -214,13 +225,14 @@ export const resolvePublishedFunnelRuntimeConfig = (published, options = {}) =>
214
225
  && published.sourceVersionId !== options.expectedFunnelVersionId) {
215
226
  throw new Error('Published runtime config belongs to a different funnel version');
216
227
  }
228
+ const offerSets = normalizePublishedOfferSets(published.config);
217
229
  return deepFreeze({
218
230
  revisionId: published.revisionId,
219
231
  sourceDeploymentId: published.sourceDeploymentId,
220
232
  sourceVersionId: published.sourceVersionId,
221
233
  publishedAt: published.publishedAt,
222
- offerSets: normalizePublishedOfferSets(published.config),
223
- experiments: normalizePublishedExperiments(published.config, published.publishedAt),
234
+ offerSets,
235
+ experiments: normalizePublishedExperiments(published.config, published.publishedAt, offerSets),
224
236
  });
225
237
  };
226
238
  const parsePublishedRuntimeConfig = (value) => {
@@ -228,33 +240,40 @@ const parsePublishedRuntimeConfig = (value) => {
228
240
  throw new Error('Funnel runtime config response is invalid');
229
241
  }
230
242
  const config = value.config;
243
+ const schemaVersion = typeof config.schemaVersion === 'number' ? config.schemaVersion : null;
231
244
  if (typeof value.revisionId !== 'string'
232
245
  || typeof value.sourceDeploymentId !== 'string'
233
246
  || typeof value.sourceVersionId !== 'string'
234
247
  || typeof value.schemaVersion !== 'number'
235
248
  || typeof value.publishedAt !== 'string'
236
- || typeof config.schemaVersion !== 'number'
249
+ || (schemaVersion !== 1 && schemaVersion !== 2)
237
250
  || typeof config.funnelId !== 'string'
238
251
  || typeof config.projectId !== 'string'
239
- || !Array.isArray(config.plans)
240
252
  || !Array.isArray(config.offerSets)
241
- || !Array.isArray(config.experiments)) {
253
+ || !Array.isArray(config.experiments)
254
+ || (schemaVersion === 1 && !Array.isArray(config.plans))) {
242
255
  throw new Error('Funnel runtime config response is invalid');
243
256
  }
244
257
  return value;
245
258
  };
246
259
  export const loadFunnelRuntimeConfig = (funnelId, options = {}) => {
260
+ var _a;
247
261
  const normalizedFunnelId = funnelId.trim();
248
262
  if (!normalizedFunnelId) {
249
263
  return Promise.reject(new Error('funnelId is required'));
250
264
  }
251
- const cachedInflight = inflightByFunnelId.get(normalizedFunnelId);
265
+ const versionId = ((_a = options.versionId) === null || _a === void 0 ? void 0 : _a.trim()) || '';
266
+ if (!versionId) {
267
+ return Promise.reject(new Error('versionId is required'));
268
+ }
269
+ const cacheKey = `${normalizedFunnelId}:${versionId}`;
270
+ const cachedInflight = inflightByFunnelId.get(cacheKey);
252
271
  if (cachedInflight) {
253
272
  return cachedInflight;
254
273
  }
255
274
  const fetcher = options.fetcher || globalThis.fetch;
256
275
  const basePath = (options.basePath || '/api/funnel-config').replace(/\/$/, '');
257
- const request = fetcher(`${basePath}/${encodeURIComponent(normalizedFunnelId)}`, {
276
+ const request = fetcher(`${basePath}/${encodeURIComponent(normalizedFunnelId)}?versionId=${encodeURIComponent(versionId)}`, {
258
277
  headers: { accept: 'application/json' },
259
278
  }).then(async (response) => {
260
279
  if (!response.ok) {
@@ -266,9 +285,9 @@ export const loadFunnelRuntimeConfig = (funnelId, options = {}) => {
266
285
  }
267
286
  return published;
268
287
  }).finally(() => {
269
- inflightByFunnelId.delete(normalizedFunnelId);
288
+ inflightByFunnelId.delete(cacheKey);
270
289
  });
271
- inflightByFunnelId.set(normalizedFunnelId, request);
290
+ inflightByFunnelId.set(cacheKey, request);
272
291
  return request;
273
292
  };
274
293
  export const clearFunnelRuntimeConfigInflight = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/The-Solid-Grove/funnelsgrove.git",