@rocapine/rocalytics-sdk 0.0.0-stage → 0.3.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/CHANGELOG.md +86 -0
- package/LICENSE +21 -0
- package/README.md +449 -2
- package/client/package.json +1 -0
- package/core/package.json +4 -0
- package/dist/client/client.d.ts +146 -0
- package/dist/client/client.d.ts.map +1 -0
- package/dist/client/client.js +376 -0
- package/dist/client/client.js.map +1 -0
- package/dist/client/index.d.ts +13 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +35 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/native.d.ts +68 -0
- package/dist/client/native.d.ts.map +1 -0
- package/dist/client/native.js +64 -0
- package/dist/client/native.js.map +1 -0
- package/dist/client/onboardingSink.d.ts +63 -0
- package/dist/client/onboardingSink.d.ts.map +1 -0
- package/dist/client/onboardingSink.js +126 -0
- package/dist/client/onboardingSink.js.map +1 -0
- package/dist/client/paywallSink.d.ts +13 -0
- package/dist/client/paywallSink.d.ts.map +1 -0
- package/dist/client/paywallSink.js +26 -0
- package/dist/client/paywallSink.js.map +1 -0
- package/dist/client/requests.d.ts +66 -0
- package/dist/client/requests.d.ts.map +1 -0
- package/dist/client/requests.js +90 -0
- package/dist/client/requests.js.map +1 -0
- package/dist/client/types.d.ts +180 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +4 -0
- package/dist/client/types.js.map +1 -0
- package/dist/core/context.d.ts +39 -0
- package/dist/core/context.d.ts.map +1 -0
- package/dist/core/context.js +45 -0
- package/dist/core/context.js.map +1 -0
- package/dist/core/delivery.d.ts +63 -0
- package/dist/core/delivery.d.ts.map +1 -0
- package/dist/core/delivery.js +133 -0
- package/dist/core/delivery.js.map +1 -0
- package/dist/core/diagnostics.d.ts +16 -0
- package/dist/core/diagnostics.d.ts.map +1 -0
- package/dist/core/diagnostics.js +24 -0
- package/dist/core/diagnostics.js.map +1 -0
- package/dist/core/httpSink.d.ts +35 -0
- package/dist/core/httpSink.d.ts.map +1 -0
- package/dist/core/httpSink.js +65 -0
- package/dist/core/httpSink.js.map +1 -0
- package/dist/core/ids.d.ts +16 -0
- package/dist/core/ids.d.ts.map +1 -0
- package/dist/core/ids.js +51 -0
- package/dist/core/ids.js.map +1 -0
- package/dist/core/index.d.ts +17 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +32 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/sink.d.ts +39 -0
- package/dist/core/sink.d.ts.map +1 -0
- package/dist/core/sink.js +16 -0
- package/dist/core/sink.js.map +1 -0
- package/dist/core/storage.d.ts +52 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/storage.js +79 -0
- package/dist/core/storage.js.map +1 -0
- package/dist/core/text.d.ts +9 -0
- package/dist/core/text.d.ts.map +1 -0
- package/dist/core/text.js +47 -0
- package/dist/core/text.js.map +1 -0
- package/dist/core/time.d.ts +18 -0
- package/dist/core/time.d.ts.map +1 -0
- package/dist/core/time.js +18 -0
- package/dist/core/time.js.map +1 -0
- package/dist/onboarding/contract.d.ts +138 -0
- package/dist/onboarding/contract.d.ts.map +1 -0
- package/dist/onboarding/contract.js +11 -0
- package/dist/onboarding/contract.js.map +1 -0
- package/dist/onboarding/index.d.ts +15 -0
- package/dist/onboarding/index.d.ts.map +1 -0
- package/dist/onboarding/index.js +22 -0
- package/dist/onboarding/index.js.map +1 -0
- package/dist/onboarding/input.d.ts +102 -0
- package/dist/onboarding/input.d.ts.map +1 -0
- package/dist/onboarding/input.js +216 -0
- package/dist/onboarding/input.js.map +1 -0
- package/dist/onboarding/persisted.d.ts +20 -0
- package/dist/onboarding/persisted.d.ts.map +1 -0
- package/dist/onboarding/persisted.js +75 -0
- package/dist/onboarding/persisted.js.map +1 -0
- package/dist/onboarding/state.d.ts +65 -0
- package/dist/onboarding/state.d.ts.map +1 -0
- package/dist/onboarding/state.js +207 -0
- package/dist/onboarding/state.js.map +1 -0
- package/dist/onboarding/tracker.d.ts +135 -0
- package/dist/onboarding/tracker.d.ts.map +1 -0
- package/dist/onboarding/tracker.js +734 -0
- package/dist/onboarding/tracker.js.map +1 -0
- package/dist/paywall/contract.d.ts +67 -0
- package/dist/paywall/contract.d.ts.map +1 -0
- package/dist/paywall/contract.js +3 -0
- package/dist/paywall/contract.js.map +1 -0
- package/dist/paywall/index.d.ts +14 -0
- package/dist/paywall/index.d.ts.map +1 -0
- package/dist/paywall/index.js +19 -0
- package/dist/paywall/index.js.map +1 -0
- package/dist/paywall/observer.d.ts +38 -0
- package/dist/paywall/observer.d.ts.map +1 -0
- package/dist/paywall/observer.js +6 -0
- package/dist/paywall/observer.js.map +1 -0
- package/dist/paywall/state.d.ts +26 -0
- package/dist/paywall/state.d.ts.map +1 -0
- package/dist/paywall/state.js +132 -0
- package/dist/paywall/state.js.map +1 -0
- package/dist/paywall/tracker.d.ts +36 -0
- package/dist/paywall/tracker.d.ts.map +1 -0
- package/dist/paywall/tracker.js +180 -0
- package/dist/paywall/tracker.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +6 -0
- package/dist/version.js.map +1 -0
- package/docs/onboarding-run-contract.md +1013 -0
- package/docs/onboarding-run.examples.ts +306 -0
- package/docs/onboarding-run.schema.json +224 -0
- package/docs/onboarding-run.types.ts +144 -0
- package/docs/paywall-presentation-contract.md +139 -0
- package/docs/paywall-presentation.examples.ts +42 -0
- package/docs/paywall-presentation.schema.json +268 -0
- package/docs/paywall-presentation.types.ts +73 -0
- package/onboarding/package.json +4 -0
- package/package.json +120 -4
- package/paywall/package.json +1 -0
- package/src/client/client.ts +417 -0
- package/src/client/index.ts +54 -0
- package/src/client/native.ts +109 -0
- package/src/client/onboardingSink.ts +121 -0
- package/src/client/paywallSink.ts +25 -0
- package/src/client/requests.ts +126 -0
- package/src/client/types.ts +198 -0
- package/src/core/context.ts +69 -0
- package/src/core/delivery.ts +181 -0
- package/src/core/diagnostics.ts +30 -0
- package/src/core/httpSink.ts +70 -0
- package/src/core/ids.ts +46 -0
- package/src/core/index.ts +20 -0
- package/src/core/sink.ts +46 -0
- package/src/core/storage.ts +112 -0
- package/src/core/text.ts +37 -0
- package/src/core/time.ts +26 -0
- package/src/onboarding/contract.ts +144 -0
- package/src/onboarding/index.ts +39 -0
- package/src/onboarding/input.ts +246 -0
- package/src/onboarding/persisted.ts +93 -0
- package/src/onboarding/state.ts +233 -0
- package/src/onboarding/tracker.ts +855 -0
- package/src/paywall/contract.ts +73 -0
- package/src/paywall/index.ts +36 -0
- package/src/paywall/observer.ts +43 -0
- package/src/paywall/state.ts +125 -0
- package/src/paywall/tracker.ts +200 -0
- package/src/version.ts +2 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Paywall presentation contract v1: the payload types.
|
|
2
|
+
// Normative text: docs/paywall-presentation-contract.md. src/paywall/contract.ts
|
|
3
|
+
// is this file, byte for byte, except its import line (test/paywall-docs.test.ts).
|
|
4
|
+
import type { RunContext, Timestamp } from "../onboarding/contract";
|
|
5
|
+
|
|
6
|
+
export type PresentationStatus = "in_progress" | "ended";
|
|
7
|
+
export type PresentationOutcomeStatus = "purchased" | "dismissed" | "cancelled" | "error";
|
|
8
|
+
export type PresentationSurface = "present" | "paywall_step";
|
|
9
|
+
|
|
10
|
+
export interface PresentationPaywall {
|
|
11
|
+
/** The moment's key: what the host passed to `present()`. */
|
|
12
|
+
moment_key: string;
|
|
13
|
+
/** The Studio paywall id the moment's audience waterfall picked. */
|
|
14
|
+
paywall_id: string;
|
|
15
|
+
/** The audience that matched; null when the catalog carried none. */
|
|
16
|
+
audience_id: string | null;
|
|
17
|
+
render_mode: "elements" | "custom";
|
|
18
|
+
billing: "store" | "stripe";
|
|
19
|
+
variant_key?: string;
|
|
20
|
+
deployment_id?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PresentationTransaction {
|
|
24
|
+
/**
|
|
25
|
+
* The exact value the app passes to Rocalytics' `purchase` call (`originalTransactionIdentifier`).
|
|
26
|
+
* iOS: StoreKit's original transaction id, the join key to the store-notification rows.
|
|
27
|
+
* Android: the Play order id (GPA.…); not used to join.
|
|
28
|
+
*/
|
|
29
|
+
original_transaction_identifier?: string;
|
|
30
|
+
/** Android only: the Play purchase token, the proceeds join key on Android. */
|
|
31
|
+
purchase_token?: string;
|
|
32
|
+
product_id?: string;
|
|
33
|
+
/** True when the purchase only restored existing access (a restore, a web redemption). Not a conversion. */
|
|
34
|
+
restored?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PresentationOutcome {
|
|
38
|
+
status: PresentationOutcomeStatus;
|
|
39
|
+
/** Only with status "error": the SDK's reason, e.g. "render-error". */
|
|
40
|
+
reason?: string;
|
|
41
|
+
/** Only with status "purchased". Absent when the host reported none. */
|
|
42
|
+
transaction?: PresentationTransaction;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface PresentationBase {
|
|
46
|
+
schema_version: 1;
|
|
47
|
+
/** Client-minted, lowercase UUID. One per presentation. */
|
|
48
|
+
presentation_id: string;
|
|
49
|
+
/** Starts at 1 and increases with every send of this presentation. */
|
|
50
|
+
seq: number;
|
|
51
|
+
started_at: Timestamp;
|
|
52
|
+
shown_at: Timestamp | null;
|
|
53
|
+
sent_at: Timestamp;
|
|
54
|
+
paywall: PresentationPaywall;
|
|
55
|
+
surface: PresentationSurface;
|
|
56
|
+
/** Only for surface "paywall_step": the onboarding run it sits in. */
|
|
57
|
+
onboarding_run?: { run_id: string; step_key: string };
|
|
58
|
+
context: RunContext;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface InProgressPresentationSnapshot extends PresentationBase {
|
|
62
|
+
status: "in_progress";
|
|
63
|
+
ended_at: null;
|
|
64
|
+
outcome: null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface EndedPresentationSnapshot extends PresentationBase {
|
|
68
|
+
status: "ended";
|
|
69
|
+
ended_at: Timestamp;
|
|
70
|
+
outcome: PresentationOutcome;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type PaywallPresentationSnapshot = InProgressPresentationSnapshot | EndedPresentationSnapshot;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @rocapine/rocalytics-sdk/paywall: the paywall presentation tracker.
|
|
2
|
+
//
|
|
3
|
+
// Reports each paywall presentation as snapshots in the shape of the paywall
|
|
4
|
+
// presentation contract v1 (docs/paywall-presentation-contract.md). Pass the
|
|
5
|
+
// tracker to the paywall host as its `observer`. Headless: no dependency.
|
|
6
|
+
|
|
7
|
+
export { createPaywallTracker, DEFAULT_PAYWALL_STORAGE_KEY, MAX_STORED_PRESENTATIONS } from "./tracker";
|
|
8
|
+
export type { PaywallTracker, PaywallTrackerConfig } from "./tracker";
|
|
9
|
+
export type {
|
|
10
|
+
PaywallObserver,
|
|
11
|
+
PaywallPresentationEnd,
|
|
12
|
+
PaywallPresentationHandle,
|
|
13
|
+
PaywallPresentationInfo,
|
|
14
|
+
PaywallSurface,
|
|
15
|
+
PaywallTransactionInfo,
|
|
16
|
+
} from "./observer";
|
|
17
|
+
export { LIBRARY_VERSION } from "../version";
|
|
18
|
+
export { createHttpSink } from "../core/httpSink";
|
|
19
|
+
export type { HttpSinkOptions } from "../core/httpSink";
|
|
20
|
+
export { memoryStorage } from "../core/storage";
|
|
21
|
+
export type { KeyValueStorage } from "../core/storage";
|
|
22
|
+
export type { Sink, SinkOutcome, SinkResult } from "../core/sink";
|
|
23
|
+
export type { Clock, Timers } from "../core/time";
|
|
24
|
+
export type { RunContextInput } from "../core/context";
|
|
25
|
+
export type { Diagnostic, DiagnosticHandler } from "../core/diagnostics";
|
|
26
|
+
export type {
|
|
27
|
+
EndedPresentationSnapshot,
|
|
28
|
+
InProgressPresentationSnapshot,
|
|
29
|
+
PaywallPresentationSnapshot,
|
|
30
|
+
PresentationOutcome,
|
|
31
|
+
PresentationOutcomeStatus,
|
|
32
|
+
PresentationPaywall,
|
|
33
|
+
PresentationStatus,
|
|
34
|
+
PresentationSurface,
|
|
35
|
+
PresentationTransaction,
|
|
36
|
+
} from "./contract";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The observer a paywall host calls, mirrored here so this package imports
|
|
2
|
+
// nothing from the host SDK: createPaywallTracker() satisfies it structurally.
|
|
3
|
+
// Names are those of the host SDK's PaywallObserver, verbatim.
|
|
4
|
+
|
|
5
|
+
export type PaywallSurface = "present" | "paywall_step";
|
|
6
|
+
|
|
7
|
+
export interface PaywallPresentationInfo {
|
|
8
|
+
moment: string;
|
|
9
|
+
paywallId: string;
|
|
10
|
+
audienceId: string | null;
|
|
11
|
+
renderMode: "elements" | "custom";
|
|
12
|
+
billing: "store" | "stripe";
|
|
13
|
+
surface: PaywallSurface;
|
|
14
|
+
variantKey?: string;
|
|
15
|
+
deploymentId?: string;
|
|
16
|
+
onboardingRun?: { runId: string; stepKey: string };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PaywallTransactionInfo {
|
|
20
|
+
/** The exact value the app passes to Rocalytics' `purchase` call: iOS StoreKit original transaction id (iOS join key); Android Play order id (GPA.…). */
|
|
21
|
+
originalTransactionIdentifier?: string;
|
|
22
|
+
/** Android only: the Play purchase token (RevenueCat `transaction.purchaseToken`). Android join key. */
|
|
23
|
+
purchaseToken?: string;
|
|
24
|
+
productId?: string;
|
|
25
|
+
/** True when the purchase only restored existing access. Not a conversion. */
|
|
26
|
+
restored?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PaywallPresentationEnd {
|
|
30
|
+
status: "purchased" | "dismissed" | "cancelled" | "error";
|
|
31
|
+
/** The host SDK's error reason; accepted as any string. */
|
|
32
|
+
reason?: string;
|
|
33
|
+
transaction?: PaywallTransactionInfo;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PaywallPresentationHandle {
|
|
37
|
+
shown(): void;
|
|
38
|
+
end(outcome: PaywallPresentationEnd): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PaywallObserver {
|
|
42
|
+
start(info: PaywallPresentationInfo): PaywallPresentationHandle | void;
|
|
43
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { WireRunContext } from "../core/context";
|
|
2
|
+
import { toTimestamp } from "../core/time";
|
|
3
|
+
import type { PaywallPresentationSnapshot, PresentationOutcome, PresentationPaywall, PresentationTransaction } from "./contract";
|
|
4
|
+
import type { PaywallPresentationEnd, PaywallPresentationInfo, PaywallTransactionInfo } from "./observer";
|
|
5
|
+
|
|
6
|
+
const OUTCOMES = new Set(["purchased", "dismissed", "cancelled", "error"]);
|
|
7
|
+
const id = (v: unknown) => typeof v === "string" && v.length >= 1 && v.length <= 128;
|
|
8
|
+
const storeId = (v: unknown): v is string => typeof v === "string" && v.length >= 1 && v.length <= 1024;
|
|
9
|
+
const KEY = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
|
|
10
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
11
|
+
|
|
12
|
+
/** Problems that would make the snapshot fail the schema. Empty when the info is usable. */
|
|
13
|
+
export function validateInfo(info: unknown): string[] {
|
|
14
|
+
if (!info || typeof info !== "object") return ["info is not an object"];
|
|
15
|
+
const i = info as Record<string, unknown>;
|
|
16
|
+
const errors: string[] = [];
|
|
17
|
+
if (!id(i.moment)) errors.push("moment must be 1 to 128 characters");
|
|
18
|
+
if (!id(i.paywallId)) errors.push("paywallId must be 1 to 128 characters");
|
|
19
|
+
if (i.audienceId !== null && !id(i.audienceId)) errors.push("audienceId must be null or 1 to 128 characters");
|
|
20
|
+
if (i.renderMode !== "elements" && i.renderMode !== "custom") errors.push("renderMode must be elements or custom");
|
|
21
|
+
if (i.billing !== "store" && i.billing !== "stripe") errors.push("billing must be store or stripe");
|
|
22
|
+
if (i.surface !== "present" && i.surface !== "paywall_step") errors.push("surface must be present or paywall_step");
|
|
23
|
+
if (i.variantKey !== undefined && !id(i.variantKey)) errors.push("variantKey must be 1 to 128 characters");
|
|
24
|
+
if (i.deploymentId !== undefined && !id(i.deploymentId)) errors.push("deploymentId must be 1 to 128 characters");
|
|
25
|
+
const run = i.onboardingRun as { runId?: unknown; stepKey?: unknown } | undefined;
|
|
26
|
+
if (run !== undefined && (typeof run !== "object" || run === null || typeof run.runId !== "string" || !UUID.test(run.runId) || typeof run.stepKey !== "string" || !KEY.test(run.stepKey))) {
|
|
27
|
+
errors.push("onboardingRun must be { runId: lowercase UUID, stepKey: key }");
|
|
28
|
+
}
|
|
29
|
+
return errors;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PresentationState {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
seq: number;
|
|
35
|
+
readonly startedAt: number;
|
|
36
|
+
shownAt: number | null;
|
|
37
|
+
endedAt: number | null;
|
|
38
|
+
outcome: PresentationOutcome | null;
|
|
39
|
+
readonly paywall: PresentationPaywall;
|
|
40
|
+
readonly surface: "present" | "paywall_step";
|
|
41
|
+
readonly onboardingRun?: { run_id: string; step_key: string };
|
|
42
|
+
readonly context: WireRunContext;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createState(info: PaywallPresentationInfo, presentationId: string, nowMs: number, context: WireRunContext): PresentationState {
|
|
46
|
+
const paywall: PresentationPaywall = {
|
|
47
|
+
moment_key: info.moment,
|
|
48
|
+
paywall_id: info.paywallId,
|
|
49
|
+
audience_id: info.audienceId,
|
|
50
|
+
render_mode: info.renderMode,
|
|
51
|
+
billing: info.billing,
|
|
52
|
+
};
|
|
53
|
+
if (info.variantKey !== undefined) paywall.variant_key = info.variantKey;
|
|
54
|
+
if (info.deploymentId !== undefined) paywall.deployment_id = info.deploymentId;
|
|
55
|
+
return {
|
|
56
|
+
id: presentationId,
|
|
57
|
+
seq: 0,
|
|
58
|
+
startedAt: nowMs,
|
|
59
|
+
shownAt: null,
|
|
60
|
+
endedAt: null,
|
|
61
|
+
outcome: null,
|
|
62
|
+
paywall,
|
|
63
|
+
surface: info.surface,
|
|
64
|
+
...(info.surface === "paywall_step" && info.onboardingRun
|
|
65
|
+
? { onboardingRun: { run_id: info.onboardingRun.runId, step_key: info.onboardingRun.stepKey } }
|
|
66
|
+
: {}),
|
|
67
|
+
context,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The latest timestamp recorded so far: the floor for the next one. */
|
|
72
|
+
const floor = (s: PresentationState) => Math.max(s.startedAt, s.shownAt ?? s.startedAt, s.endedAt ?? s.startedAt);
|
|
73
|
+
|
|
74
|
+
export function markShown(s: PresentationState, nowMs: number): "ok" | "already-shown" | "after-end" {
|
|
75
|
+
if (s.endedAt !== null) return "after-end";
|
|
76
|
+
if (s.shownAt !== null) return "already-shown";
|
|
77
|
+
s.shownAt = Math.max(nowMs, floor(s));
|
|
78
|
+
return "ok";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toTransaction(t: PaywallTransactionInfo | undefined): PresentationTransaction | undefined {
|
|
82
|
+
if (!t || typeof t !== "object") return undefined;
|
|
83
|
+
const out: PresentationTransaction = {};
|
|
84
|
+
if (storeId(t.originalTransactionIdentifier)) out.original_transaction_identifier = t.originalTransactionIdentifier;
|
|
85
|
+
if (storeId(t.purchaseToken)) out.purchase_token = t.purchaseToken;
|
|
86
|
+
if (storeId(t.productId)) out.product_id = t.productId;
|
|
87
|
+
if (t.restored === true) out.restored = true;
|
|
88
|
+
return Object.keys(out).length ? out : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function markEnded(s: PresentationState, end: PaywallPresentationEnd, nowMs: number): "ok" | "already-ended" | "invalid-status" {
|
|
92
|
+
if (s.endedAt !== null) return "already-ended";
|
|
93
|
+
if (!end || !OUTCOMES.has(end.status)) return "invalid-status";
|
|
94
|
+
const outcome: PresentationOutcome = { status: end.status };
|
|
95
|
+
if (end.status === "error" && typeof end.reason === "string" && KEY.test(end.reason)) outcome.reason = end.reason;
|
|
96
|
+
if (end.status === "purchased") {
|
|
97
|
+
const t = toTransaction(end.transaction);
|
|
98
|
+
if (t) outcome.transaction = t;
|
|
99
|
+
}
|
|
100
|
+
s.endedAt = Math.max(nowMs, floor(s));
|
|
101
|
+
// A purchase implies the paywall was on screen (P7): the contract requires shown_at with purchased.
|
|
102
|
+
if (end.status === "purchased" && s.shownAt === null) s.shownAt = s.endedAt;
|
|
103
|
+
s.outcome = outcome;
|
|
104
|
+
return "ok";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The next send: increments seq. */
|
|
108
|
+
export function toSnapshot(s: PresentationState, nowMs: number): PaywallPresentationSnapshot {
|
|
109
|
+
s.seq += 1;
|
|
110
|
+
const base = {
|
|
111
|
+
schema_version: 1 as const,
|
|
112
|
+
presentation_id: s.id,
|
|
113
|
+
seq: s.seq,
|
|
114
|
+
started_at: toTimestamp(s.startedAt),
|
|
115
|
+
shown_at: s.shownAt === null ? null : toTimestamp(s.shownAt),
|
|
116
|
+
sent_at: toTimestamp(Math.max(nowMs, floor(s))),
|
|
117
|
+
paywall: { ...s.paywall },
|
|
118
|
+
surface: s.surface,
|
|
119
|
+
...(s.onboardingRun ? { onboarding_run: { ...s.onboardingRun } } : {}),
|
|
120
|
+
context: { ...s.context },
|
|
121
|
+
};
|
|
122
|
+
return s.endedAt !== null && s.outcome !== null
|
|
123
|
+
? { ...base, status: "ended", ended_at: toTimestamp(s.endedAt), outcome: { ...s.outcome } }
|
|
124
|
+
: { ...base, status: "in_progress", ended_at: null, outcome: null };
|
|
125
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { captureRunContext, type RunContextInput } from "../core/context";
|
|
2
|
+
import { createDelivery, type Delivery, type Outbound, type RetryPolicy } from "../core/delivery";
|
|
3
|
+
import { safeDiagnostics, type DiagnosticHandler } from "../core/diagnostics";
|
|
4
|
+
import { createUuid } from "../core/ids";
|
|
5
|
+
import type { Sink } from "../core/sink";
|
|
6
|
+
import { createSerialStore, type KeyValueStorage, type SerialStore } from "../core/storage";
|
|
7
|
+
import { systemClock, systemTimers, type Clock, type Timers } from "../core/time";
|
|
8
|
+
import { LIBRARY_VERSION } from "../version";
|
|
9
|
+
import type { PaywallPresentationSnapshot } from "./contract";
|
|
10
|
+
import type { PaywallPresentationEnd, PaywallPresentationHandle, PaywallPresentationInfo } from "./observer";
|
|
11
|
+
import { createState, markEnded, markShown, toSnapshot, validateInfo, type PresentationState } from "./state";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_PAYWALL_STORAGE_KEY = "rocalytics-sdk:paywall-presentations";
|
|
14
|
+
export const MAX_STORED_PRESENTATIONS = 20;
|
|
15
|
+
|
|
16
|
+
export interface PaywallTrackerConfig {
|
|
17
|
+
/** Where snapshots go: `createRocalyticsPaywallSink(client)` from /client, or `createHttpSink`. */
|
|
18
|
+
sink: Sink<PaywallPresentationSnapshot>;
|
|
19
|
+
/** Device and app facts, or a function returning them, read once per presentation. */
|
|
20
|
+
context: RunContextInput | (() => RunContextInput);
|
|
21
|
+
/** Keeps unsent snapshots across restarts. AsyncStorage fits as is. */
|
|
22
|
+
storage?: KeyValueStorage;
|
|
23
|
+
storageKey?: string;
|
|
24
|
+
clock?: Clock;
|
|
25
|
+
timers?: Timers;
|
|
26
|
+
/** Presentation id minter. Must return a lowercase UUID. Default: UUIDv7. */
|
|
27
|
+
uuid?: () => string;
|
|
28
|
+
retry?: RetryPolicy;
|
|
29
|
+
attemptTimeoutMs?: number;
|
|
30
|
+
onDiagnostic?: DiagnosticHandler;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Satisfies the host SDK's `PaywallObserver`: pass it as `observer`. Never throws. */
|
|
34
|
+
export interface PaywallTracker {
|
|
35
|
+
start(info: PaywallPresentationInfo): PaywallPresentationHandle | undefined;
|
|
36
|
+
/** Resolves once queued storage writes are done. */
|
|
37
|
+
idle(): Promise<void>;
|
|
38
|
+
/** Stops retry timers. Unsent snapshots stay stored for the next launch. */
|
|
39
|
+
dispose(): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Stored = Record<string, Outbound<PaywallPresentationSnapshot>>;
|
|
43
|
+
|
|
44
|
+
export function createPaywallTracker(config: PaywallTrackerConfig): PaywallTracker {
|
|
45
|
+
const report = safeDiagnostics(config.onDiagnostic);
|
|
46
|
+
const clock = config.clock ?? systemClock;
|
|
47
|
+
const timers = config.timers ?? systemTimers;
|
|
48
|
+
const uuid = config.uuid ?? createUuid(clock);
|
|
49
|
+
const store: SerialStore<Stored> | null = config.storage
|
|
50
|
+
? createSerialStore<Stored>(config.storage, config.storageKey ?? DEFAULT_PAYWALL_STORAGE_KEY, (e) =>
|
|
51
|
+
report({ code: "storage", message: String(e) }),
|
|
52
|
+
)
|
|
53
|
+
: null;
|
|
54
|
+
const pending = new Map<string, Outbound<PaywallPresentationSnapshot>>();
|
|
55
|
+
const deliveries = new Map<string, Delivery<PaywallPresentationSnapshot>>();
|
|
56
|
+
let disposed = false;
|
|
57
|
+
// Off when the first read failed: stored state is then left untouched, never overwritten.
|
|
58
|
+
let persistenceOff = false;
|
|
59
|
+
// Writes wait for the first read, so a start() at launch cannot overwrite the stored map.
|
|
60
|
+
const loaded: Promise<Stored | null> = store
|
|
61
|
+
? store
|
|
62
|
+
.read()
|
|
63
|
+
.then((r) => {
|
|
64
|
+
if (r.status === "ok") return r.value;
|
|
65
|
+
if (r.status === "failed") {
|
|
66
|
+
persistenceOff = true;
|
|
67
|
+
report({ code: "storage", message: `reading stored presentations failed (${String(r.error)}): left untouched; this session runs without persistence` });
|
|
68
|
+
} else report({ code: "invalid-stored", message: "stored presentations are not JSON; ignored" });
|
|
69
|
+
return null;
|
|
70
|
+
})
|
|
71
|
+
.catch(() => null)
|
|
72
|
+
: Promise.resolve(null);
|
|
73
|
+
|
|
74
|
+
const persist = () => {
|
|
75
|
+
if (!store || persistenceOff) return;
|
|
76
|
+
if (pending.size > MAX_STORED_PRESENTATIONS) {
|
|
77
|
+
const oldest = [...pending.values()].sort((a, b) => a.body.started_at.localeCompare(b.body.started_at));
|
|
78
|
+
for (const o of oldest.slice(0, pending.size - MAX_STORED_PRESENTATIONS)) {
|
|
79
|
+
pending.delete(o.body.presentation_id);
|
|
80
|
+
report({ code: "storage-cap", message: `dropped unsent presentation ${o.body.presentation_id} from storage` });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// The map is built when the write's turn comes, not now: a write queued at launch
|
|
84
|
+
// must include the entries the initial read restores in the meantime.
|
|
85
|
+
void loaded.then(() => {
|
|
86
|
+
if (!persistenceOff) return store.saveWith(() => (pending.size ? Object.fromEntries(pending) : null));
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const deliveryFor = (presentationId: string): Delivery<PaywallPresentationSnapshot> => {
|
|
91
|
+
let d = deliveries.get(presentationId);
|
|
92
|
+
if (d) return d;
|
|
93
|
+
d = createDelivery<PaywallPresentationSnapshot>({
|
|
94
|
+
sink: config.sink,
|
|
95
|
+
timers,
|
|
96
|
+
retry: config.retry,
|
|
97
|
+
attemptTimeoutMs: config.attemptTimeoutMs,
|
|
98
|
+
onSettled: (item, result) => {
|
|
99
|
+
if (result.outcome === "rejected") {
|
|
100
|
+
report({ code: "rejected", message: `snapshot ${item.seq} rejected${result.reason ? `: ${result.reason}` : ""}` });
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
onPendingChange: (p) => {
|
|
104
|
+
if (p) pending.set(presentationId, p);
|
|
105
|
+
else {
|
|
106
|
+
pending.delete(presentationId);
|
|
107
|
+
deliveries.get(presentationId)?.stop();
|
|
108
|
+
deliveries.delete(presentationId);
|
|
109
|
+
}
|
|
110
|
+
persist();
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
deliveries.set(presentationId, d);
|
|
114
|
+
return d;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const isOutbound = (v: unknown): v is Outbound<PaywallPresentationSnapshot> => {
|
|
118
|
+
const o = v as { seq?: unknown; body?: { presentation_id?: unknown; seq?: unknown } } | null;
|
|
119
|
+
return !!o && typeof o.seq === "number" && !!o.body && typeof o.body === "object"
|
|
120
|
+
&& typeof o.body.presentation_id === "string" && o.body.seq === o.seq;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (store) {
|
|
124
|
+
void loaded.then((stored) => {
|
|
125
|
+
if (disposed || !stored || typeof stored !== "object") return;
|
|
126
|
+
for (const [id, item] of Object.entries(stored)) {
|
|
127
|
+
if (!isOutbound(item) || item.body.presentation_id !== id) {
|
|
128
|
+
report({ code: "invalid-stored", message: `ignored stored entry ${id}` });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (deliveries.has(id)) continue;
|
|
132
|
+
deliveryFor(id).enqueue(item);
|
|
133
|
+
}
|
|
134
|
+
}).catch((e) => report({ code: "internal", message: String(e) }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const send = (s: PresentationState) => {
|
|
138
|
+
if (disposed) return;
|
|
139
|
+
const body = toSnapshot(s, clock.now());
|
|
140
|
+
deliveryFor(s.id).enqueue({ seq: body.seq, body });
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const guard = (fn: () => void) => {
|
|
144
|
+
try {
|
|
145
|
+
fn();
|
|
146
|
+
} catch (e) {
|
|
147
|
+
report({ code: "internal", message: String(e) });
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const handle = (s: PresentationState): PaywallPresentationHandle => ({
|
|
152
|
+
shown: () =>
|
|
153
|
+
guard(() => {
|
|
154
|
+
const r = markShown(s, clock.now());
|
|
155
|
+
if (r === "ok") send(s);
|
|
156
|
+
else report({ code: r, message: `shown() ignored: ${r}`, runId: s.id });
|
|
157
|
+
}),
|
|
158
|
+
end: (outcome: PaywallPresentationEnd) =>
|
|
159
|
+
guard(() => {
|
|
160
|
+
const r = markEnded(s, outcome, clock.now());
|
|
161
|
+
if (r === "ok") send(s);
|
|
162
|
+
else report({ code: r, message: `end() ignored: ${r}`, runId: s.id });
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
start(info) {
|
|
168
|
+
try {
|
|
169
|
+
const problems = validateInfo(info);
|
|
170
|
+
if (problems.length) {
|
|
171
|
+
report({ code: "invalid-info", message: problems.join("; ") });
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
let input: RunContextInput;
|
|
175
|
+
try {
|
|
176
|
+
input = typeof config.context === "function" ? config.context() : config.context;
|
|
177
|
+
} catch (e) {
|
|
178
|
+
report({ code: "invalid-context", message: String(e) });
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
const captured = captureRunContext(input, LIBRARY_VERSION);
|
|
182
|
+
if (!captured.ok) {
|
|
183
|
+
report({ code: "invalid-context", message: captured.errors.join("; ") });
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
const s = createState(info, uuid(), clock.now(), captured.context);
|
|
187
|
+
send(s);
|
|
188
|
+
return handle(s);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
report({ code: "internal", message: String(e) });
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
idle: () => (store ? store.idle() : Promise.resolve()),
|
|
195
|
+
dispose() {
|
|
196
|
+
disposed = true;
|
|
197
|
+
for (const d of deliveries.values()) d.stop();
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
package/src/version.ts
ADDED