@reform-society/agera-core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -274,6 +274,10 @@ type CrmFetchResult = {
274
274
  ok: boolean;
275
275
  status: number;
276
276
  data: Record<string, unknown> | null;
277
+ raw?: unknown;
278
+ error?: unknown;
279
+ cancelled?: boolean;
280
+ normalized?: unknown;
277
281
  };
278
282
  type CrmCommand = {
279
283
  client: string;
@@ -317,6 +321,156 @@ declare function executeCrm(gateway: CrmGateway, command: CrmCommand, options?:
317
321
  signal?: AbortSignal;
318
322
  }): Promise<CrmOutcome>;
319
323
  //#endregion
324
+ //#region src/lifecycle.d.ts
325
+ type NormalizedResult = {
326
+ delivery: "observed" | "unobserved";
327
+ operation: string;
328
+ operationStatus: "acknowledged" | "pending" | "completed" | "rejected" | "unknown";
329
+ contactStatus: "created" | "updated" | "unknown";
330
+ contactEvidence: "provider" | "inferred" | "unknown";
331
+ paymentStatus: "unknown" | "pending" | "succeeded" | "failed";
332
+ paymentEvidence: "provider" | "inferred" | "unknown";
333
+ nextAction?: {
334
+ type: "redirect" | "poll" | "verify";
335
+ url?: string;
336
+ };
337
+ outcome?: string;
338
+ extensions?: Record<string, unknown>;
339
+ };
340
+ type EventSource = {
341
+ client: string;
342
+ integration?: string;
343
+ formId?: string;
344
+ triggerId?: string;
345
+ pagePath: string;
346
+ };
347
+ type RequestSnapshot = {
348
+ fields: Record<string, unknown>;
349
+ context: Record<string, unknown>;
350
+ config: Record<string, unknown>;
351
+ method: string;
352
+ delivery: "wait" | "background";
353
+ body?: unknown;
354
+ };
355
+ type EventEnvelope = {
356
+ schemaVersion: 1;
357
+ eventId: string;
358
+ occurredAt: string;
359
+ source: EventSource;
360
+ context: Record<string, unknown>;
361
+ result: NormalizedResult;
362
+ submissionId?: string;
363
+ flowId?: string;
364
+ attemptId?: string;
365
+ conversionId?: string;
366
+ request?: RequestSnapshot;
367
+ };
368
+ type LifecycleEvent = EventEnvelope & ({
369
+ type: "crm.dispatched";
370
+ submissionId: string;
371
+ request: RequestSnapshot;
372
+ } | {
373
+ type: "crm.response";
374
+ submissionId: string;
375
+ request: RequestSnapshot;
376
+ response: {
377
+ httpStatus: number;
378
+ raw: unknown;
379
+ };
380
+ } | {
381
+ type: "crm.failed" | "crm.cancelled";
382
+ submissionId: string;
383
+ request: RequestSnapshot;
384
+ } | {
385
+ type: "element.entered" | "element.clicked" | "element.viewed" | "step.changed";
386
+ } | {
387
+ type: "flow.started" | "flow.returned" | "flow.verified" | "flow.failed";
388
+ flowId: string;
389
+ attemptId: string;
390
+ });
391
+ type LifecycleType = LifecycleEvent["type"];
392
+ declare function unknownResult(delivery?: NormalizedResult["delivery"]): NormalizedResult;
393
+ /** Serializable copies omit functions, DOM handles, and cyclic references. */
394
+ declare function snapshot<T>(value: T): T;
395
+ declare function createLifecycleBus(onError?: () => void): {
396
+ on: <T extends LifecycleType>(type: T | "*", listener: (event: LifecycleEvent & {
397
+ type: T;
398
+ }) => unknown) => () => void;
399
+ emit: (event: LifecycleEvent) => void;
400
+ clear(): void;
401
+ };
402
+ declare function normalizeResult(value: unknown, httpStatus?: number): NormalizedResult;
403
+ //#endregion
404
+ //#region src/tracking.d.ts
405
+ type ValueResolver = string | number | boolean | null | undefined | ValueResolver[] | {
406
+ [key: string]: ValueResolver;
407
+ } | ((event: LifecycleEvent) => unknown);
408
+ type TrackingConfig = {
409
+ destination: string | ((payload: Record<string, unknown>, event: LifecycleEvent) => unknown);
410
+ eventKey?: string;
411
+ reset?: Record<string, unknown>;
412
+ events: Partial<Record<LifecycleType, (event: LifecycleEvent) => string | null | undefined>>;
413
+ payload?: Record<string, ValueResolver>;
414
+ userData?: {
415
+ key: string;
416
+ fields: Record<string, string[] | ((event: LifecycleEvent) => unknown)>;
417
+ };
418
+ consent?: (event: LifecycleEvent) => boolean;
419
+ dedupe?: {
420
+ key: (event: LifecycleEvent, name: string) => string | undefined;
421
+ ttlMs: number;
422
+ maxEntries?: number;
423
+ };
424
+ envelope?: (payload: Record<string, unknown>, event: LifecycleEvent) => Record<string, unknown> | null;
425
+ };
426
+ declare function firstDefined(values: unknown[]): unknown;
427
+ declare function mapTrackingEvent(config: TrackingConfig, event: LifecycleEvent): {
428
+ name: string;
429
+ payload: any;
430
+ } | null;
431
+ declare function createTrackingConsumer(options: {
432
+ config: TrackingConfig;
433
+ now: () => number;
434
+ queue: (name: string, payload: Record<string, unknown>) => void;
435
+ diagnostic?: (status: "queued" | "submitted" | "deduplicated" | "failed") => void;
436
+ }): (event: LifecycleEvent) => void;
437
+ //#endregion
438
+ //#region src/flow-attempts.d.ts
439
+ type FlowAttempt = {
440
+ schemaVersion: 1;
441
+ flowId: string;
442
+ attemptId: string;
443
+ submissionId?: string;
444
+ token: string;
445
+ expectedReturn: string;
446
+ expiresAt: number;
447
+ acknowledged: boolean;
448
+ source: EventSource;
449
+ context: Record<string, unknown>;
450
+ request?: RequestSnapshot;
451
+ };
452
+ type AttemptStorage = {
453
+ getItem(key: string): string | null;
454
+ setItem(key: string, value: string): void;
455
+ removeItem(key: string): void;
456
+ };
457
+ declare function createAttemptStore(options: {
458
+ namespace: string;
459
+ storage: AttemptStorage;
460
+ now: () => number;
461
+ id: () => string;
462
+ ttlMs: number;
463
+ maxEntries?: number;
464
+ diagnostic?: () => void;
465
+ }): {
466
+ begin: (input: Omit<FlowAttempt, "schemaVersion" | "attemptId" | "token" | "expiresAt" | "acknowledged">) => FlowAttempt | null;
467
+ acknowledge: (token: string) => boolean;
468
+ consume: (token: string, destination: string) => FlowAttempt | null;
469
+ cancel: (token: string) => boolean;
470
+ read: (token: string) => FlowAttempt | null;
471
+ purge: () => void;
472
+ };
473
+ //#endregion
320
474
  //#region src/utm.d.ts
