@reactionary/core 0.11.2 → 0.12.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.
Files changed (60) hide show
  1. package/analytics/context.js +173 -0
  2. package/analytics/dispatcher.js +181 -0
  3. package/analytics/index.js +6 -0
  4. package/analytics/legacy-adapter.js +48 -0
  5. package/analytics/origins.js +64 -0
  6. package/analytics/recording-subscriber.js +26 -0
  7. package/analytics/refs.js +14 -0
  8. package/capabilities/analytics.capability.js +265 -84
  9. package/capabilities/analytics.provider.js +13 -0
  10. package/capabilities/index.js +1 -0
  11. package/client/client-builder.js +45 -16
  12. package/index.js +1 -0
  13. package/package.json +1 -1
  14. package/schemas/models/analytics/consent.model.js +10 -0
  15. package/schemas/models/analytics/dispatch.model.js +15 -0
  16. package/schemas/models/analytics/envelope.model.js +38 -0
  17. package/schemas/models/analytics/event-parts.model.js +47 -0
  18. package/schemas/models/analytics/event.model.js +255 -0
  19. package/schemas/models/analytics/index.js +6 -0
  20. package/schemas/models/analytics/origin.model.js +113 -0
  21. package/schemas/models/analytics.model.js +0 -2
  22. package/schemas/models/index.js +1 -0
  23. package/schemas/mutations/analytics/events/account.mutation.js +66 -0
  24. package/schemas/mutations/analytics/events/base.mutation.js +10 -0
  25. package/schemas/mutations/analytics/events/browse.mutation.js +53 -0
  26. package/schemas/mutations/analytics/events/commerce.mutation.js +79 -0
  27. package/schemas/mutations/analytics/events/index.js +85 -0
  28. package/schemas/mutations/analytics/index.js +1 -0
  29. package/schemas/session.schema.js +3 -1
  30. package/src/analytics/context.d.ts +85 -0
  31. package/src/analytics/dispatcher.d.ts +71 -0
  32. package/src/analytics/index.d.ts +6 -0
  33. package/src/analytics/legacy-adapter.d.ts +10 -0
  34. package/src/analytics/origins.d.ts +25 -0
  35. package/src/analytics/recording-subscriber.d.ts +18 -0
  36. package/src/analytics/refs.d.ts +8 -0
  37. package/src/capabilities/analytics.capability.d.ts +64 -17
  38. package/src/capabilities/analytics.provider.d.ts +24 -0
  39. package/src/capabilities/index.d.ts +1 -0
  40. package/src/client/client-builder.d.ts +25 -2
  41. package/src/index.d.ts +1 -0
  42. package/src/schemas/capabilities.schema.d.ts +8 -1
  43. package/src/schemas/models/analytics/consent.model.d.ts +13 -0
  44. package/src/schemas/models/analytics/dispatch.model.d.ts +29 -0
  45. package/src/schemas/models/analytics/envelope.model.d.ts +565 -0
  46. package/src/schemas/models/analytics/event-parts.model.d.ts +248 -0
  47. package/src/schemas/models/analytics/event.model.d.ts +83182 -0
  48. package/src/schemas/models/analytics/index.d.ts +6 -0
  49. package/src/schemas/models/analytics/origin.model.d.ts +275 -0
  50. package/src/schemas/models/analytics.model.d.ts +1 -2
  51. package/src/schemas/models/index.d.ts +1 -0
  52. package/src/schemas/mutations/analytics/events/account.mutation.d.ts +1939 -0
  53. package/src/schemas/mutations/analytics/events/base.mutation.d.ts +121 -0
  54. package/src/schemas/mutations/analytics/events/browse.mutation.d.ts +1832 -0
  55. package/src/schemas/mutations/analytics/events/commerce.mutation.d.ts +30188 -0
  56. package/src/schemas/mutations/analytics/events/index.d.ts +33928 -0
  57. package/src/schemas/mutations/analytics/index.d.ts +1 -0
  58. package/src/schemas/mutations/checkout.mutation.d.ts +4 -4
  59. package/src/schemas/mutations/product-list.mutation.d.ts +2 -2
  60. package/src/schemas/session.schema.d.ts +8 -0
