@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,855 @@
|
|
|
1
|
+
import { captureRunContext, type RunContextInput } from "../core/context";
|
|
2
|
+
import { createDelivery, type Delivery, type Outbound, type RetryPolicy } from "../core/delivery";
|
|
3
|
+
import { consoleDiagnostics, 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 { OnboardingRunSnapshot, Properties } from "./contract";
|
|
10
|
+
import { mergeProperties, toAnswer, validateStart, type AnswerInput, type StartOptions } from "./input";
|
|
11
|
+
import { parsePersisted, type Persisted } from "./persisted";
|
|
12
|
+
import {
|
|
13
|
+
completeState,
|
|
14
|
+
enter,
|
|
15
|
+
exit,
|
|
16
|
+
floorMs,
|
|
17
|
+
restore,
|
|
18
|
+
setProperties as setPropertiesOn,
|
|
19
|
+
toSnapshot,
|
|
20
|
+
type Note,
|
|
21
|
+
type Result,
|
|
22
|
+
type RunState,
|
|
23
|
+
} from "./state";
|
|
24
|
+
|
|
25
|
+
export interface TrackerConfig {
|
|
26
|
+
/** Where snapshots go. `createHttpSink` is the stock one. */
|
|
27
|
+
sink: Sink<OnboardingRunSnapshot>;
|
|
28
|
+
/** Device and app facts, or a function returning them, read once per run at start. */
|
|
29
|
+
context: RunContextInput | (() => RunContextInput);
|
|
30
|
+
/**
|
|
31
|
+
* Persists the run so it can be resumed after the app is killed, and keeps an
|
|
32
|
+
* unsent snapshot across restarts. AsyncStorage fits as is. Without it,
|
|
33
|
+
* nothing survives a restart, and `resume()` resolves null unless a tracker
|
|
34
|
+
* disposed in this process on the same `storageKey` handed it a run.
|
|
35
|
+
*/
|
|
36
|
+
storage?: KeyValueStorage;
|
|
37
|
+
/** Storage key. Default `rocalytics-sdk:onboarding-run`. One restorable run per key. */
|
|
38
|
+
storageKey?: string;
|
|
39
|
+
clock?: Clock;
|
|
40
|
+
timers?: Timers;
|
|
41
|
+
/** Run id minter. Must return a lowercase UUID. Default: UUIDv7. */
|
|
42
|
+
uuid?: () => string;
|
|
43
|
+
/**
|
|
44
|
+
* Changes within this window go out as one send. Default 500 ms. Completion
|
|
45
|
+
* and background skip the debounce, but with `storage` every send, these
|
|
46
|
+
* included, first waits for its snapshot to be written: up to
|
|
47
|
+
* `persistTimeoutMs` (default 1,000 ms) later.
|
|
48
|
+
*/
|
|
49
|
+
debounceMs?: number;
|
|
50
|
+
retry?: RetryPolicy;
|
|
51
|
+
/** A send with no answer after this long is retried. Default 30,000 ms. */
|
|
52
|
+
attemptTimeoutMs?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Each snapshot is stored (with its seq) before it is handed to the sink, so
|
|
55
|
+
* a kill in between can never make a resumed run reuse that seq for a
|
|
56
|
+
* different body. A storage slower than this does not hold the send back any
|
|
57
|
+
* longer: it goes out anyway, and only then can a kill in that window cost
|
|
58
|
+
* the guarantee. Default 1,000 ms. Ignored without `storage`.
|
|
59
|
+
*/
|
|
60
|
+
persistTimeoutMs?: number;
|
|
61
|
+
/**
|
|
62
|
+
* How long a storage read may take before it is reported as slow. It does
|
|
63
|
+
* not cut `resume()` short: `resume()` waits for the read however long it
|
|
64
|
+
* takes. It bounds `idle()`, which waits at most this long for the read and
|
|
65
|
+
* as long again for queued writes, so up to about twice this; and with it,
|
|
66
|
+
* how long a tracker created after a `dispose()` on the same `storageKey`
|
|
67
|
+
* waits for the disposed one's writes before it reads. `start()` never waits
|
|
68
|
+
* for the read. Default 5,000 ms.
|
|
69
|
+
*/
|
|
70
|
+
storageReadTimeoutMs?: number;
|
|
71
|
+
/** Receives what the tracker declined to do. Default: `console.warn`. */
|
|
72
|
+
onDiagnostic?: DiagnosticHandler;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ExitOptions {
|
|
76
|
+
/** What was answered on this visit. Values are stable option keys, never displayed labels. */
|
|
77
|
+
answers?: AnswerInput[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One pass through an onboarding. Every method is safe to call at any time and never throws. */
|
|
81
|
+
export interface OnboardingRun {
|
|
82
|
+
/** Lowercase UUID, minted when the run starts. */
|
|
83
|
+
readonly runId: string;
|
|
84
|
+
/**
|
|
85
|
+
* The step of the last RECORDED entry; null before the first step. In a
|
|
86
|
+
* truncated run recording has stopped, so this can be an earlier screen than
|
|
87
|
+
* the one the user was on: restore the position from the app's own
|
|
88
|
+
* navigation state, not from this.
|
|
89
|
+
*/
|
|
90
|
+
readonly currentStepKey: string | null;
|
|
91
|
+
/** A screen is shown. Call it again for the same step after going back to it. */
|
|
92
|
+
enterStep(stepKey: string): void;
|
|
93
|
+
/** The user leaves a screen, with what they answered there. */
|
|
94
|
+
exitStep(stepKey: string, options?: ExitOptions): void;
|
|
95
|
+
/** Adds or changes run properties. */
|
|
96
|
+
setProperties(properties: Properties): void;
|
|
97
|
+
/** The onboarding is finished. Final: the run records nothing after it. */
|
|
98
|
+
complete(): void;
|
|
99
|
+
/** The app moved to the background: records the moment and sends now. */
|
|
100
|
+
background(): void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface OnboardingRunTracker {
|
|
104
|
+
/** Starts a new run. A run already in progress is left as it is, and stays in progress (a replay is a new run). */
|
|
105
|
+
start(options: StartOptions): OnboardingRun;
|
|
106
|
+
/**
|
|
107
|
+
* Resumes the run that was in progress when the app was killed, or resolves
|
|
108
|
+
* null. Call it only when the app restores the user's position; otherwise
|
|
109
|
+
* call `start`. The restored screen is recorded as a new entry for the last
|
|
110
|
+
* recorded step. A truncated run is still returned (so it can complete) but
|
|
111
|
+
* records no new entry. Null once a run was started this session.
|
|
112
|
+
*
|
|
113
|
+
* After a reconfigure, the run is the one the disposed tracker on this
|
|
114
|
+
* `storageKey` was recording, in its newest state, with storage or without;
|
|
115
|
+
* null if that tracker completed it or started another.
|
|
116
|
+
*
|
|
117
|
+
* It waits for the stored state to be read, however long that takes: a
|
|
118
|
+
* storage that never answers the read means it never resolves. An app that
|
|
119
|
+
* cannot wait should race it with its own timeout and call `start` instead.
|
|
120
|
+
*/
|
|
121
|
+
resume(): Promise<OnboardingRun | null>;
|
|
122
|
+
/** Resolves once queued storage writes are done. */
|
|
123
|
+
idle(): Promise<void>;
|
|
124
|
+
/**
|
|
125
|
+
* Stops recording without dropping what was recorded: a change on the
|
|
126
|
+
* debounce is sent, writes already queued land, and each unsent snapshot
|
|
127
|
+
* gets one last attempt (no retry timers). One the sink does not take is
|
|
128
|
+
* handed, in memory, to the next tracker created on the same `storageKey`
|
|
129
|
+
* (as `configure()` does), which sends it with its own sink and retries,
|
|
130
|
+
* with or without storage, provided its sink has the same `destination`
|
|
131
|
+
* (or, without one, is the same object). That tracker keeps this one's
|
|
132
|
+
* unsent snapshots in its writes from the start, sends none of them until
|
|
133
|
+
* these last attempts are answered, and drops any they delivered. A tracker
|
|
134
|
+
* disposed without a run of its own passes on what it was handed. With
|
|
135
|
+
* working storage the snapshot also stays stored for the next launch;
|
|
136
|
+
* without it, a snapshot is lost if the app is killed before a next tracker
|
|
137
|
+
* is created. Safe to call twice.
|
|
138
|
+
*/
|
|
139
|
+
dispose(): void;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
143
|
+
const NOTE_MESSAGES: Record<Note, string> = {
|
|
144
|
+
truncated: "the run reached a recording limit (500 entries or 261,120 bytes): recording has stopped",
|
|
145
|
+
"too-many-answers": "an entry holds at most 50 answers: the extra answer was dropped",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
function inertRun(runId: string): OnboardingRun {
|
|
149
|
+
return {
|
|
150
|
+
runId,
|
|
151
|
+
currentStepKey: null,
|
|
152
|
+
enterStep() {},
|
|
153
|
+
exitStep() {},
|
|
154
|
+
setProperties() {},
|
|
155
|
+
complete() {},
|
|
156
|
+
background() {},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** What a disposed tracker hands to the next one on its key. */
|
|
161
|
+
interface Handoff {
|
|
162
|
+
/** Per run, the snapshot the sink has not taken. */
|
|
163
|
+
unsent: Map<string, Outbound<OnboardingRunSnapshot>>;
|
|
164
|
+
/** Per run, the highest seq the sink settled: storage may still hold it, since nothing is written after dispose(). */
|
|
165
|
+
settled: Map<string, number>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Per storage key, process-wide, so they hold whichever storage object a host
|
|
169
|
+
// passes (a fresh adapter on every configure() included):
|
|
170
|
+
// - the last disposed tracker's writes: `drain`, bounded by its idle(), which a
|
|
171
|
+
// new tracker's first read waits for, so it sees what the old one staged;
|
|
172
|
+
// `landed`, unbounded; and each queued write, its predecessors' still to land
|
|
173
|
+
// included, so a new tracker that read before one landed writes its own
|
|
174
|
+
// state again;
|
|
175
|
+
// - what the last disposed tracker hands over, with storage or without, taken
|
|
176
|
+
// by the next tracker. Known at dispose(), and newer than any write still to
|
|
177
|
+
// land: its run, when it started or resumed one (null once that run is
|
|
178
|
+
// completed or replaced), or else the run it was handed; and its unsent
|
|
179
|
+
// snapshots, which the next tracker holds (and writes) from the start. Then,
|
|
180
|
+
// once its last attempts are over, what is still unsent and what was settled.
|
|
181
|
+
// A tracker disposed without a run passes on what it was handed;
|
|
182
|
+
// - how many live trackers use the key: two at once overwrite each other.
|
|
183
|
+
const lastDrainOnKey = new Map<string, { drain: Promise<void>; landed: Promise<void>; writes: Promise<void>[] }>();
|
|
184
|
+
// Only a tracker whose sink has the same destination takes it: one that
|
|
185
|
+
// sends elsewhere takes nothing, and discards the stored state it reads;
|
|
186
|
+
const handoffOnKey = new Map<
|
|
187
|
+
string,
|
|
188
|
+
{
|
|
189
|
+
sink: Sink<OnboardingRunSnapshot>;
|
|
190
|
+
destination: string | undefined;
|
|
191
|
+
run?: { current: RunState | null };
|
|
192
|
+
outboxes: Map<string, Outbound<OnboardingRunSnapshot>>;
|
|
193
|
+
left: Promise<Handoff>;
|
|
194
|
+
}
|
|
195
|
+
>();
|
|
196
|
+
const liveOnKey = new Map<string, number>();
|
|
197
|
+
|
|
198
|
+
export function createOnboardingRunTracker(config: TrackerConfig): OnboardingRunTracker {
|
|
199
|
+
const clock = config.clock ?? systemClock;
|
|
200
|
+
const timers = config.timers ?? systemTimers;
|
|
201
|
+
const report = safeDiagnostics(config.onDiagnostic);
|
|
202
|
+
const fallbackUuid = createUuid(clock);
|
|
203
|
+
const debounceMs = config.debounceMs ?? 500;
|
|
204
|
+
// Set only when the stored value was READ and found unparseable or malformed,
|
|
205
|
+
// so it is rewritten without the bad part. A failed read never sets it.
|
|
206
|
+
let rewrite = false;
|
|
207
|
+
// Set when the stored value must not be touched: storage could not be read
|
|
208
|
+
// (twice), or it holds a format this version does not know. The session then
|
|
209
|
+
// runs without persistence, and the stored value is left as it is.
|
|
210
|
+
let persistenceOff = false;
|
|
211
|
+
// The slow read was reported (once).
|
|
212
|
+
let readSlow = false;
|
|
213
|
+
const storageKey = config.storageKey ?? "rocalytics-sdk:onboarding-run";
|
|
214
|
+
const store: SerialStore<unknown> | null = config.storage
|
|
215
|
+
? createSerialStore<unknown>(config.storage, storageKey, (e) =>
|
|
216
|
+
report({ code: "storage", message: `storage failed: ${String(e)}` }),
|
|
217
|
+
)
|
|
218
|
+
: null;
|
|
219
|
+
if (store) {
|
|
220
|
+
const others = liveOnKey.get(storageKey) ?? 0;
|
|
221
|
+
if (others > 0) {
|
|
222
|
+
report({
|
|
223
|
+
code: "storage",
|
|
224
|
+
message: `another live tracker already uses storageKey "${storageKey}": trackers running at once must each use their own storageKey, or they overwrite each other's unsent snapshots`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
liveOnKey.set(storageKey, others + 1);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const deliveries = new Map<string, Delivery<OnboardingRunSnapshot>>();
|
|
231
|
+
const outboxes = new Map<string, Outbound<OnboardingRunSnapshot>>();
|
|
232
|
+
// Per run, the highest seq a sink settled, here or in the predecessor.
|
|
233
|
+
const settledSeqs = new Map<string, number>();
|
|
234
|
+
// Sends waiting for their snapshot's write (at most persistTimeoutMs).
|
|
235
|
+
const staging = new Set<Promise<void>>();
|
|
236
|
+
// Writes queued and not yet done.
|
|
237
|
+
const writing = new Set<Promise<void>>();
|
|
238
|
+
// Where this tracker's snapshots go; stamped on what it stores.
|
|
239
|
+
const destination = typeof config.sink?.destination === "string" ? config.sink.destination : undefined;
|
|
240
|
+
// Taken once: a later tracker on this key gets this one's handoff, not the predecessor's.
|
|
241
|
+
const offered = handoffOnKey.get(storageKey);
|
|
242
|
+
handoffOnKey.delete(storageKey);
|
|
243
|
+
// A predecessor that sent elsewhere: nothing it left is taken, in memory or from storage.
|
|
244
|
+
const destinationChanged =
|
|
245
|
+
!!offered &&
|
|
246
|
+
(offered.destination !== undefined && destination !== undefined ? offered.destination !== destination : offered.sink !== config.sink);
|
|
247
|
+
// Reported once, and only when something is discarded: a switch that leaves nothing is not a loss.
|
|
248
|
+
let discardReported = false;
|
|
249
|
+
const reportDiscarded = (message: string) => {
|
|
250
|
+
if (discardReported) return;
|
|
251
|
+
discardReported = true;
|
|
252
|
+
report({ code: "destination-changed", message });
|
|
253
|
+
};
|
|
254
|
+
const replacedElsewhere = `the tracker this one replaces on storageKey "${storageKey}" sent to another destination: what it left is discarded, not sent here`;
|
|
255
|
+
if (offered && destinationChanged) {
|
|
256
|
+
if (offered.run?.current) reportDiscarded(replacedElsewhere); // a run in progress
|
|
257
|
+
// Its unsent snapshots are known once its last attempts are answered: those delivered are not lost.
|
|
258
|
+
void offered.left.then((left) => left.unsent.size > 0 && reportDiscarded(replacedElsewhere)).catch(() => undefined);
|
|
259
|
+
}
|
|
260
|
+
const handed = destinationChanged ? undefined : offered;
|
|
261
|
+
const predecessor = handed?.left;
|
|
262
|
+
// The predecessor's run: when set, it replaces the stored one, which a late write may not have updated.
|
|
263
|
+
const handedRun = handed?.run;
|
|
264
|
+
const priorWrites = store ? lastDrainOnKey.get(storageKey) : undefined;
|
|
265
|
+
// The predecessor's unsent snapshots, and stored outboxes read before its last
|
|
266
|
+
// attempts were over: kept (so every write holds them), and sent after them.
|
|
267
|
+
const heldBack = new Map<string, Outbound<OnboardingRunSnapshot>>(handed?.outboxes);
|
|
268
|
+
for (const [runId, item] of heldBack) outboxes.set(runId, item);
|
|
269
|
+
let inheritedDone = !predecessor;
|
|
270
|
+
let live: Controller | null = null;
|
|
271
|
+
// A run was started (or resumed) this session: the previous launch's run is then abandoned,
|
|
272
|
+
// whether or not the run started here is still live.
|
|
273
|
+
let startedThisSession = false;
|
|
274
|
+
let persistedCurrent: RunState | null = handedRun?.current ?? null;
|
|
275
|
+
let disposed = false;
|
|
276
|
+
|
|
277
|
+
const mintRunId = () => {
|
|
278
|
+
try {
|
|
279
|
+
const id = config.uuid ? String(config.uuid()).toLowerCase() : fallbackUuid();
|
|
280
|
+
if (UUID.test(id)) return id;
|
|
281
|
+
report({ code: "invalid-uuid", message: `the uuid option returned "${id}", not a UUID: using the default` });
|
|
282
|
+
} catch (e) {
|
|
283
|
+
report({ code: "invalid-uuid", message: `the uuid option threw: ${String(e)}` });
|
|
284
|
+
}
|
|
285
|
+
return fallbackUuid();
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const delivered = (runId: string, item: Outbound<OnboardingRunSnapshot>) => item.seq <= (settledSeqs.get(runId) ?? 0);
|
|
289
|
+
|
|
290
|
+
const compose = (): Persisted | null => {
|
|
291
|
+
const current = live && live.state.status === "in_progress" ? live.state : persistedCurrent;
|
|
292
|
+
if (!current && outboxes.size === 0) return null;
|
|
293
|
+
return { format: 1, ...(destination === undefined ? {} : { destination }), current, outboxes: Object.fromEntries(outboxes) };
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// Persisted outboxes are merged in before anything is written, so a write
|
|
297
|
+
// composed this session never drops a snapshot left by the previous one.
|
|
298
|
+
// Every stored part is validated first; an invalid one is dropped, reported,
|
|
299
|
+
// and the stored value rewritten without it. This promise never rejects.
|
|
300
|
+
const loaded: Promise<void> = store
|
|
301
|
+
? store
|
|
302
|
+
.read(priorWrites?.drain) // after a disposed predecessor's last writes
|
|
303
|
+
.then((result) => {
|
|
304
|
+
if (result.status === "failed") {
|
|
305
|
+
persistenceOff = true;
|
|
306
|
+
return report({
|
|
307
|
+
code: "storage",
|
|
308
|
+
message: `reading stored state failed twice (${String(result.error)}): it is left untouched, and this session runs without persistence`,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
if (result.status === "invalid") {
|
|
312
|
+
rewrite = true;
|
|
313
|
+
return report({ code: "storage", message: "the stored state is not JSON: discarded" });
|
|
314
|
+
}
|
|
315
|
+
const raw = result.value;
|
|
316
|
+
if (raw === null) return;
|
|
317
|
+
const format = (raw as { format?: unknown }).format;
|
|
318
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw) && typeof format === "number" && format !== 1) {
|
|
319
|
+
persistenceOff = true;
|
|
320
|
+
return report({
|
|
321
|
+
code: "storage",
|
|
322
|
+
message: `the stored state has format ${format}, which this version does not know: it is left untouched, and this session runs without persistence`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
const { current, outboxes: stored, problems } = parsePersisted(raw);
|
|
326
|
+
if (problems.length) {
|
|
327
|
+
rewrite = true;
|
|
328
|
+
report({ code: "storage", message: `${problems.join("; ")}: discarded` });
|
|
329
|
+
}
|
|
330
|
+
// What is left, written for another destination: never sent here, so discarded.
|
|
331
|
+
if (destinationChanged || (raw as { destination?: unknown }).destination !== destination) {
|
|
332
|
+
if (!current && stored.length === 0) return;
|
|
333
|
+
rewrite = true;
|
|
334
|
+
return reportDiscarded(
|
|
335
|
+
destinationChanged
|
|
336
|
+
? replacedElsewhere
|
|
337
|
+
: "the stored state was written for another destination than this sink's: discarded, nothing of it is sent",
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
// Merged even after dispose(), so a write still queued keeps them; but
|
|
341
|
+
// sent only before it, and only once the predecessor's last attempts
|
|
342
|
+
// are over (the snapshots they deliver are dropped, not sent again).
|
|
343
|
+
for (const [runId, item] of stored) {
|
|
344
|
+
if ((outboxes.get(runId)?.seq ?? 0) >= item.seq) continue;
|
|
345
|
+
if (delivered(runId, item)) rewrite = true;
|
|
346
|
+
else if (!inheritedDone) {
|
|
347
|
+
outboxes.set(runId, item);
|
|
348
|
+
heldBack.set(runId, item);
|
|
349
|
+
} else if (disposed) outboxes.set(runId, item);
|
|
350
|
+
else deliveryFor(runId).enqueue(item);
|
|
351
|
+
}
|
|
352
|
+
if (current && current.status === "in_progress" && !handedRun) {
|
|
353
|
+
if (!startedThisSession) persistedCurrent = current;
|
|
354
|
+
else sendAbandoned(current); // a new run started before storage was read
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
.catch((e) => report({ code: "storage", message: `loading stored state failed: ${String(e)}` }))
|
|
358
|
+
.then(() => {
|
|
359
|
+
if (rewrite && !persistenceOff && !disposed) void persist();
|
|
360
|
+
rewrite = false;
|
|
361
|
+
})
|
|
362
|
+
: Promise.resolve();
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The predecessor's handoff, once its last attempts are over: what it left
|
|
366
|
+
* unsent is kept and sent from here, with the stored outboxes held back for
|
|
367
|
+
* it. Per run, only a snapshot newer than the one held here and than any the
|
|
368
|
+
* predecessor's sink settled. Never rejects.
|
|
369
|
+
*/
|
|
370
|
+
const inherited: Promise<void> = predecessor
|
|
371
|
+
? predecessor
|
|
372
|
+
.then((handoff) => {
|
|
373
|
+
for (const [runId, seq] of handoff.settled) settledSeqs.set(runId, Math.max(seq, settledSeqs.get(runId) ?? 0));
|
|
374
|
+
let dropped = false;
|
|
375
|
+
for (const [runId, item] of [...heldBack, ...handoff.unsent]) {
|
|
376
|
+
const held = outboxes.get(runId);
|
|
377
|
+
if (delivered(runId, item)) {
|
|
378
|
+
if (held === item) {
|
|
379
|
+
outboxes.delete(runId);
|
|
380
|
+
dropped = true;
|
|
381
|
+
}
|
|
382
|
+
} else if (!held || held === item || held.seq < item.seq) {
|
|
383
|
+
if (disposed) outboxes.set(runId, item); // passed on to the next tracker
|
|
384
|
+
else deliveryFor(runId).enqueue(item); // persisted by onPendingChange
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
heldBack.clear();
|
|
388
|
+
if (dropped) persist();
|
|
389
|
+
})
|
|
390
|
+
.catch((e) => report({ code: "internal-error", message: `adopting a disposed tracker's snapshots: ${String(e)}` }))
|
|
391
|
+
.then(() => {
|
|
392
|
+
inheritedDone = true;
|
|
393
|
+
})
|
|
394
|
+
: Promise.resolve();
|
|
395
|
+
|
|
396
|
+
// Each predecessor write that lands after this tracker read storage replaces
|
|
397
|
+
// what it wrote: this tracker writes its state again once it holds a state
|
|
398
|
+
// at least as new, that is once it has a run of its own or was handed the
|
|
399
|
+
// predecessor's. (Otherwise start() or resume() writes it anyway.)
|
|
400
|
+
if (priorWrites) {
|
|
401
|
+
let drained = false;
|
|
402
|
+
void priorWrites.drain.then(() => (drained = true));
|
|
403
|
+
for (const write of priorWrites.writes) {
|
|
404
|
+
void write.then(() => {
|
|
405
|
+
if (drained && (startedThisSession || handedRun || destinationChanged)) persist();
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* A run left in progress by the previous launch and not resumed: if it holds
|
|
412
|
+
* a change that never went into a send (killed inside the debounce window),
|
|
413
|
+
* send it now, so the latest snapshot is not lost (section 5).
|
|
414
|
+
*/
|
|
415
|
+
function sendAbandoned(state: RunState) {
|
|
416
|
+
if (!state.dirty || state.steps.length === 0) return;
|
|
417
|
+
const seq = state.lastSeq + 1;
|
|
418
|
+
stageAndSend(state.runId, { seq, body: toSnapshot(state, seq, Math.max(clock.now(), floorMs(state))) }, false);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const persistTimeoutMs = config.persistTimeoutMs ?? 1000;
|
|
422
|
+
const storageReadTimeoutMs = config.storageReadTimeoutMs ?? 5000;
|
|
423
|
+
|
|
424
|
+
/** `p`, or `false` once `ms` have passed without it settling. Never rejects. */
|
|
425
|
+
const within = <T>(p: Promise<T>, ms: number): Promise<T | false> =>
|
|
426
|
+
new Promise((resolve) => {
|
|
427
|
+
const timer = timers.setTimeout(() => resolve(false), ms);
|
|
428
|
+
p.then(
|
|
429
|
+
(v) => {
|
|
430
|
+
timers.clearTimeout(timer);
|
|
431
|
+
resolve(v);
|
|
432
|
+
},
|
|
433
|
+
() => {
|
|
434
|
+
timers.clearTimeout(timer);
|
|
435
|
+
resolve(false);
|
|
436
|
+
},
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Waits for the stored state to be read, at most storageReadTimeoutMs, and
|
|
442
|
+
* reports a read that takes longer. The read itself goes on: resume() still
|
|
443
|
+
* waits for it, and writes, each queued behind it and composed after it, go
|
|
444
|
+
* on with what it found once it lands.
|
|
445
|
+
*/
|
|
446
|
+
const waitLoaded = async (): Promise<boolean> => {
|
|
447
|
+
if (!store) return true;
|
|
448
|
+
const ok = await within(loaded.then(() => true), storageReadTimeoutMs);
|
|
449
|
+
if (!ok && !readSlow) {
|
|
450
|
+
readSlow = true;
|
|
451
|
+
report({
|
|
452
|
+
code: "storage",
|
|
453
|
+
message: `the stored state has not been read within ${storageReadTimeoutMs} ms: resume() waits for it`,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
return ok;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Queues a write of the whole tracker state, in order, right away; its value
|
|
461
|
+
* is composed when its turn comes (after the stored state was read). A write
|
|
462
|
+
* queued before dispose() still happens: that is how dispose() keeps a
|
|
463
|
+
* staged snapshot. Never rejects; resolves once the write is done or failed.
|
|
464
|
+
*/
|
|
465
|
+
const persist = (): Promise<void> => {
|
|
466
|
+
if (!store || disposed) return Promise.resolve();
|
|
467
|
+
const write = store.saveWith(async () => {
|
|
468
|
+
await loaded;
|
|
469
|
+
return persistenceOff ? undefined : compose();
|
|
470
|
+
});
|
|
471
|
+
writing.add(write);
|
|
472
|
+
void write.then(() => writing.delete(write));
|
|
473
|
+
return write;
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Stores a snapshot as the run's unsent one, together with the state that
|
|
478
|
+
* assigned its seq, and only then hands it to the sink (3.2: persist on
|
|
479
|
+
* every change). Waits at most `persistTimeoutMs` for the write.
|
|
480
|
+
*/
|
|
481
|
+
function stageAndSend(runId: string, item: Outbound<OnboardingRunSnapshot>, flush: boolean) {
|
|
482
|
+
outboxes.set(runId, item);
|
|
483
|
+
// Not gated on dispose(): a snapshot staged before it is still sent (once; see deliveryFor).
|
|
484
|
+
const send = () => {
|
|
485
|
+
const d = deliveryFor(runId);
|
|
486
|
+
d.enqueue(item);
|
|
487
|
+
if (flush) d.flush();
|
|
488
|
+
};
|
|
489
|
+
if (!store) return send();
|
|
490
|
+
let done = false;
|
|
491
|
+
let sent = () => {};
|
|
492
|
+
const staged = new Promise<void>((resolve) => (sent = resolve));
|
|
493
|
+
staging.add(staged);
|
|
494
|
+
const once = () => {
|
|
495
|
+
if (done) return;
|
|
496
|
+
done = true;
|
|
497
|
+
timers.clearTimeout(timer);
|
|
498
|
+
staging.delete(staged);
|
|
499
|
+
send();
|
|
500
|
+
sent();
|
|
501
|
+
};
|
|
502
|
+
const timer = timers.setTimeout(once, persistTimeoutMs);
|
|
503
|
+
void persist().then(once);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function deliveryFor(runId: string): Delivery<OnboardingRunSnapshot> {
|
|
507
|
+
let d = deliveries.get(runId);
|
|
508
|
+
if (!d) {
|
|
509
|
+
d = createDelivery<OnboardingRunSnapshot>({
|
|
510
|
+
sink: config.sink,
|
|
511
|
+
timers,
|
|
512
|
+
retry: config.retry,
|
|
513
|
+
attemptTimeoutMs: config.attemptTimeoutMs,
|
|
514
|
+
onPendingChange: (pending) => {
|
|
515
|
+
// Only ever moves forward: a newer snapshot may already be staged for this run.
|
|
516
|
+
if (pending && (outboxes.get(runId)?.seq ?? 0) <= pending.seq) {
|
|
517
|
+
outboxes.set(runId, pending);
|
|
518
|
+
persist();
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
onSettled: (item, result) => {
|
|
522
|
+
settledSeqs.set(runId, Math.max(item.seq, settledSeqs.get(runId) ?? 0));
|
|
523
|
+
const staged = outboxes.get(runId);
|
|
524
|
+
if (staged && staged.seq <= item.seq) {
|
|
525
|
+
outboxes.delete(runId);
|
|
526
|
+
persist();
|
|
527
|
+
}
|
|
528
|
+
if (result.outcome === "rejected") {
|
|
529
|
+
report({
|
|
530
|
+
code: "rejected",
|
|
531
|
+
runId,
|
|
532
|
+
message: `snapshot seq ${item.seq} was rejected by the ingest${result.reason ? `: ${result.reason}` : ""}`,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
deliveries.set(runId, d);
|
|
538
|
+
if (disposed) d.close(); // after dispose(): one last attempt per snapshot, no retry timers
|
|
539
|
+
}
|
|
540
|
+
return d;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Resolves once queued storage writes are done, however long they take. */
|
|
544
|
+
const writesDone = async () => {
|
|
545
|
+
if (!store) return;
|
|
546
|
+
for (let i = 0; i < 3; i++) {
|
|
547
|
+
await store.idle();
|
|
548
|
+
await Promise.resolve();
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
/** Waits up to storageReadTimeoutMs for the read, then as long again for `writes`. */
|
|
553
|
+
const idleWithin = async (writes: Promise<void>) => {
|
|
554
|
+
if (!store || !(await waitLoaded())) return;
|
|
555
|
+
await within(writes, storageReadTimeoutMs);
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* What this disposed tracker leaves unsent, once its last attempts are
|
|
560
|
+
* over: the predecessor's handoff taken in, every send that waited for its
|
|
561
|
+
* write handed to the sink, and no attempt in flight. Each attempt is bounded
|
|
562
|
+
* by attemptTimeoutMs, and each wait for a write by persistTimeoutMs.
|
|
563
|
+
*/
|
|
564
|
+
const leftUnsent = async (): Promise<Handoff> => {
|
|
565
|
+
await inherited;
|
|
566
|
+
do {
|
|
567
|
+
await Promise.all(staging);
|
|
568
|
+
await Promise.all([...deliveries.values()].map((d) => d.idle()));
|
|
569
|
+
} while (staging.size > 0);
|
|
570
|
+
return { unsent: new Map(outboxes), settled: new Map(settledSeqs) };
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
class Controller implements OnboardingRun {
|
|
574
|
+
state: RunState;
|
|
575
|
+
active = true;
|
|
576
|
+
private debounce: unknown = null;
|
|
577
|
+
|
|
578
|
+
constructor(state: RunState) {
|
|
579
|
+
this.state = state;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
get runId() {
|
|
583
|
+
return this.state.runId;
|
|
584
|
+
}
|
|
585
|
+
get currentStepKey() {
|
|
586
|
+
return this.state.steps[this.state.steps.length - 1]?.step_key ?? null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
private now() {
|
|
590
|
+
return Math.max(clock.now(), floorMs(this.state));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private diag(code: string, message: string) {
|
|
594
|
+
report({ code, message, runId: this.state.runId });
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Runs a public method body: never throws, and refuses once the run is over. */
|
|
598
|
+
private guard(name: string, body: () => void, allowCompleted = false) {
|
|
599
|
+
try {
|
|
600
|
+
if (disposed) return;
|
|
601
|
+
if (!this.active) return this.diag("run-inactive", `${name}: a newer run was started; this one records nothing more`);
|
|
602
|
+
if (this.state.status === "completed" && !allowCompleted) {
|
|
603
|
+
return this.diag("run-completed", `${name}: the run is completed and records nothing more`);
|
|
604
|
+
}
|
|
605
|
+
body();
|
|
606
|
+
} catch (e) {
|
|
607
|
+
this.diag("internal-error", `${name}: ${String(e)}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private apply(result: Result, send: "debounced" | "now") {
|
|
612
|
+
for (const note of result.notes) this.diag(note, NOTE_MESSAGES[note]);
|
|
613
|
+
this.state = result.changed ? { ...result.state, dirty: true } : result.state;
|
|
614
|
+
persist();
|
|
615
|
+
if (!result.changed) return;
|
|
616
|
+
if (send === "now" || debounceMs <= 0) this.sendNow();
|
|
617
|
+
else if (this.debounce === null) this.debounce = timers.setTimeout(() => this.sendNow(), debounceMs);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Sends the run now. `flush` also skips a backoff in progress (completion, background). */
|
|
621
|
+
sendNow(flush = false) {
|
|
622
|
+
if (this.debounce !== null) timers.clearTimeout(this.debounce);
|
|
623
|
+
this.debounce = null;
|
|
624
|
+
if (disposed || this.state.steps.length === 0) return;
|
|
625
|
+
const now = this.now();
|
|
626
|
+
const seq = this.state.lastSeq + 1;
|
|
627
|
+
const body = toSnapshot(this.state, seq, now);
|
|
628
|
+
this.state = {
|
|
629
|
+
...this.state,
|
|
630
|
+
lastSeq: seq,
|
|
631
|
+
lastActiveAt: this.state.status === "in_progress" ? now : this.state.lastActiveAt,
|
|
632
|
+
dirty: false,
|
|
633
|
+
};
|
|
634
|
+
stageAndSend(this.state.runId, { seq, body }, flush);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** A change waiting on the debounce is sent now. */
|
|
638
|
+
flushDebounce() {
|
|
639
|
+
if (this.debounce !== null) this.sendNow();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
deactivate() {
|
|
643
|
+
this.flushDebounce(); // a change waiting on the debounce still goes out
|
|
644
|
+
this.active = false;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
enterStep(stepKey: string) {
|
|
648
|
+
this.guard("enterStep", () => {
|
|
649
|
+
if (typeof stepKey !== "string" || !this.state.manifest.steps.some((s) => s.step_key === stepKey)) {
|
|
650
|
+
return this.diag("unknown-step", `enterStep: "${String(stepKey)}" is not in the manifest`);
|
|
651
|
+
}
|
|
652
|
+
const last = this.state.steps[this.state.steps.length - 1];
|
|
653
|
+
if (last && last.step_key === stepKey && last.exited_at === null) {
|
|
654
|
+
return this.diag("already-on-step", `enterStep: already on "${stepKey}"`);
|
|
655
|
+
}
|
|
656
|
+
this.apply(enter(this.state, stepKey, this.now()), "debounced");
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
exitStep(stepKey: string, options?: ExitOptions) {
|
|
661
|
+
this.guard("exitStep", () => {
|
|
662
|
+
const steps = this.state.steps;
|
|
663
|
+
let index = -1;
|
|
664
|
+
if (steps[steps.length - 1]?.step_key === stepKey) index = steps.length - 1;
|
|
665
|
+
else if (steps[steps.length - 2]?.step_key === stepKey) index = steps.length - 2;
|
|
666
|
+
if (index < 0) return this.diag("not-current-step", `exitStep: "${String(stepKey)}" is not the current step`);
|
|
667
|
+
const raw = options && Array.isArray(options.answers) ? options.answers : [];
|
|
668
|
+
if (options?.answers !== undefined && !Array.isArray(options.answers)) {
|
|
669
|
+
this.diag("invalid-answer", "exitStep: answers must be an array");
|
|
670
|
+
}
|
|
671
|
+
const answers = [];
|
|
672
|
+
for (const input of raw) {
|
|
673
|
+
const r = toAnswer(input);
|
|
674
|
+
if (r.ok) answers.push(r.answer);
|
|
675
|
+
else this.diag("invalid-answer", r.error);
|
|
676
|
+
}
|
|
677
|
+
this.apply(exit(this.state, index, answers, this.now()), "debounced");
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
setProperties(properties: Properties) {
|
|
682
|
+
this.guard("setProperties", () => {
|
|
683
|
+
const { value, problems } = mergeProperties(this.state.properties ?? {}, properties);
|
|
684
|
+
for (const [code, message] of problems) this.diag(code, message);
|
|
685
|
+
this.apply(setPropertiesOn(this.state, value, this.now()), "debounced");
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
complete() {
|
|
690
|
+
this.guard("complete", () => {
|
|
691
|
+
if (this.state.steps.length === 0) return this.diag("no-steps", "complete: no step was entered, so there is no run to send");
|
|
692
|
+
this.state = completeState(this.state, this.now());
|
|
693
|
+
if (live === this) live = null;
|
|
694
|
+
this.sendNow(true);
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
background() {
|
|
699
|
+
this.guard("background", () => {
|
|
700
|
+
if (this.state.status === "completed") return deliveryFor(this.state.runId).flush();
|
|
701
|
+
if (this.state.steps.length === 0) return this.diag("no-steps", "background: no step was entered yet");
|
|
702
|
+
this.state = { ...this.state, lastActiveAt: this.now() };
|
|
703
|
+
this.sendNow(true);
|
|
704
|
+
}, true);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return {
|
|
709
|
+
start(options) {
|
|
710
|
+
try {
|
|
711
|
+
if (disposed) return inertRun(mintRunId());
|
|
712
|
+
let contextInput: RunContextInput;
|
|
713
|
+
try {
|
|
714
|
+
contextInput = typeof config.context === "function" ? config.context() : config.context;
|
|
715
|
+
} catch (e) {
|
|
716
|
+
report({ code: "invalid-start", message: `the context function threw: ${String(e)}` });
|
|
717
|
+
return inertRun(mintRunId());
|
|
718
|
+
}
|
|
719
|
+
const context = captureRunContext(contextInput, LIBRARY_VERSION);
|
|
720
|
+
const valid = validateStart(options);
|
|
721
|
+
const errors = [...(context.ok ? [] : context.errors), ...(valid.ok ? [] : valid.errors)];
|
|
722
|
+
if (!context.ok || !valid.ok) {
|
|
723
|
+
report({ code: "invalid-start", message: `run not started, nothing will be sent: ${errors.join("; ")}` });
|
|
724
|
+
return inertRun(mintRunId());
|
|
725
|
+
}
|
|
726
|
+
for (const [code, message] of valid.value.warnings) report({ code, message });
|
|
727
|
+
|
|
728
|
+
if (live && live.state.status === "in_progress") {
|
|
729
|
+
report({ code: "run-replaced", runId: live.runId, message: "a new run was started; the previous one stays in progress" });
|
|
730
|
+
live.deactivate();
|
|
731
|
+
}
|
|
732
|
+
if (persistedCurrent) sendAbandoned(persistedCurrent);
|
|
733
|
+
persistedCurrent = null;
|
|
734
|
+
const { onboarding, studio, manifest, properties } = valid.value;
|
|
735
|
+
const state: RunState = {
|
|
736
|
+
runId: mintRunId(),
|
|
737
|
+
lastSeq: 0,
|
|
738
|
+
onboarding,
|
|
739
|
+
...(studio ? { studio } : {}),
|
|
740
|
+
context: context.context,
|
|
741
|
+
manifest,
|
|
742
|
+
...(properties ? { properties } : {}),
|
|
743
|
+
steps: [],
|
|
744
|
+
provisionalExit: false,
|
|
745
|
+
truncated: false,
|
|
746
|
+
status: "in_progress",
|
|
747
|
+
completedAt: null,
|
|
748
|
+
lastActiveAt: clock.now(),
|
|
749
|
+
};
|
|
750
|
+
live = new Controller(state);
|
|
751
|
+
startedThisSession = true;
|
|
752
|
+
persist();
|
|
753
|
+
return live;
|
|
754
|
+
} catch (e) {
|
|
755
|
+
report({ code: "internal-error", message: `start: ${String(e)}` });
|
|
756
|
+
return inertRun(fallbackUuid());
|
|
757
|
+
}
|
|
758
|
+
},
|
|
759
|
+
|
|
760
|
+
async resume() {
|
|
761
|
+
try {
|
|
762
|
+
void waitLoaded(); // reports a slow read
|
|
763
|
+
await loaded;
|
|
764
|
+
if (disposed || startedThisSession || !persistedCurrent) return null;
|
|
765
|
+
const state = persistedCurrent;
|
|
766
|
+
persistedCurrent = null;
|
|
767
|
+
const now = Math.max(clock.now(), floorMs(state), state.lastActiveAt);
|
|
768
|
+
const controller = new Controller(state);
|
|
769
|
+
live = controller;
|
|
770
|
+
startedThisSession = true;
|
|
771
|
+
const result = restore(state, now);
|
|
772
|
+
for (const note of result.notes) report({ code: note, runId: state.runId, message: NOTE_MESSAGES[note] });
|
|
773
|
+
controller.state = result.state;
|
|
774
|
+
persist();
|
|
775
|
+
controller.sendNow();
|
|
776
|
+
return controller;
|
|
777
|
+
} catch (e) {
|
|
778
|
+
report({ code: "internal-error", message: `resume: ${String(e)}` });
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
},
|
|
782
|
+
|
|
783
|
+
// Waits up to storageReadTimeoutMs for the read, then as long again for queued writes.
|
|
784
|
+
async idle() {
|
|
785
|
+
try {
|
|
786
|
+
await idleWithin(writesDone());
|
|
787
|
+
} catch {
|
|
788
|
+
// idle() never rejects into the host
|
|
789
|
+
}
|
|
790
|
+
},
|
|
791
|
+
|
|
792
|
+
dispose() {
|
|
793
|
+
if (disposed) return;
|
|
794
|
+
// A change still waiting on the debounce becomes a staged snapshot first.
|
|
795
|
+
try {
|
|
796
|
+
live?.flushDebounce();
|
|
797
|
+
} catch (e) {
|
|
798
|
+
report({ code: "internal-error", message: `dispose: ${String(e)}` });
|
|
799
|
+
}
|
|
800
|
+
// From here nothing records, nothing new starts, and no write is queued.
|
|
801
|
+
// What was staged before still goes through: its write is already queued,
|
|
802
|
+
// and its send is handed to a closed delivery (one last attempt, no retry
|
|
803
|
+
// timers). A snapshot the sink does not take is handed to the next
|
|
804
|
+
// tracker on this key, and stays in storage for the next launch.
|
|
805
|
+
disposed = true;
|
|
806
|
+
for (const d of deliveries.values()) d.close();
|
|
807
|
+
handoffOnKey.set(storageKey, {
|
|
808
|
+
sink: config.sink,
|
|
809
|
+
destination,
|
|
810
|
+
run: startedThisSession ? { current: live && live.state.status === "in_progress" ? live.state : null } : handedRun,
|
|
811
|
+
outboxes: new Map(outboxes),
|
|
812
|
+
left: leftUnsent(),
|
|
813
|
+
});
|
|
814
|
+
if (store) {
|
|
815
|
+
liveOnKey.set(storageKey, Math.max(0, (liveOnKey.get(storageKey) ?? 1) - 1));
|
|
816
|
+
// The next tracker on this key reads only after these writes (bounded by idle()).
|
|
817
|
+
const landed = writesDone().catch(() => undefined);
|
|
818
|
+
const entry = { drain: idleWithin(landed).catch(() => undefined), landed, writes: [...(priorWrites?.writes ?? []), ...writing] };
|
|
819
|
+
lastDrainOnKey.set(storageKey, entry);
|
|
820
|
+
void Promise.all([landed, ...entry.writes]).then(() => {
|
|
821
|
+
if (lastDrainOnKey.get(storageKey) === entry) lastDrainOnKey.delete(storageKey);
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
},
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
let instance: OnboardingRunTracker | null = null;
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* The app-wide tracker. Call `configure` once at startup, then `start` (or
|
|
832
|
+
* `resume`) for each run. Before `configure`, `start` returns a run that
|
|
833
|
+
* records nothing, and warns.
|
|
834
|
+
*/
|
|
835
|
+
export const onboardingRun = {
|
|
836
|
+
configure(config: TrackerConfig): void {
|
|
837
|
+
instance?.dispose();
|
|
838
|
+
instance = createOnboardingRunTracker(config);
|
|
839
|
+
},
|
|
840
|
+
start(options: StartOptions): OnboardingRun {
|
|
841
|
+
if (instance) return instance.start(options);
|
|
842
|
+
consoleDiagnostics({ code: "not-configured", message: "onboardingRun.start was called before onboardingRun.configure: nothing is tracked" });
|
|
843
|
+
return inertRun("00000000-0000-0000-0000-000000000000");
|
|
844
|
+
},
|
|
845
|
+
resume(): Promise<OnboardingRun | null> {
|
|
846
|
+
return instance ? instance.resume() : Promise.resolve(null);
|
|
847
|
+
},
|
|
848
|
+
idle(): Promise<void> {
|
|
849
|
+
return instance ? instance.idle() : Promise.resolve();
|
|
850
|
+
},
|
|
851
|
+
dispose(): void {
|
|
852
|
+
instance?.dispose();
|
|
853
|
+
instance = null;
|
|
854
|
+
},
|
|
855
|
+
};
|