321
475
  declare const UTM_KEYS: readonly ["source", "campaign", "medium", "content", "term", "wec"];
322
476
  type Utms = Partial<Record<(typeof UTM_KEYS)[number], string>>;
@@ -396,6 +550,19 @@ type DeviceInput = {
396
550
  };
397
551
  declare function detectDeviceType(input: DeviceInput): DeviceType;
398
552
  //#endregion
553
+ //#region src/visitor.d.ts
554
+ declare const VISITOR_ID_STORAGE_KEY = "agera:vid";
555
+ /**
556
+ * Read the semi-persistent pseudonymous visitor id from storage, generating
557
+ * and persisting a new one when absent. Used only to correlate requests from
558
+ * the same browser in crm-api logs — never stored server-side.
559
+ *
560
+ * Returns null when storage can't be read or written (private mode, quota,
561
+ * blocked storage): an unpersisted id would look like a new visitor on every
562
+ * page load, so callers omit the header instead.
563
+ */
564
+ declare function getOrCreateVisitorId(storage: StorageAccessor): string | null;
565
+ //#endregion
399
566
  //#region src/crm-fetch.d.ts
400
567
  type CrmFetchOptions = {
401
568
  waitForResponse?: boolean;
@@ -446,5 +613,5 @@ declare function assembleCrmRequest({
446
613
  //#region src/polling.d.ts
447
614
  declare function matchesFormat(example: unknown, value: unknown): boolean;
448
615
  //#endregion
449
- export { type AssembleCrmRequestParams, type BandQuizResults, CRM_CONTACT_KEYS, type CompositePredicate, type CounterDisplayConfig, type CounterPayload, type CounterResponse, type CounterUpdateConfig, type CrmCommand, type CrmConfig, type CrmContactKey, type CrmDispatch, type CrmFetchOptions, type CrmFetchResult, type CrmFrontendKey, type CrmGateway, type CrmGatewayResult, type CrmGroupedAnswer, type CrmJsonGroups, type CrmOutcome, type CrmV2PatchRequest, type CrmV2Payload, type CrmV2Request, type DeviceInput, type DeviceType, type ErrorMessages, type FetchBasinJWTOptions, type InsertDirective, type JsonValue, type JwtPayload, type MemoryCache, type MemoryCacheOptions, type MemoryCacheSetOptions, type PassQuizResults, type PlacesMessageKey, type PlacesMessages, type Predicate, type PredicateOp, type ProgressInfo, type ProgressModeInfo, type QuestionMetrics, type QuizBandDefinition, type QuizConfig, type QuizOutcomeModel, type QuizPassFailConfig, type QuizQuestionInput, type QuizQuestionModel, type QuizQuestionResult, type QuizQuestionState, type QuizResults, type QuizScoringModel, type QuizSectionConfig, type QuizSectionResults, type ResolveValueSources, type ScoreQuizResults, type SessionState, type ShareConfig, type SimplePredicate, type SplitConfig, type StepState, type StorageAccessor, type SurveyOptions, type Utms, type ValidationRule, type Validators, type WeightedQuizResults, aggregateWeightedScores, answersChanged, assembleCrmRequest, buildContext, buildCounterPayload, buildFcrmUtms, calculateScoreResults, calculateSectionResults, calculateWinner, computeAutoTarget, createMemoryCache, createSessionStore, createStepsStore, crmFetch, decodeJwtPayload, deriveBandResults, derivePassResults, deriveQuestionState, detectDeviceType, errorMessages, evaluateNextMap, evaluatePredicate, evaluateShowIf, executeCrm, extractUtms, fetchBasinJWT, fetchCounter, flattenObject, formatCounterValue, getErrorMessagesForLanguage, getPhoneExample, getPlacesMessagesForLanguage, getPostalExample, groupCrmAnswers, inferCounterName, isCounterResponse, isCrmContactKey, isJwtExpired, isSafeRedirectUrl, matchesFormat, mergeAnswers, parseCounterUpdateAttr, parseInsertDirective, parseInsertDirectives, resolveValue, scoreExactQuestion, scorePartialQuestion, scoreQuiz, scoreQuizQuestions, serializeCrmJsonGroups, updateUrlWithParams, validators };
616
+ export { type AssembleCrmRequestParams, type AttemptStorage, type BandQuizResults, CRM_CONTACT_KEYS, type CompositePredicate, type CounterDisplayConfig, type CounterPayload, type CounterResponse, type CounterUpdateConfig, type CrmCommand, type CrmConfig, type CrmContactKey, type CrmDispatch, type CrmFetchOptions, type CrmFetchResult, type CrmFrontendKey, type CrmGateway, type CrmGatewayResult, type CrmGroupedAnswer, type CrmJsonGroups, type CrmOutcome, type CrmV2PatchRequest, type CrmV2Payload, type CrmV2Request, type DeviceInput, type DeviceType, type ErrorMessages, type EventSource, type FetchBasinJWTOptions, type FlowAttempt, type InsertDirective, type JsonValue, type JwtPayload, type LifecycleEvent, type LifecycleType, type MemoryCache, type MemoryCacheOptions, type MemoryCacheSetOptions, type NormalizedResult, type PassQuizResults, type PlacesMessageKey, type PlacesMessages, type Predicate, type PredicateOp, type ProgressInfo, type ProgressModeInfo, type QuestionMetrics, type QuizBandDefinition, type QuizConfig, type QuizOutcomeModel, type QuizPassFailConfig, type QuizQuestionInput, type QuizQuestionModel, type QuizQuestionResult, type QuizQuestionState, type QuizResults, type QuizScoringModel, type QuizSectionConfig, type QuizSectionResults, type RequestSnapshot, type ResolveValueSources, type ScoreQuizResults, type SessionState, type ShareConfig, type SimplePredicate, type SplitConfig, type StepState, type StorageAccessor, type SurveyOptions, type TrackingConfig, type Utms, VISITOR_ID_STORAGE_KEY, type ValidationRule, type Validators, type ValueResolver, type WeightedQuizResults, aggregateWeightedScores, answersChanged, assembleCrmRequest, buildContext, buildCounterPayload, buildFcrmUtms, calculateScoreResults, calculateSectionResults, calculateWinner, computeAutoTarget, createAttemptStore, createLifecycleBus, createMemoryCache, createSessionStore, createStepsStore, createTrackingConsumer, crmFetch, decodeJwtPayload, deriveBandResults, derivePassResults, deriveQuestionState, detectDeviceType, errorMessages, evaluateNextMap, evaluatePredicate, evaluateShowIf, executeCrm, extractUtms, fetchBasinJWT, fetchCounter, firstDefined, flattenObject, formatCounterValue, getErrorMessagesForLanguage, getOrCreateVisitorId, getPhoneExample, getPlacesMessagesForLanguage, getPostalExample, groupCrmAnswers, inferCounterName, isCounterResponse, isCrmContactKey, isJwtExpired, isSafeRedirectUrl, mapTrackingEvent, matchesFormat, mergeAnswers, normalizeResult, parseCounterUpdateAttr, parseInsertDirective, parseInsertDirectives, resolveValue, scoreExactQuestion, scorePartialQuestion, scoreQuiz, scoreQuizQuestions, serializeCrmJsonGroups, snapshot, unknownResult, updateUrlWithParams, validators };
450
617
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -789,6 +789,277 @@ async function executeCrm(gateway, command, options = {}) {
789
789
  }
790
790
  }
791
791
  //#endregion
792
+ //#region src/lifecycle.ts
793
+ function unknownResult(delivery = "unobserved") {
794
+ return {
795
+ delivery,
796
+ operation: "unknown",
797
+ operationStatus: "unknown",
798
+ contactStatus: "unknown",
799
+ contactEvidence: "unknown",
800
+ paymentStatus: "unknown",
801
+ paymentEvidence: "unknown"
802
+ };
803
+ }
804
+ /** Serializable copies omit functions, DOM handles, and cyclic references. */
805
+ function snapshot(value) {
806
+ const seen = /* @__PURE__ */ new WeakSet();
807
+ function copy(input) {
808
+ if (input === null || typeof input === "string" || typeof input === "boolean") return input;
809
+ if (typeof input === "number") return Number.isFinite(input) ? input : null;
810
+ if (typeof input !== "object" || seen.has(input)) return void 0;
811
+ if (!Array.isArray(input) && Object.getPrototypeOf(input) !== Object.prototype && Object.getPrototypeOf(input) !== null) return void 0;
812
+ seen.add(input);
813
+ const result = Array.isArray(input) ? input.map(copy) : Object.fromEntries(Object.entries(input).map(([key, item]) => [key, copy(item)]).filter(([, item]) => item !== void 0));
814
+ seen.delete(input);
815
+ return result;
816
+ }
817
+ return copy(value);
818
+ }
819
+ function createLifecycleBus(onError = () => {}) {
820
+ const listeners = /* @__PURE__ */ new Map();
821
+ function report() {
822
+ try {
823
+ onError();
824
+ } catch {}
825
+ }
826
+ function on(type, listener) {
827
+ const set = listeners.get(type) ?? /* @__PURE__ */ new Set();
828
+ set.add(listener);
829
+ listeners.set(type, set);
830
+ return () => {
831
+ set.delete(listener);
832
+ };
833
+ }
834
+ function emit(event) {
835
+ const captured = snapshot(event);
836
+ for (const listener of [...listeners.get(event.type) ?? [], ...listeners.get("*") ?? []]) try {
837
+ Promise.resolve(listener(snapshot(captured))).catch(report);
838
+ } catch {
839
+ report();
840
+ }
841
+ }
842
+ return {
843
+ on,
844
+ emit,
845
+ clear() {
846
+ listeners.clear();
847
+ }
848
+ };
849
+ }
850
+ function normalizeResult(value, httpStatus) {
851
+ const result = unknownResult(httpStatus === void 0 ? "unobserved" : "observed");
852
+ if (httpStatus !== void 0 && httpStatus >= 400) result.operationStatus = "rejected";
853
+ if (!value || typeof value !== "object" || Array.isArray(value)) return result;
854
+ const record = value;
855
+ if (typeof record.operation === "string") result.operation = record.operation;
856
+ for (const [key, allowed] of Object.entries({
857
+ operationStatus: [
858
+ "acknowledged",
859
+ "pending",
860
+ "completed",
861
+ "rejected",
862
+ "unknown"
863
+ ],
864
+ contactStatus: [
865
+ "created",
866
+ "updated",
867
+ "unknown"
868
+ ],
869
+ contactEvidence: [
870
+ "provider",
871
+ "inferred",
872
+ "unknown"
873
+ ],
874
+ paymentStatus: [
875
+ "unknown",
876
+ "pending",
877
+ "succeeded",
878
+ "failed"
879
+ ],
880
+ paymentEvidence: [
881
+ "provider",
882
+ "inferred",
883
+ "unknown"
884
+ ]
885
+ })) if (typeof record[key] === "string" && allowed.includes(record[key])) Object.assign(result, { [key]: record[key] });
886
+ if (record.nextAction && typeof record.nextAction === "object") {
887
+ const action = record.nextAction;
888
+ if (action.type === "redirect" || action.type === "poll" || action.type === "verify") result.nextAction = {
889
+ type: action.type,
890
+ ...typeof action.url === "string" ? { url: action.url } : {}
891
+ };
892
+ }
893
+ if (record.extensions && typeof record.extensions === "object" && !Array.isArray(record.extensions)) result.extensions = snapshot(record.extensions);
894
+ return result;
895
+ }
896
+ //#endregion
897
+ //#region src/tracking.ts
898
+ function firstDefined(values) {
899
+ return values.find((value) => value !== void 0 && value !== "");
900
+ }
901
+ function resolveValue$1(value, event) {
902
+ if (typeof value === "function") return value(snapshot(event));
903
+ if (Array.isArray(value)) return value.map((item) => resolveValue$1(item, event));
904
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveValue$1(item, event)]).filter(([, item]) => item !== void 0));
905
+ return value;
906
+ }
907
+ function mapTrackingEvent(config, event) {
908
+ if (config.consent && !config.consent(snapshot(event))) return null;
909
+ const name = config.events[event.type]?.(snapshot(event));
910
+ if (typeof name !== "string" || name === "") return null;
911
+ const payload = Object.fromEntries(Object.entries(config.payload ?? {}).map(([key, value]) => [key, resolveValue$1(value, event)]).filter(([, value]) => value !== void 0));
912
+ payload[config.eventKey ?? "event"] = name;
913
+ if (config.userData) {
914
+ const fields = event.request?.fields ?? {};
915
+ const eligible = event.type.startsWith("crm.") || event.type.startsWith("flow.");
916
+ payload[config.userData.key] = Object.fromEntries(Object.entries(config.userData.fields).map(([key, aliases]) => {
917
+ return [key, !eligible ? void 0 : typeof aliases === "function" ? aliases(snapshot(event)) : firstDefined(aliases.map((alias) => fields[alias]))];
918
+ }).filter(([, value]) => value !== void 0 && value !== null && value !== ""));
919
+ }
920
+ const output = config.envelope ? config.envelope(snapshot(payload), snapshot(event)) : payload;
921
+ return output ? {
922
+ name,
923
+ payload: snapshot(output)
924
+ } : null;
925
+ }
926
+ function createTrackingConsumer(options) {
927
+ const seen = /* @__PURE__ */ new Map();
928
+ function report(status) {
929
+ try {
930
+ options.diagnostic?.(status);
931
+ } catch {}
932
+ }
933
+ return function consume(event) {
934
+ let key;
935
+ try {
936
+ const { config } = options;
937
+ const mapped = mapTrackingEvent(config, event);
938
+ if (!mapped) return;
939
+ const now = options.now();
940
+ for (const [key, expires] of seen) if (expires <= now) seen.delete(key);
941
+ key = config.dedupe?.key(snapshot(event), mapped.name);
942
+ if (key !== void 0 && seen.has(key)) {
943
+ report("deduplicated");
944
+ return;
945
+ }
946
+ if (key !== void 0 && config.dedupe && config.dedupe.ttlMs > 0) {
947
+ while (seen.size >= Math.max(1, config.dedupe.maxEntries ?? 1e3)) seen.delete(seen.keys().next().value);
948
+ seen.set(key, now + config.dedupe.ttlMs);
949
+ }
950
+ if (typeof config.destination === "string") {
951
+ if (config.reset) options.queue(config.destination, snapshot(config.reset));
952
+ options.queue(config.destination, mapped.payload);
953
+ report("queued");
954
+ } else Promise.resolve(config.destination(mapped.payload, snapshot(event))).then(() => report("submitted"), () => {
955
+ if (key !== void 0) seen.delete(key);
956
+ report("failed");
957
+ });
958
+ } catch {
959
+ if (key !== void 0) seen.delete(key);
960
+ report("failed");
961
+ }
962
+ };
963
+ }
964
+ //#endregion
965
+ //#region src/flow-attempts.ts
966
+ function createAttemptStore(options) {
967
+ const prefix = `agera:attempts:${options.namespace}:`;
968
+ const indexKey = `${prefix}index`;
969
+ function report() {
970
+ try {
971
+ options.diagnostic?.();
972
+ } catch {}
973
+ }
974
+ function readIds() {
975
+ try {
976
+ const ids = JSON.parse(options.storage.getItem(indexKey) ?? "[]");
977
+ return Array.isArray(ids) ? ids.filter((id) => typeof id === "string").slice(-Math.max(1, options.maxEntries ?? 20)) : [];
978
+ } catch {
979
+ report();
980
+ return [];
981
+ }
982
+ }
983
+ function read(token) {
984
+ try {
985
+ const value = JSON.parse(options.storage.getItem(prefix + token) ?? "null");
986
+ if (!value || typeof value !== "object") return null;
987
+ const item = value;
988
+ if (item.schemaVersion !== 1 || item.token !== token || typeof item.attemptId !== "string" || typeof item.flowId !== "string" || typeof item.expectedReturn !== "string" || typeof item.expiresAt !== "number" || !Number.isFinite(item.expiresAt) || item.expiresAt <= options.now() || typeof item.acknowledged !== "boolean" || !item.source || typeof item.source.client !== "string" || !item.context || typeof item.context !== "object") {
989
+ options.storage.removeItem(prefix + token);
990
+ return null;
991
+ }
992
+ return snapshot(item);
993
+ } catch {
994
+ report();
995
+ return null;
996
+ }
997
+ }
998
+ function remove(token) {
999
+ try {
1000
+ options.storage.removeItem(prefix + token);
1001
+ return true;
1002
+ } catch {
1003
+ report();
1004
+ return false;
1005
+ }
1006
+ }
1007
+ function write(item) {
1008
+ try {
1009
+ options.storage.setItem(prefix + item.token, JSON.stringify(item));
1010
+ return true;
1011
+ } catch {
1012
+ report();
1013
+ return false;
1014
+ }
1015
+ }
1016
+ function begin(input) {
1017
+ if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) return null;
1018
+ const attempt = {
1019
+ ...snapshot(input),
1020
+ schemaVersion: 1,
1021
+ attemptId: options.id(),
1022
+ token: options.id(),
1023
+ expiresAt: options.now() + options.ttlMs,
1024
+ acknowledged: false
1025
+ };
1026
+ const ids = readIds().filter((token) => read(token) !== null);
1027
+ while (ids.length >= Math.max(1, options.maxEntries ?? 20)) remove(ids.shift());
1028
+ if (!write(attempt)) return null;
1029
+ try {
1030
+ options.storage.setItem(indexKey, JSON.stringify([...ids, attempt.token]));
1031
+ } catch {
1032
+ remove(attempt.token);
1033
+ report();
1034
+ return null;
1035
+ }
1036
+ return snapshot(attempt);
1037
+ }
1038
+ function acknowledge(token) {
1039
+ const attempt = read(token);
1040
+ return attempt ? write({
1041
+ ...attempt,
1042
+ acknowledged: true
1043
+ }) : false;
1044
+ }
1045
+ function consume(token, destination) {
1046
+ const attempt = read(token);
1047
+ if (!attempt?.acknowledged || attempt.expectedReturn !== destination) return null;
1048
+ return remove(token) ? attempt : null;
1049
+ }
1050
+ function purge() {
1051
+ for (const token of readIds()) read(token);
1052
+ }
1053
+ return {
1054
+ begin,
1055
+ acknowledge,
1056
+ consume,
1057
+ cancel: remove,
1058
+ read,
1059
+ purge
1060
+ };
1061
+ }
1062
+ //#endregion
792
1063
  //#region src/utm.ts