@@ -0,0 +1,173 @@
1
+ import { error, success } from "../schemas/result.js";
2
+ const DEFAULT_CONCURRENCY = 5;
3
+ function notConfigured(name) {
4
+ return error({ type: "Generic", message: `The ${name} capability is not configured, so analytics cannot look it up` });
5
+ }
6
+ function notFound(identifier) {
7
+ return error({ type: "NotFound", identifier });
8
+ }
9
+ function memoize(fn, keyOf) {
10
+ const cache = /* @__PURE__ */ new Map();
11
+ return (arg) => {
12
+ const key = keyOf(arg);
13
+ const hit = cache.get(key);
14
+ if (hit) {
15
+ return hit;
16
+ }
17
+ const pending = fn(arg);
18
+ cache.set(key, pending);
19
+ return pending;
20
+ };
21
+ }
22
+ function memoizeOnce(fn) {
23
+ let pending;
24
+ return () => pending ??= fn();
25
+ }
26
+ async function mapLimit(items, limit, fn) {
27
+ const results = new Array(items.length);
28
+ let next = 0;
29
+ const worker = async () => {
30
+ while (next < items.length) {
31
+ const index = next++;
32
+ results[index] = await fn(items[index]);
33
+ }
34
+ };
35
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
36
+ return results;
37
+ }
38
+ function createAnalyticsContext(dependencies) {
39
+ const { capabilities, requestContext } = dependencies;
40
+ const concurrency = Math.max(1, dependencies.concurrency ?? DEFAULT_CONCURRENCY);
41
+ const productById = memoize(
42
+ async (id) => capabilities.product ? capabilities.product.getById({ identifier: id }) : notConfigured("product"),
43
+ (id) => id.key
44
+ );
45
+ const productBySku = memoize(
46
+ async (variant) => capabilities.product ? capabilities.product.getBySKU({ variant }) : notConfigured("product"),
47
+ (variant) => variant.sku
48
+ );
49
+ const categoryById = memoize(
50
+ async (id) => capabilities.category ? capabilities.category.getById({ id }) : notConfigured("category"),
51
+ (id) => id.key
52
+ );
53
+ const categoryPath = memoize(
54
+ async (id) => capabilities.category ? capabilities.category.getBreadcrumbPathToCategory({ id }) : notConfigured("category"),
55
+ (id) => id.key
56
+ );
57
+ const pricesForSku = memoize(
58
+ async (variant) => {
59
+ if (!capabilities.price) {
60
+ return notConfigured("price");
61
+ }
62
+ const [customer, list] = await Promise.all([
63
+ capabilities.price.getCustomerPrice({ variant }),
64
+ capabilities.price.getListPrice({ variant })
65
+ ]);
66
+ if (!customer.success && !list.success) {
67
+ return customer;
68
+ }
69
+ return success({
70
+ customer: customer.success ? customer.value : void 0,
71
+ list: list.success ? list.value : void 0
72
+ });
73
+ },
74
+ (variant) => variant.sku
75
+ );
76
+ const storeById = memoize(
77
+ async (id) => capabilities.store ? capabilities.store.getById({ identifier: id }) : notConfigured("store"),
78
+ (id) => id.key
79
+ );
80
+ const inventoryForSku = memoize(
81
+ async (variant) => {
82
+ if (!capabilities.inventory) {
83
+ return notConfigured("inventory");
84
+ }
85
+ const store = await storeById(requestContext.storeIdentifier);
86
+ if (!store.success) {
87
+ return store;
88
+ }
89
+ return capabilities.inventory.getBySKU({ variant, fulfilmentCenter: store.value.fulfillmentCenter });
90
+ },
91
+ (variant) => variant.sku
92
+ );
93
+ const activeCart = memoizeOnce(
94
+ async () => {
95
+ if (!capabilities.cart) {
96
+ return notConfigured("cart");
97
+ }
98
+ const id = await capabilities.cart.getActiveCartId();
99
+ if (!id.success) {
100
+ return id;
101
+ }
102
+ return capabilities.cart.getById({ cart: id.value });
103
+ }
104
+ );
105
+ const orderById = memoize(
106
+ async (id) => capabilities.order ? capabilities.order.getById({ order: id }) : notConfigured("order"),
107
+ (id) => id.key
108
+ );
109
+ const selfIdentity = memoizeOnce(
110
+ async () => capabilities.identity ? capabilities.identity.getSelf({}) : notConfigured("identity")
111
+ );
112
+ const selfProfile = memoizeOnce(
113
+ async () => {
114
+ if (!capabilities.profile) {
115
+ return notConfigured("profile");
116
+ }
117
+ const identity = requestContext.session.identityContext.identity;
118
+ if (identity.type === "Anonymous") {
119
+ return notFound(identity);
120
+ }
121
+ return capabilities.profile.getById({ identifier: identity.id });
122
+ }
123
+ );
124
+ const selfPersonalization = memoizeOnce(
125
+ async () => {
126
+ if (!capabilities.personalizationProfile) {
127
+ return notConfigured("personalizationProfile");
128
+ }
129
+ const marketing = requestContext.session.marketingContext.identifier;
130
+ return capabilities.personalizationProfile.getPersonalizationProfile({
131
+ identity: requestContext.session.identityContext.identity,
132
+ personalizationProfileIdentifier: marketing.key ? marketing : void 0
133
+ });
134
+ }
135
+ );
136
+ const selfCompany = memoizeOnce(
137
+ async () => {
138
+ if (!capabilities.company) {
139
+ return notConfigured("company");
140
+ }
141
+ const cart = await activeCart();
142
+ if (!cart.success) {
143
+ return cart;
144
+ }
145
+ if (!cart.value.company) {
146
+ return notFound(cart.value.identifier);
147
+ }
148
+ return capabilities.company.getById({ identifier: cart.value.company });
149
+ }
150
+ );
151
+ return {
152
+ products: {
153
+ byId: productById,
154
+ bySku: productBySku,
155
+ many: (ids) => mapLimit(ids, concurrency, productById)
156
+ },
157
+ categories: { byId: categoryById, path: categoryPath },
158
+ prices: { forSku: pricesForSku },
159
+ inventory: { forSku: inventoryForSku },
160
+ cart: { active: () => activeCart() },
161
+ orders: { byId: orderById },
162
+ stores: { byId: storeById },
163
+ self: {
164
+ identity: () => selfIdentity(),
165
+ profile: () => selfProfile(),
166
+ personalization: () => selfPersonalization(),
167
+ company: () => selfCompany()
168
+ }
169
+ };
170
+ }
171
+ export {
172
+ createAnalyticsContext
173
+ };
@@ -0,0 +1,181 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { traceSpan } from "../decorators/reactionary.decorator.js";
3
+ import {
4
+ AnalyticsEventMutationSchemas,
5
+ AnalyticsEventSchema,
6
+ AnalyticsMutationConsentChangedSchema,
7
+ AnalyticsSubscriberDescriptorSchema
8
+ } from "../schemas/index.js";
9
+ import { error, success } from "../schemas/result.js";
10
+ const DEFAULT_CONSENT = { analytics: true, marketing: true };
11
+ const insideHandler = new AsyncLocalStorage();
12
+ function eventTypeMatches(pattern, type) {
13
+ if (pattern === "*") {
14
+ return true;
15
+ }
16
+ if (pattern.endsWith("*")) {
17
+ return type.startsWith(pattern.slice(0, -1));
18
+ }
19
+ return pattern === type;
20
+ }
21
+ function isPurposeAllowed(purpose, consent) {
22
+ if (purpose === "necessary") {
23
+ return true;
24
+ }
25
+ return purpose === "analytics" ? consent.analytics : consent.marketing;
26
+ }
27
+ class AnalyticsDispatcher {
28
+ requestContext;
29
+ subscribers;
30
+ createContext;
31
+ options;
32
+ defaultConsent;
33
+ queue = [];
34
+ flushScheduled = false;
35
+ tails = /* @__PURE__ */ new Map();
36
+ inflight = /* @__PURE__ */ new Set();
37
+ constructor(dependencies) {
38
+ this.requestContext = dependencies.requestContext;
39
+ this.createContext = dependencies.createContext;
40
+ this.options = dependencies.options ?? {};
41
+ this.defaultConsent = this.options.defaultConsent ?? DEFAULT_CONSENT;
42
+ const names = /* @__PURE__ */ new Set();
43
+ for (const subscriber of dependencies.subscribers) {
44
+ AnalyticsSubscriberDescriptorSchema.parse({ name: subscriber.name, types: subscriber.types, purpose: subscriber.purpose });
45
+ if (names.has(subscriber.name)) {
46
+ throw new Error(`Duplicate analytics subscriber name '${subscriber.name}'`);
47
+ }
48
+ names.add(subscriber.name);
49
+ }
50
+ this.subscribers = [...dependencies.subscribers];
51
+ }
52
+ /**
53
+ * Validates the input, captures request state, enqueues the event and schedules delivery.
54
+ * Never throws: invalid input and emits made from inside a subscriber are logged and dropped.
55
+ */
56
+ emit(type, input, options = {}) {
57
+ if (insideHandler.getStore()) {
58
+ console.warn(`Analytics event '${type}' was emitted from inside a subscriber and has been dropped`);
59
+ return error({ type: "InvalidInput", error: "Events cannot be emitted from inside an analytics subscriber" });
60
+ }
61
+ try {
62
+ const schema = AnalyticsEventMutationSchemas[type];
63
+ const mutation = schema.safeParse(input);
64
+ if (!mutation.success) {
65
+ const message = mutation.error.message;
66
+ console.warn(`Analytics event '${type}' had invalid input and has been dropped: ${message}`);
67
+ return error({ type: "InvalidInput", error: message });
68
+ }
69
+ if (type === "consent-changed") {
70
+ const change = AnalyticsMutationConsentChangedSchema.parse(input);
71
+ this.requestContext.session.consent = change.consent;
72
+ }
73
+ const { origin, properties, ...payload } = input;
74
+ const event = AnalyticsEventSchema.safeParse({
75
+ id: crypto.randomUUID(),
76
+ type,
77
+ occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
78
+ origin,
79
+ properties,
80
+ context: this.snapshot(options),
81
+ payload
82
+ });
83
+ if (!event.success) {
84
+ console.warn(`Analytics event '${type}' could not be built and has been dropped: ${event.error.message}`);
85
+ return error({ type: "InvalidInput", error: event.error.message });
86
+ }
87
+ this.queue.push(event.data);
88
+ this.scheduleFlush();
89
+ return success({ eventId: event.data.id });
90
+ } catch (err) {
91
+ console.error(`Analytics event '${type}' failed to enqueue and has been dropped`, err);
92
+ return error({ type: "InvalidInput", error: err instanceof Error ? err.message : String(err) });
93
+ }
94
+ }
95
+ /**
96
+ * Delivers everything queued so far and resolves once all deliveries, including ones already in flight, are done.
97
+ * For scripts, tests and runtimes without `waitUntil`. Never rejects.
98
+ */
99
+ async flush() {
100
+ this.deliverQueued();
101
+ await Promise.all([...this.inflight]);
102
+ }
103
+ /**
104
+ * A fresh lookup context, for analytics code that has to resolve something before it can emit (the legacy adapter).
105
+ */
106
+ lookups() {
107
+ return this.createContext();
108
+ }
109
+ snapshot(options) {
110
+ const { session, languageContext, referrer, userAgent, clientIp, isBot } = this.requestContext;
111
+ return {
112
+ marketingIdentifier: { ...options.marketingIdentifier ?? session.marketingContext.identifier },
113
+ identity: session.identityContext.identity,
114
+ locale: languageContext.locale,
115
+ currency: languageContext.currencyCode,
116
+ consent: session.consent ?? this.defaultConsent,
117
+ page: referrer ? { referrer } : void 0,
118
+ userAgent: userAgent || void 0,
119
+ clientIp: clientIp || void 0,
120
+ isBot
121
+ };
122
+ }
123
+ scheduleFlush() {
124
+ if (this.flushScheduled) {
125
+ return;
126
+ }
127
+ this.flushScheduled = true;
128
+ const run = Promise.resolve().then(() => {
129
+ this.flushScheduled = false;
130
+ return this.flush();
131
+ }).catch((err) => console.error("Analytics flush failed", err));
132
+ try {
133
+ this.options.waitUntil?.(run);
134
+ } catch (err) {
135
+ console.error("Analytics waitUntil failed", err);
136
+ }
137
+ }
138
+ deliverQueued() {
139
+ const batch = this.queue;
140
+ if (batch.length === 0) {
141
+ return;
142
+ }
143
+ this.queue = [];
144
+ const ctx = this.createContext();
145
+ const deliveries = [];
146
+ for (const subscriber of this.subscribers) {
147
+ const events = batch.filter(
148
+ (event) => subscriber.types.some((pattern) => eventTypeMatches(pattern, event.type)) && isPurposeAllowed(subscriber.purpose, event.context.consent ?? this.defaultConsent)
149
+ );
150
+ if (events.length === 0) {
151
+ continue;
152
+ }
153
+ const tail = (this.tails.get(subscriber) ?? Promise.resolve()).then(() => this.runSubscriber(subscriber, events, ctx));
154
+ this.tails.set(subscriber, tail);
155
+ deliveries.push(tail);
156
+ }
157
+ const done = Promise.all(deliveries).then(() => void 0);
158
+ this.inflight.add(done);
159
+ void done.finally(() => this.inflight.delete(done));
160
+ }
161
+ async runSubscriber(subscriber, events, ctx) {
162
+ for (const event of events) {
163
+ try {
164
+ const outcome = await insideHandler.run(
165
+ true,
166
+ () => traceSpan(`AnalyticsSubscriber.${subscriber.name}.handle`, () => subscriber.handle(event, ctx))
167
+ );
168
+ if (outcome === "rejected") {
169
+ console.warn(`Analytics subscriber '${subscriber.name}' rejected '${event.type}' (${event.id})`);
170
+ }
171
+ } catch (err) {
172
+ console.error(`Analytics subscriber '${subscriber.name}' failed on '${event.type}' (${event.id})`, err);
173
+ }
174
+ }
175
+ }
176
+ }
177
+ export {
178
+ AnalyticsDispatcher,
179
+ eventTypeMatches,
180
+ isPurposeAllowed
181
+ };
@@ -0,0 +1,6 @@
1
+ export * from "./context.js";
2
+ export * from "./dispatcher.js";
3
+ export * from "./legacy-adapter.js";
4
+ export * from "./origins.js";
5
+ export * from "./recording-subscriber.js";
6
+ export * from "./refs.js";
@@ -0,0 +1,48 @@
1
+ import { error } from "../schemas/result.js";
2
+ import { fromDirect, fromSearch } from "./origins.js";
3
+ async function emitLegacyAnalyticsEvent(event, dispatcher) {
4
+ const identity = event.personalizationProfile ? { marketingIdentifier: event.personalizationProfile.identifier } : {};
5
+ switch (event.event) {
6
+ case "product-summary-view":
7
+ return event.source ? dispatcher.emit("search-results-viewed", { search: event.source.identifier, products: event.products }, identity) : dispatcher.emit("product-carousel-viewed", { carousel: { carouselName: "unknown" }, products: event.products }, identity);
8
+ case "product-summary-click":
9
+ return dispatcher.emit(
10
+ "product-clicked",
11
+ {
12
+ product: event.product,
13
+ origin: event.source ? fromSearch(event.source.identifier, event.position) : fromDirect()
14
+ },
15
+ identity
16
+ );
17
+ case "product-details-view":
18
+ return dispatcher.emit("product-viewed", { product: event.product }, identity);
19
+ case "product-cart-add": {
20
+ const lookups = dispatcher.lookups();
21
+ const [cart, product] = await Promise.all([lookups.cart.active(), lookups.products.byId(event.product)]);
22
+ if (!cart.success) {
23
+ return cart;
24
+ }
25
+ if (!product.success) {
26
+ return product;
27
+ }
28
+ return dispatcher.emit(
29
+ "cart-item-added",
30
+ {
31
+ cart: cart.value,
32
+ variant: { sku: product.value.mainVariant.identifier.sku },
33
+ quantity: 1,
34
+ // The legacy source carries no position, only the search that led here.
35
+ origin: event.source ? fromSearch(event.source.identifier, 0) : void 0
36
+ },
37
+ identity
38
+ );
39
+ }
40
+ case "purchase":
41
+ return dispatcher.emit("order-placed", { order: event.order }, identity);
42
+ default:
43
+ return error({ type: "Generic", message: "Unsupported legacy analytics event" });
44
+ }
45
+ }
46
+ export {
47
+ emitLegacyAnalyticsEvent
48
+ };
@@ -0,0 +1,64 @@
1
+ function fromSearch(identifier, position) {
2
+ return { type: "search", identifier, position };
3
+ }
4
+ function fromAutocomplete(term, position) {
5
+ return { type: "autocomplete", term, position };
6
+ }
7
+ function fromCategory(categoryId, filters, position) {
8
+ return { type: "category", categoryId, filters, position };
9
+ }
10
+ function fromList(recommendationId, carouselName, position) {
11
+ return { type: "list", recommendationId, carouselName, position };
12
+ }
13
+ function fromAssociations(productId, kind, position) {
14
+ return { type: "associations", productId, kind, position };
15
+ }
16
+ function fromProductList(listId, position) {
17
+ return { type: "product-list", listId, position };
18
+ }
19
+ function fromPromotion(promotionId, slot) {
20
+ return { type: "promotion", promotionId, slot };
21
+ }
22
+ function fromContent(contentId, slot) {
23
+ return { type: "content", contentId, slot };
24
+ }
25
+ function fromCart(kind) {
26
+ return { type: "cart", kind };
27
+ }
28
+ function fromOrder(orderId) {
29
+ return { type: "order", orderId };
30
+ }
31
+ function fromQuickOrder(kind) {
32
+ return { type: "quick-order", kind };
33
+ }
34
+ function fromPDP(upstream) {
35
+ return { type: "pdp", upstream };
36
+ }
37
+ function fromDirect() {
38
+ return { type: "direct" };
39
+ }
40
+ function searchOriginOf(origin) {
41
+ if (origin?.type === "search") {
42
+ return origin;
43
+ }
44
+ if (origin?.type === "pdp") {
45
+ return searchOriginOf(origin.upstream);
46
+ }
47
+ return void 0;
48
+ }
49
+ export {
50
+ fromAssociations,
51
+ fromAutocomplete,
52
+ fromCart,
53
+ fromCategory,
54
+ fromContent,
55
+ fromDirect,
56
+ fromList,
57
+ fromOrder,
58
+ fromPDP,
59
+ fromProductList,
60
+ fromPromotion,
61
+ fromQuickOrder,
62
+ fromSearch,
63
+ searchOriginOf
64
+ };
@@ -0,0 +1,26 @@
1
+ class RecordingAnalyticsSubscriber {
2
+ constructor(name = "recording", types = ["*"], purpose = "necessary") {
3
+ this.name = name;
4
+ this.types = types;
5
+ this.purpose = purpose;
6
+ }
7
+ name;
8
+ types;
9
+ purpose;
10
+ events = [];
11
+ /** Lookup contexts the subscriber was handed, one per flush. */
12
+ contexts = [];
13
+ async handle(event, ctx) {
14
+ this.events.push(event);
15
+ if (!this.contexts.includes(ctx)) {
16
+ this.contexts.push(ctx);
17
+ }
18
+ return "accepted";
19
+ }
20
+ eventsOfType(type) {
21
+ return this.events.filter((event) => event.type === type);
22
+ }
23
+ }
24
+ export {
25
+ RecordingAnalyticsSubscriber
26
+ };
@@ -0,0 +1,14 @@
1
+ function productKey(ref) {
2
+ return "key" in ref ? ref.key : ref.identifier.key;
3
+ }
4
+ function categoryKey(ref) {
5
+ return "key" in ref ? ref.key : ref.identifier.key;
6
+ }
7
+ function storeKey(ref) {
8
+ return "key" in ref ? ref.key : ref.identifier.key;
9
+ }
10
+ export {
11
+ categoryKey,
12
+ productKey,
13
+ storeKey
14
+ };