@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,144 @@
|
|
|
1
|
+
// Onboarding run contract, schema_version 1: TypeScript types.
|
|
2
|
+
//
|
|
3
|
+
// The normative text is onboarding-run-contract.md; the machine-checkable shape
|
|
4
|
+
// is onboarding-run.schema.json. These types mirror the schema. Where a rule is
|
|
5
|
+
// a pattern or a limit (key characters, list lengths), the type says `string`
|
|
6
|
+
// or `T[]` and the comment names the rule; the schema enforces it.
|
|
7
|
+
//
|
|
8
|
+
// This file has no imports, so it can be copied into any TypeScript project.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* RFC 3339 timestamp in UTC with exactly three fractional digits and a `Z`
|
|
12
|
+
* suffix, e.g. `2026-01-10T09:00:00.000Z`.
|
|
13
|
+
*/
|
|
14
|
+
export type Timestamp = string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An identifier chosen by the app: 1 to 128 characters from `A-Z a-z 0-9 _ . : -`,
|
|
18
|
+
* starting with a letter or digit. Case-sensitive. Used for onboarding, step,
|
|
19
|
+
* question, option, slot and variant keys.
|
|
20
|
+
*/
|
|
21
|
+
export type Key = string;
|
|
22
|
+
|
|
23
|
+
export interface OnboardingIdentity {
|
|
24
|
+
/**
|
|
25
|
+
* Stable key for one flow. Never reused for a different flow. Each
|
|
26
|
+
* Studio-served A/B arm is its own onboarding, so it has its own key.
|
|
27
|
+
*/
|
|
28
|
+
key: Key;
|
|
29
|
+
/**
|
|
30
|
+
* Revision of the flow. 1 to 64 characters from `A-Z a-z 0-9 _ . : + -`,
|
|
31
|
+
* starting with a letter or digit. Must change whenever the manifest changes,
|
|
32
|
+
* except for the reserved version `draft`, which a Studio-served draft or
|
|
33
|
+
* preview must send because it has no deployment id.
|
|
34
|
+
*/
|
|
35
|
+
version: string;
|
|
36
|
+
/**
|
|
37
|
+
* In-flow variant, for an app that runs one flow (one key) with its own
|
|
38
|
+
* variants. Not set for a Studio-served run. Absent or null otherwise.
|
|
39
|
+
*/
|
|
40
|
+
variant_key?: Key | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Optional links to Studio records. Opaque strings of 1 to 128 characters; numeric ids are sent in decimal. */
|
|
44
|
+
export interface StudioLinks {
|
|
45
|
+
onboarding_id?: string | null;
|
|
46
|
+
deployment_id?: string | null;
|
|
47
|
+
audience_id?: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Captured once, at run start, and repeated unchanged in every snapshot. */
|
|
51
|
+
export interface RunContext {
|
|
52
|
+
/** Marketing version, e.g. `2.4.0`. 1 to 32 characters. */
|
|
53
|
+
app_version: string;
|
|
54
|
+
/** Build number, 1 to 32 characters, or null on a platform with no such concept. */
|
|
55
|
+
build: string | null;
|
|
56
|
+
platform: "ios" | "android" | "web";
|
|
57
|
+
/** OS version, 1 to 32 characters, or null when the platform cannot report one. */
|
|
58
|
+
os_version: string | null;
|
|
59
|
+
/** BCP 47 language tag the onboarding is shown in, e.g. `fr-FR`. */
|
|
60
|
+
locale: string;
|
|
61
|
+
/** IANA time zone, e.g. `Europe/Paris`. At most 64 characters. */
|
|
62
|
+
timezone: string;
|
|
63
|
+
/** Version of the tracking library that produced the payload. 1 to 32 characters. */
|
|
64
|
+
library_version?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ManifestStep {
|
|
68
|
+
step_key: Key;
|
|
69
|
+
/** Steps sharing a slot are alternatives shown at one position. Must be contiguous. */
|
|
70
|
+
slot?: Key;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Declared once, at run start, and repeated unchanged in every snapshot. */
|
|
74
|
+
export interface Manifest {
|
|
75
|
+
/** Every step the flow can show, in flow order. 1 to 200 entries. */
|
|
76
|
+
steps: ManifestStep[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Run-level custom property value. Strings are at most 256 characters; numbers are finite. */
|
|
80
|
+
export type PropertyValue = string | number | boolean | null;
|
|
81
|
+
|
|
82
|
+
/** At most 20 keys, each matching `^[a-z][a-z0-9_]{0,39}$`. */
|
|
83
|
+
export type Properties = Record<string, PropertyValue>;
|
|
84
|
+
|
|
85
|
+
export type Answer =
|
|
86
|
+
| { question_key: Key; kind: "single"; value: Key }
|
|
87
|
+
/** At most 50 option keys, unique. */
|
|
88
|
+
| { question_key: Key; kind: "multi"; value: Key[] }
|
|
89
|
+
| { question_key: Key; kind: "numeric"; value: number; unit?: Key }
|
|
90
|
+
/** At most 1,000 characters. Never aggregated. */
|
|
91
|
+
| { question_key: Key; kind: "text"; value: string };
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One visit to a screen the user was shown. A step that was not shown has no
|
|
95
|
+
* entry; whether the run passed over it is derived from the manifest.
|
|
96
|
+
*/
|
|
97
|
+
export interface StepEntry {
|
|
98
|
+
step_key: Key;
|
|
99
|
+
entered_at: Timestamp;
|
|
100
|
+
/**
|
|
101
|
+
* Null only on the last entry of an in_progress run. In a truncated run the
|
|
102
|
+
* last kept entry may already be closed, and keeps its own exit on completion.
|
|
103
|
+
*/
|
|
104
|
+
exited_at: Timestamp | null;
|
|
105
|
+
/** At most 50. `question_key` is unique within an entry. */
|
|
106
|
+
answers: Answer[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface RunSnapshotBase {
|
|
110
|
+
schema_version: 1;
|
|
111
|
+
/** Client-minted, lowercase UUID. One per run. */
|
|
112
|
+
run_id: string;
|
|
113
|
+
/** Starts at 1 and increases with every send of this run. A retry reuses it. */
|
|
114
|
+
seq: number;
|
|
115
|
+
started_at: Timestamp;
|
|
116
|
+
/** Client clock when this snapshot was sent. Advisory only. */
|
|
117
|
+
sent_at: Timestamp;
|
|
118
|
+
onboarding: OnboardingIdentity;
|
|
119
|
+
studio?: StudioLinks;
|
|
120
|
+
context: RunContext;
|
|
121
|
+
manifest: Manifest;
|
|
122
|
+
properties?: Properties;
|
|
123
|
+
/**
|
|
124
|
+
* Present once the tracker has stopped recording (no new entries, answers or
|
|
125
|
+
* property changes) because recording more would break the 500-entry or
|
|
126
|
+
* 256 KiB limit. What was recorded is kept.
|
|
127
|
+
*/
|
|
128
|
+
truncated?: true;
|
|
129
|
+
/** Every entry so far, in the order they happened. 1 to 500 entries. */
|
|
130
|
+
steps: StepEntry[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface InProgressRunSnapshot extends RunSnapshotBase {
|
|
134
|
+
status: "in_progress";
|
|
135
|
+
completed_at: null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface CompletedRunSnapshot extends RunSnapshotBase {
|
|
139
|
+
status: "completed";
|
|
140
|
+
completed_at: Timestamp;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** One send. Each send replaces the run's previous snapshot. */
|
|
144
|
+
export type OnboardingRunSnapshot = InProgressRunSnapshot | CompletedRunSnapshot;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @rocapine/rocalytics-sdk/onboarding: the onboarding run tracker.
|
|
2
|
+
//
|
|
3
|
+
// Reports one run of an onboarding as snapshots in the shape of the onboarding
|
|
4
|
+
// run contract v1 (docs/onboarding-run-contract.md). Headless: no renderer,
|
|
5
|
+
// router or UI dependency, so a hand-coded onboarding uses it as is.
|
|
6
|
+
|
|
7
|
+
export { createOnboardingRunTracker, onboardingRun } from "./tracker";
|
|
8
|
+
export type { ExitOptions, OnboardingRun, OnboardingRunTracker, TrackerConfig } from "./tracker";
|
|
9
|
+
export type { AnswerInput, ManifestInput, OnboardingInput, StartOptions, StudioInput } from "./input";
|
|
10
|
+
export { MAX_ENTRIES, RECORDING_BUDGET } from "./state";
|
|
11
|
+
export { LIBRARY_VERSION } from "../version";
|
|
12
|
+
|
|
13
|
+
// Transport and environment pieces a host needs to configure the tracker.
|
|
14
|
+
export { createHttpSink } from "../core/httpSink";
|
|
15
|
+
export type { HttpSinkOptions } from "../core/httpSink";
|
|
16
|
+
export { memoryStorage } from "../core/storage";
|
|
17
|
+
export type { KeyValueStorage } from "../core/storage";
|
|
18
|
+
export type { Sink, SinkOutcome, SinkResult } from "../core/sink";
|
|
19
|
+
export type { Clock, Timers } from "../core/time";
|
|
20
|
+
export type { RunContextInput } from "../core/context";
|
|
21
|
+
export type { Diagnostic, DiagnosticHandler } from "../core/diagnostics";
|
|
22
|
+
|
|
23
|
+
// The contract's payload types, exactly as docs/onboarding-run.types.ts defines them.
|
|
24
|
+
export type {
|
|
25
|
+
Answer,
|
|
26
|
+
CompletedRunSnapshot,
|
|
27
|
+
InProgressRunSnapshot,
|
|
28
|
+
Key,
|
|
29
|
+
Manifest,
|
|
30
|
+
ManifestStep,
|
|
31
|
+
OnboardingIdentity,
|
|
32
|
+
OnboardingRunSnapshot,
|
|
33
|
+
Properties,
|
|
34
|
+
PropertyValue,
|
|
35
|
+
RunContext,
|
|
36
|
+
StepEntry,
|
|
37
|
+
StudioLinks,
|
|
38
|
+
Timestamp,
|
|
39
|
+
} from "./contract";
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// The tracker's public input types (camelCase, as an app writes them) and their
|
|
2
|
+
// validation against the contract. Anything that would make the ingest reject a
|
|
3
|
+
// snapshot is caught here, so the tracker never sends it.
|
|
4
|
+
import { codePointLength } from "../core/text";
|
|
5
|
+
import type { Answer, Manifest, OnboardingIdentity, Properties, PropertyValue, StudioLinks } from "./contract";
|
|
6
|
+
|
|
7
|
+
export interface OnboardingInput {
|
|
8
|
+
/** A stable key for this flow. Never reused for a different flow. */
|
|
9
|
+
key: string;
|
|
10
|
+
/** The flow's revision. Must change whenever the manifest or a question's keys change. */
|
|
11
|
+
version: string;
|
|
12
|
+
/** An in-flow variant, for an app running one flow under one key with its own variants. */
|
|
13
|
+
variantKey?: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ManifestInput {
|
|
17
|
+
/** Every step the flow can show, in flow order. Steps sharing a `slot` are alternatives at one position. */
|
|
18
|
+
steps: { stepKey: string; slot?: string }[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Studio ids are strings. A number passed at run time (from untyped JSON, for
|
|
23
|
+
* example) is sent in decimal when it is a safe non-negative integer; any
|
|
24
|
+
* other number makes the start invalid.
|
|
25
|
+
*/
|
|
26
|
+
export interface StudioInput {
|
|
27
|
+
/** Null and undefined both mean absent. */
|
|
28
|
+
onboardingId?: string | null;
|
|
29
|
+
/** Null and undefined both mean absent; a Studio-served run with none is a draft. */
|
|
30
|
+
deploymentId?: string | null;
|
|
31
|
+
audienceId?: string | null;
|
|
32
|
+
/** A Studio draft or preview: always sent as version `draft`. */
|
|
33
|
+
draft?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface StartBase {
|
|
37
|
+
manifest: ManifestInput;
|
|
38
|
+
/** Run-level scalar properties for slicing runs. No personal data. */
|
|
39
|
+
properties?: Properties;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Starts a run. Either the app declares `onboarding`, or a Studio-served flow
|
|
44
|
+
* passes `studio.onboardingId` and the identity defaults to the Studio
|
|
45
|
+
* onboarding id (key) and deployment id (version, `draft` without one).
|
|
46
|
+
*/
|
|
47
|
+
export type StartOptions =
|
|
48
|
+
| (StartBase & { onboarding: OnboardingInput; studio?: StudioInput })
|
|
49
|
+
| (StartBase & { onboarding?: undefined; studio: StudioInput & { onboardingId: string } });
|
|
50
|
+
|
|
51
|
+
export type AnswerInput =
|
|
52
|
+
| { questionKey: string; kind: "single"; value: string }
|
|
53
|
+
| { questionKey: string; kind: "multi"; value: string[] }
|
|
54
|
+
| { questionKey: string; kind: "numeric"; value: number; unit?: string }
|
|
55
|
+
| { questionKey: string; kind: "text"; value: string };
|
|
56
|
+
|
|
57
|
+
const KEY = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
|
|
58
|
+
const VERSION = /^[A-Za-z0-9][A-Za-z0-9_.:+-]{0,63}$/;
|
|
59
|
+
const PROPERTY_KEY = /^[a-z][a-z0-9_]{0,39}$/;
|
|
60
|
+
|
|
61
|
+
export const MAX_MANIFEST_STEPS = 200;
|
|
62
|
+
export const MAX_ANSWERS = 50;
|
|
63
|
+
export const MAX_PROPERTIES = 20;
|
|
64
|
+
|
|
65
|
+
export const isKey = (v: unknown): v is string => typeof v === "string" && KEY.test(v);
|
|
66
|
+
const isLink = (v: unknown): v is string => typeof v === "string" && codePointLength(v) >= 1 && codePointLength(v) <= 128;
|
|
67
|
+
|
|
68
|
+
export interface ValidStart {
|
|
69
|
+
onboarding: OnboardingIdentity;
|
|
70
|
+
studio?: StudioLinks;
|
|
71
|
+
manifest: Manifest;
|
|
72
|
+
properties?: Properties;
|
|
73
|
+
/** Problems that were corrected rather than fatal: [code, message]. */
|
|
74
|
+
warnings: [string, string][];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A Studio id that is a safe non-negative integer, in decimal (contract 3.1); anything else as it is. */
|
|
78
|
+
const decimalId = (v: unknown): unknown => (typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? String(v) : v);
|
|
79
|
+
|
|
80
|
+
export function validateStart(options: StartOptions): { ok: true; value: ValidStart } | { ok: false; errors: string[] } {
|
|
81
|
+
const errors: string[] = [];
|
|
82
|
+
const warnings: [string, string][] = [];
|
|
83
|
+
if (!options || typeof options !== "object") return { ok: false, errors: ["start options are not an object"] };
|
|
84
|
+
|
|
85
|
+
// Studio links. Read once, with numeric ids converted, by both the links and the identity below.
|
|
86
|
+
const studioIn: StudioInput | undefined =
|
|
87
|
+
options.studio && typeof options.studio === "object"
|
|
88
|
+
? {
|
|
89
|
+
...options.studio,
|
|
90
|
+
onboardingId: decimalId(options.studio.onboardingId) as string | null | undefined,
|
|
91
|
+
deploymentId: decimalId(options.studio.deploymentId) as string | null | undefined,
|
|
92
|
+
audienceId: decimalId(options.studio.audienceId) as string | null | undefined,
|
|
93
|
+
}
|
|
94
|
+
: options.studio;
|
|
95
|
+
let studio: StudioLinks | undefined;
|
|
96
|
+
if (studioIn !== undefined) {
|
|
97
|
+
if (!studioIn || typeof studioIn !== "object") errors.push("studio is not an object");
|
|
98
|
+
else {
|
|
99
|
+
studio = {};
|
|
100
|
+
const links: [keyof StudioLinks, unknown][] = [
|
|
101
|
+
["onboarding_id", studioIn.onboardingId],
|
|
102
|
+
["deployment_id", studioIn.deploymentId],
|
|
103
|
+
["audience_id", studioIn.audienceId],
|
|
104
|
+
];
|
|
105
|
+
for (const [field, value] of links) {
|
|
106
|
+
if (value === undefined || value === null) continue;
|
|
107
|
+
if (!isLink(value)) errors.push(`studio.${field} must be 1 to 128 characters`);
|
|
108
|
+
else studio[field] = value;
|
|
109
|
+
}
|
|
110
|
+
if (Object.keys(studio).length === 0) studio = undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const draft = !!studioIn?.draft;
|
|
114
|
+
|
|
115
|
+
// Identity.
|
|
116
|
+
let onboarding: OnboardingIdentity | undefined;
|
|
117
|
+
if (options.onboarding) {
|
|
118
|
+
const { key, version, variantKey } = options.onboarding;
|
|
119
|
+
if (!isKey(key)) errors.push("onboarding.key must be 1 to 128 characters from A-Z a-z 0-9 _ . : -");
|
|
120
|
+
if (!draft && (typeof version !== "string" || !VERSION.test(version))) {
|
|
121
|
+
errors.push("onboarding.version must be 1 to 64 characters from A-Z a-z 0-9 _ . : + -");
|
|
122
|
+
}
|
|
123
|
+
if (draft && version !== "draft") warnings.push(["draft-version", `a Studio draft sends version "draft", not "${version}"`]);
|
|
124
|
+
onboarding = { key, version: draft ? "draft" : version };
|
|
125
|
+
if (variantKey !== undefined && variantKey !== null) {
|
|
126
|
+
// Studio-served means it carries a Studio link (or is a Studio draft), not that a `studio` object was passed.
|
|
127
|
+
if (studio || draft) {
|
|
128
|
+
warnings.push(["studio-variant-dropped", "a Studio-served run does not carry variant_key: each Studio arm is its own onboarding key"]);
|
|
129
|
+
} else if (!isKey(variantKey)) errors.push("onboarding.variantKey must be a key");
|
|
130
|
+
else onboarding.variant_key = variantKey;
|
|
131
|
+
}
|
|
132
|
+
} else if (studioIn && isKey(studioIn.onboardingId)) {
|
|
133
|
+
// A null deployment id is absent, as it is for the links above: a Studio-served run with none is a draft (3.1).
|
|
134
|
+
const deployment: unknown = studioIn.deploymentId;
|
|
135
|
+
const version = draft || deployment === undefined || deployment === null ? "draft" : deployment;
|
|
136
|
+
if (typeof version !== "string" || !VERSION.test(version)) {
|
|
137
|
+
errors.push("studio.deploymentId cannot be used as onboarding.version");
|
|
138
|
+
} else {
|
|
139
|
+
onboarding = { key: studioIn.onboardingId, version };
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
errors.push("either onboarding { key, version } or studio.onboardingId is required");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Manifest.
|
|
146
|
+
const manifest: Manifest = { steps: [] };
|
|
147
|
+
const stepsIn = options.manifest?.steps;
|
|
148
|
+
if (!Array.isArray(stepsIn) || stepsIn.length < 1 || stepsIn.length > MAX_MANIFEST_STEPS) {
|
|
149
|
+
errors.push(`manifest.steps must hold 1 to ${MAX_MANIFEST_STEPS} steps`);
|
|
150
|
+
} else {
|
|
151
|
+
const seen = new Set<string>();
|
|
152
|
+
const closedSlots = new Set<string>();
|
|
153
|
+
stepsIn.forEach((s, i) => {
|
|
154
|
+
const stepKey = s?.stepKey;
|
|
155
|
+
if (!isKey(stepKey)) return void errors.push(`manifest.steps[${i}].stepKey is not a valid key`);
|
|
156
|
+
if (seen.has(stepKey)) errors.push(`manifest step "${stepKey}" is declared twice`);
|
|
157
|
+
seen.add(stepKey);
|
|
158
|
+
const slot = s.slot;
|
|
159
|
+
if (slot !== undefined) {
|
|
160
|
+
if (!isKey(slot)) errors.push(`manifest.steps[${i}].slot is not a valid key`);
|
|
161
|
+
if (closedSlots.has(slot)) errors.push(`slot "${slot}" is not contiguous`);
|
|
162
|
+
if (stepsIn[i + 1]?.slot !== slot) closedSlots.add(slot);
|
|
163
|
+
manifest.steps.push({ step_key: stepKey, slot });
|
|
164
|
+
} else {
|
|
165
|
+
manifest.steps.push({ step_key: stepKey });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Properties: invalid ones are dropped, not fatal.
|
|
171
|
+
let properties: Properties | undefined;
|
|
172
|
+
if (options.properties !== undefined) {
|
|
173
|
+
const { value, problems } = mergeProperties({}, options.properties);
|
|
174
|
+
problems.forEach((p) => warnings.push(p));
|
|
175
|
+
if (Object.keys(value).length) properties = value;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (errors.length || !onboarding) return { ok: false, errors };
|
|
179
|
+
return { ok: true, value: { onboarding, studio, manifest, properties, warnings } };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function propertyProblem(key: string, value: unknown): string | null {
|
|
183
|
+
if (!PROPERTY_KEY.test(key)) return `property key "${key}" must match ^[a-z][a-z0-9_]{0,39}$`;
|
|
184
|
+
if (value === null || typeof value === "boolean") return null;
|
|
185
|
+
if (typeof value === "number") return Number.isFinite(value) ? null : `property "${key}" is not a finite number`;
|
|
186
|
+
if (typeof value === "string") return codePointLength(value) <= 256 ? null : `property "${key}" is longer than 256 characters`;
|
|
187
|
+
return `property "${key}" must be a string, number, boolean or null`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** `base` with `patch` applied, keeping only valid entries and at most 20 keys. */
|
|
191
|
+
export function mergeProperties(base: Properties, patch: unknown): { value: Properties; problems: [string, string][] } {
|
|
192
|
+
const value: Properties = { ...base };
|
|
193
|
+
const problems: [string, string][] = [];
|
|
194
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
|
195
|
+
return { value, problems: [["invalid-property", "properties must be an object"]] };
|
|
196
|
+
}
|
|
197
|
+
for (const [k, v] of Object.entries(patch as Record<string, unknown>)) {
|
|
198
|
+
const problem = propertyProblem(k, v);
|
|
199
|
+
if (problem) {
|
|
200
|
+
problems.push(["invalid-property", problem]);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (!Object.prototype.hasOwnProperty.call(value, k) && Object.keys(value).length >= MAX_PROPERTIES) {
|
|
204
|
+
problems.push(["too-many-properties", `property "${k}" dropped: at most ${MAX_PROPERTIES}`]);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
value[k] = v as PropertyValue;
|
|
208
|
+
}
|
|
209
|
+
return { value, problems };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** One answer in wire form, or the reason it cannot be sent. */
|
|
213
|
+
export function toAnswer(input: unknown): { ok: true; answer: Answer } | { ok: false; error: string } {
|
|
214
|
+
if (!input || typeof input !== "object") return { ok: false, error: "an answer must be an object" };
|
|
215
|
+
const a = input as Record<string, unknown>;
|
|
216
|
+
const q = a.questionKey;
|
|
217
|
+
if (!isKey(q)) return { ok: false, error: "questionKey is not a valid key" };
|
|
218
|
+
switch (a.kind) {
|
|
219
|
+
case "single":
|
|
220
|
+
return isKey(a.value)
|
|
221
|
+
? { ok: true, answer: { question_key: q, kind: "single", value: a.value } }
|
|
222
|
+
: { ok: false, error: `${q}: a single answer's value must be an option key` };
|
|
223
|
+
case "multi": {
|
|
224
|
+
if (!Array.isArray(a.value) || !a.value.every(isKey)) return { ok: false, error: `${q}: a multi answer's value must be option keys` };
|
|
225
|
+
const unique = [...new Set(a.value as string[])];
|
|
226
|
+
if (unique.length > MAX_ANSWERS) return { ok: false, error: `${q}: at most ${MAX_ANSWERS} options` };
|
|
227
|
+
return { ok: true, answer: { question_key: q, kind: "multi", value: unique } };
|
|
228
|
+
}
|
|
229
|
+
case "numeric": {
|
|
230
|
+
if (typeof a.value !== "number" || !Number.isFinite(a.value)) return { ok: false, error: `${q}: a numeric answer must be a finite number` };
|
|
231
|
+
if (a.unit !== undefined && !isKey(a.unit)) return { ok: false, error: `${q}: unit must be a key` };
|
|
232
|
+
return {
|
|
233
|
+
ok: true,
|
|
234
|
+
answer: a.unit === undefined
|
|
235
|
+
? { question_key: q, kind: "numeric", value: a.value }
|
|
236
|
+
: { question_key: q, kind: "numeric", value: a.value, unit: a.unit as string },
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
case "text":
|
|
240
|
+
return typeof a.value === "string" && codePointLength(a.value) <= 1000
|
|
241
|
+
? { ok: true, answer: { question_key: q, kind: "text", value: a.value } }
|
|
242
|
+
: { ok: false, error: `${q}: a text answer is at most 1,000 characters` };
|
|
243
|
+
default:
|
|
244
|
+
return { ok: false, error: `${q}: unknown answer kind ${JSON.stringify(a.kind)}` };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// What the tracker keeps in storage, and the check every stored value goes
|
|
2
|
+
// through before use. Storage outlives app versions and can be corrupted, so
|
|
3
|
+
// nothing read from it is trusted: an invalid part is dropped and reported,
|
|
4
|
+
// and the stored value is then rewritten without it.
|
|
5
|
+
import type { Outbound } from "../core/delivery";
|
|
6
|
+
import type { OnboardingRunSnapshot } from "./contract";
|
|
7
|
+
import type { RunState } from "./state";
|
|
8
|
+
|
|
9
|
+
export interface Persisted {
|
|
10
|
+
format: 1;
|
|
11
|
+
/** The destination of the sink of the tracker that wrote it; absent when that sink had none. */
|
|
12
|
+
destination?: string;
|
|
13
|
+
current: RunState | null;
|
|
14
|
+
outboxes: Record<string, Outbound<OnboardingRunSnapshot>>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
18
|
+
const TIMESTAMP = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/;
|
|
19
|
+
|
|
20
|
+
type Obj = Record<string, unknown>;
|
|
21
|
+
const isObj = (v: unknown): v is Obj => !!v && typeof v === "object" && !Array.isArray(v);
|
|
22
|
+
const isTimestamp = (v: unknown) => typeof v === "string" && TIMESTAMP.test(v);
|
|
23
|
+
const isSeq = (v: unknown) => typeof v === "number" && Number.isInteger(v) && v >= 1;
|
|
24
|
+
|
|
25
|
+
const isEntry = (e: unknown) =>
|
|
26
|
+
isObj(e) &&
|
|
27
|
+
typeof e.step_key === "string" &&
|
|
28
|
+
isTimestamp(e.entered_at) &&
|
|
29
|
+
(e.exited_at === null || isTimestamp(e.exited_at)) &&
|
|
30
|
+
Array.isArray(e.answers) &&
|
|
31
|
+
e.answers.every(isObj);
|
|
32
|
+
|
|
33
|
+
export function isRunState(v: unknown): v is RunState {
|
|
34
|
+
if (!isObj(v)) return false;
|
|
35
|
+
const { onboarding, context, manifest } = v;
|
|
36
|
+
return (
|
|
37
|
+
typeof v.runId === "string" && UUID.test(v.runId) &&
|
|
38
|
+
typeof v.lastSeq === "number" && Number.isInteger(v.lastSeq) && v.lastSeq >= 0 &&
|
|
39
|
+
isObj(onboarding) && typeof onboarding.key === "string" && typeof onboarding.version === "string" &&
|
|
40
|
+
isObj(context) &&
|
|
41
|
+
isObj(manifest) && Array.isArray(manifest.steps) && manifest.steps.length > 0 &&
|
|
42
|
+
manifest.steps.every((s) => isObj(s) && typeof s.step_key === "string") &&
|
|
43
|
+
Array.isArray(v.steps) && v.steps.every(isEntry) &&
|
|
44
|
+
(v.studio === undefined || isObj(v.studio)) &&
|
|
45
|
+
(v.properties === undefined || isObj(v.properties)) &&
|
|
46
|
+
typeof v.provisionalExit === "boolean" &&
|
|
47
|
+
typeof v.truncated === "boolean" &&
|
|
48
|
+
(v.status === "in_progress" || v.status === "completed") &&
|
|
49
|
+
(v.completedAt === null || isTimestamp(v.completedAt)) &&
|
|
50
|
+
typeof v.lastActiveAt === "number" && Number.isFinite(v.lastActiveAt) &&
|
|
51
|
+
(v.dirty === undefined || typeof v.dirty === "boolean")
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** An unsent snapshot of run `runId`: a positive seq and a body that is that run's snapshot at that seq. */
|
|
56
|
+
export function isOutbound(runId: string, v: unknown): v is Outbound<OnboardingRunSnapshot> {
|
|
57
|
+
if (!isObj(v) || !isSeq(v.seq) || !isObj(v.body)) return false;
|
|
58
|
+
const b = v.body;
|
|
59
|
+
return (
|
|
60
|
+
b.run_id === runId &&
|
|
61
|
+
b.seq === v.seq &&
|
|
62
|
+
(b.status === "in_progress" || b.status === "completed") &&
|
|
63
|
+
Array.isArray(b.steps) && b.steps.length > 0 &&
|
|
64
|
+
isObj(b.manifest) && isObj(b.onboarding) && isObj(b.context)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The usable parts of a stored value, and what was dropped. */
|
|
69
|
+
export function parsePersisted(raw: unknown): {
|
|
70
|
+
current: RunState | null;
|
|
71
|
+
outboxes: [string, Outbound<OnboardingRunSnapshot>][];
|
|
72
|
+
problems: string[];
|
|
73
|
+
} {
|
|
74
|
+
const problems: string[] = [];
|
|
75
|
+
if (!isObj(raw) || raw.format !== 1) {
|
|
76
|
+
return { current: null, outboxes: [], problems: ["unrecognised stored state"] };
|
|
77
|
+
}
|
|
78
|
+
let current: RunState | null = null;
|
|
79
|
+
if (raw.current !== null && raw.current !== undefined) {
|
|
80
|
+
if (isRunState(raw.current)) current = raw.current;
|
|
81
|
+
else problems.push("the stored run is malformed");
|
|
82
|
+
}
|
|
83
|
+
const outboxes: [string, Outbound<OnboardingRunSnapshot>][] = [];
|
|
84
|
+
if (isObj(raw.outboxes)) {
|
|
85
|
+
for (const [runId, item] of Object.entries(raw.outboxes)) {
|
|
86
|
+
if (isOutbound(runId, item)) outboxes.push([runId, item]);
|
|
87
|
+
else problems.push(`the stored snapshot of run "${runId}" is malformed`);
|
|
88
|
+
}
|
|
89
|
+
} else if (raw.outboxes !== undefined) {
|
|
90
|
+
problems.push("the stored snapshots are malformed");
|
|
91
|
+
}
|
|
92
|
+
return { current, outboxes, problems };
|
|
93
|
+
}
|