793
1064
  const UTM_KEYS = [
794
1065
  "source",
@@ -1115,6 +1386,32 @@ function detectDeviceType(input) {
1115
1386
  return "desktop";
1116
1387
  }
1117
1388
  //#endregion
1389
+ //#region src/visitor.ts
1390
+ const VISITOR_ID_STORAGE_KEY = "agera:vid";
1391
+ function generateVisitorId() {
1392
+ return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1393
+ }
1394
+ /**
1395
+ * Read the semi-persistent pseudonymous visitor id from storage, generating
1396
+ * and persisting a new one when absent. Used only to correlate requests from
1397
+ * the same browser in crm-api logs — never stored server-side.
1398
+ *
1399
+ * Returns null when storage can't be read or written (private mode, quota,
1400
+ * blocked storage): an unpersisted id would look like a new visitor on every
1401
+ * page load, so callers omit the header instead.
1402
+ */
1403
+ function getOrCreateVisitorId(storage) {
1404
+ try {
1405
+ const existing = storage.getItem(VISITOR_ID_STORAGE_KEY);
1406
+ if (existing) return existing;
1407
+ const id = generateVisitorId();
1408
+ storage.setItem(VISITOR_ID_STORAGE_KEY, id);
1409
+ return id;
1410
+ } catch {
1411
+ return null;
1412
+ }
1413
+ }
1414
+ //#endregion
1118
1415
  //#region src/crm-fetch.ts
1119
1416
  const noop = () => {};
1120
1417
  async function crmFetch(url, payload, options = {}) {
@@ -1234,6 +1531,6 @@ function matchesFormat(example, value) {
1234
1531
  return example === value;
1235
1532
  }
1236
1533
  //#endregion
1237
- export { CRM_CONTACT_KEYS, aggregateWeightedScores, answersChanged, assembleCrmRequest, buildContext, buildCounterPayload, buildFcrmUtms, calculateScoreResults, calculateSectionResults, calculateWinner, computeAutoTarget, createMemoryCache, createSessionStore, createStepsStore, crmFetch, decodeJwtPayload, deriveBandResults, derivePassResults, deriveQuestionState, detectDeviceType, errorMessages, evaluateNextMap, evaluatePredicate, evaluateShowIf, executeCrm, extractUtms, fetchBasinJWT, fetchCounter, flattenObject, formatCounterValue, getErrorMessagesForLanguage, getPhoneExample, getPlacesMessagesForLanguage, getPostalExample, groupCrmAnswers, inferCounterName, isCounterResponse, isCrmContactKey, isJwtExpired, isSafeRedirectUrl, matchesFormat, mergeAnswers, parseCounterUpdateAttr, parseInsertDirective, parseInsertDirectives, resolveValue, scoreExactQuestion, scorePartialQuestion, scoreQuiz, scoreQuizQuestions, serializeCrmJsonGroups, updateUrlWithParams, validators };
1534
+ export { CRM_CONTACT_KEYS, VISITOR_ID_STORAGE_KEY, aggregateWeightedScores, answersChanged, assembleCrmRequest, buildContext, buildCounterPayload, buildFcrmUtms, calculateScoreResults, calculateSectionResults, calculateWinner, computeAutoTarget, createAttemptStore, createLifecycleBus, createMemoryCache, createSessionStore, createStepsStore, createTrackingConsumer, crmFetch, decodeJwtPayload, deriveBandResults, derivePassResults, deriveQuestionState, detectDeviceType, errorMessages, evaluateNextMap, evaluatePredicate, evaluateShowIf, executeCrm, extractUtms, fetchBasinJWT, fetchCounter, firstDefined, flattenObject, formatCounterValue, getErrorMessagesForLanguage, getOrCreateVisitorId, getPhoneExample, getPlacesMessagesForLanguage, getPostalExample, groupCrmAnswers, inferCounterName, isCounterResponse, isCrmContactKey, isJwtExpired, isSafeRedirectUrl, mapTrackingEvent, matchesFormat, mergeAnswers, normalizeResult, parseCounterUpdateAttr, parseInsertDirective, parseInsertDirectives, resolveValue, scoreExactQuestion, scorePartialQuestion, scoreQuiz, scoreQuizQuestions, serializeCrmJsonGroups, snapshot, unknownResult, updateUrlWithParams, validators };
1238
1535
 
1239
1536
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reform-society/agera-core",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "DOM-free core logic for Agera",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -35,13 +35,13 @@
35
35
  },
36
36
  "devDependencies": {
37
37
  "@typescript/native": "npm:typescript@^7.0.2",
38
- "tsdown": "^0.21.8",
38
+ "tsdown": "^0.21.10",
39
39
  "typescript": "npm:@typescript/typescript6@^6.0.2",
40
- "vite": "^7.3.2",
41
- "vitest": "^4.1.3"
40
+ "vite": "7.3.5",
41
+ "vitest": "^4.1.11"
42
42
  },
43
43
  "dependencies": {
44
44
  "loglevel": "^1.9.2",
45
- "nanostores": "^1.1.0"
45
+ "nanostores": "^1.5.2"
46
46
  }
47
47
  }