@drip-apex/app-sdk 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,401 @@
1
- # Temporary Holding Version
1
+ # Apex app SDK
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `@drip-apex/app-sdk` adds server-assigned flags, exposures, goals and purchases to
4
+ hybrid apps built with Ionic, Angular, Capacitor or Cordova. It has zero runtime
5
+ dependencies and uses WebView APIs: fetch, timers, Web Crypto, localStorage and
6
+ visibility events. It does not change the DOM or load the storefront SDK.
7
+
8
+ ```sh
9
+ npm install @drip-apex/app-sdk
10
+ ```
11
+
12
+ For Cordova, allow the events host in your app's CSP **before testing**. Without
13
+ `connect-src https://events.drip-apex.com`, every SDK request fails with a network
14
+ error. Preserve any other origins your app needs:
15
+
16
+ ```html
17
+ <meta http-equiv="Content-Security-Policy"
18
+ content="default-src 'self'; connect-src 'self' https://events.drip-apex.com">
19
+ ```
20
+
21
+ Cordova iOS apps need `cordova-ios` 8+ to build with current Xcode. Older shells
22
+ lack UIScene support and can crash at launch on current iOS; this is independent
23
+ of Apex. When reinstalling the same SDK version, clear Angular's `.angular/cache`
24
+ before rebuilding so the app loads the updated package.
25
+
26
+ Configure once at app initialization. Create a **publishable** `apx_pk_…` key in
27
+ Apex **Settings → Developers**. Never put a private API key or shop secret in the
28
+ app. Await configure for storage hydration and the first assignment fetch to settle.
29
+ `initTimeoutMs` (default 3,000 ms) bounds the wait for that fetch; initialization
30
+ never rejects. Reads before resolution use defaults or cached assignments, with
31
+ `variation(key).reason` reporting `default`, `cached`, or `stale` as appropriate.
32
+
33
+ ```ts
34
+ import { Apex } from '@drip-apex/app-sdk';
35
+
36
+ await Apex.configure({
37
+ publishableKey: 'apx_pk_live_…',
38
+ shopId: 'YOUR_SHOP_UUID',
39
+ applicationId: 'ch.coop.supercard',
40
+ appVersion: '1.0.0',
41
+ screen: () => 'home',
42
+ qaMode: false,
43
+ });
44
+
45
+ await Apex.identify('app-user-id'); // Wait for this user's assignments (or initTimeoutMs).
46
+ const enabled = Apex.flag('welcome', false); // New user's value after a successful load.
47
+ // Use await Apex.identify(null) on logout to restore the device UUID.
48
+ await Apex.track('signup_completed');
49
+ await Apex.trackPurchase('order-42', 42.5, 'CHF');
50
+ ```
51
+
52
+ Attributes passed to `identify(userId, attributes)` are reserved for a future
53
+ contract and are not sent in v1. `identify()` never rejects and resolves when the
54
+ new identity's assignments load or `initTimeoutMs` elapses. Await it before reading
55
+ flags for the new user. Reads before the matching assignments load return the
56
+ previous user's values **without counting exposures**, including reads immediately
57
+ after calling identify. `logExposure` is also deferred during this period; it does
58
+ not replay an old assignment's exposure. Read the flag or call `logExposure` again
59
+ after the new assignments load to count them normally. If the wait times out,
60
+ previous values remain uncounted until a matching response succeeds. A later
61
+ identify supersedes an earlier response. The identified user is persisted per
62
+ shop/environment. On a cold launch, the SDK restores that user's cached
63
+ assignments immediately, including their exposure and purchase attribution,
64
+ before waiting for the network. Without a saved user it restores the anonymous
65
+ device cache. `identify(null)` clears the saved user for the next launch. A new
66
+ in-session identify still serves the previous assignments until its fetch succeeds.
67
+ Purchases during this pending period keep the existing attribution rule: the base
68
+ purchase uses the new current identity, while experiment copies use the previous
69
+ served assignment and its identity. After the new assignments load, both use the
70
+ new identity.
71
+
72
+ ## Angular service
73
+
74
+ Provide one service at the root, configure through Angular app initialization,
75
+ and inject that service into components. Change callbacks execute asynchronously;
76
+ update Angular state through your usual change detection or signals.
77
+
78
+ ```ts
79
+ import { Injectable, NgZone, inject, provideAppInitializer } from '@angular/core';
80
+ import { Apex } from '@drip-apex/app-sdk';
81
+
82
+ @Injectable({ providedIn: 'root' })
83
+ export class ApexService {
84
+ private readonly zone = inject(NgZone);
85
+ init(): Promise<void> {
86
+ return this.zone.runOutsideAngular(() => Apex.configure({
87
+ publishableKey: 'apx_pk_live_…', shopId: 'YOUR_SHOP_UUID',
88
+ applicationId: 'ch.coop.supercard',
89
+ screen: () => 'home', // Replace with your router's route, excluding query/fragment.
90
+ }));
91
+ }
92
+ welcome() { return Apex.flag('welcome', false); }
93
+ subscribe(update: () => void) {
94
+ return Apex.onChange(change => {
95
+ if (change.type === 'changed') this.zone.run(update);
96
+ });
97
+ }
98
+ }
99
+
100
+ // Add this provider to ApplicationConfig.providers:
101
+ export const apexInitializer = provideAppInitializer(() => inject(ApexService).init());
102
+
103
+ // In a component:
104
+ // private readonly experiments = inject(ApexService);
105
+ // enabled = this.experiments.welcome();
106
+ // private readonly subscription = this.experiments.subscribe(() => {
107
+ // this.enabled = this.experiments.welcome();
108
+ // });
109
+ // ngOnDestroy() { this.subscription.cancel(); }
110
+ ```
111
+
112
+ For apps that react to `onChange`, start configuration in `NgZone.runOutsideAngular`
113
+ so SDK timers and network completions do not trigger unnecessary change detection.
114
+ Re-enter with `zone.run` only to update UI, as the service above does. Older Angular
115
+ versions without `provideAppInitializer` can provide `APP_INITIALIZER` with
116
+ `multi: true` and a factory that captures `const service = inject(ApexService)`
117
+ synchronously and returns `() => service.init()`.
118
+
119
+ ## What your developers code
120
+
121
+ - Read a flag where the app selects its content or code variant. Exposures are
122
+ automatic on compatible flag reads, deduped per experiment, variation and epoch
123
+ in a session. A missing epoch still exposes with `assignment_epoch: null`.
124
+ - Call `track("goal_name")` once at each goal action. Custom events are
125
+ unattributed; purchase attribution is handled separately.
126
+ - Call `trackPurchase(orderId, revenue, currency)` at order confirmation with a
127
+ stable order ID, a finite non-negative number in currency units and a
128
+ three-letter currency code. The SDK enqueues a base row plus one row for every
129
+ served assignment. These rows share an event ID and retain their IDs on retry.
130
+ - Call `identify(userId)` on login and `identify(null)` on logout.
131
+ - Set `qaMode: true` in test builds. Non-production environments are also QA.
132
+ Store builds use `environment: "production"` and `qaMode: false`.
133
+ - After configure, use `Apex.deviceId` for an anonymous QA registration or event
134
+ lookup. After login use `Apex.userId`. Register that identity with the flag's
135
+ QA devices in Apex to pin a variation. Use the trusted-terminal tracking
136
+ verify endpoint described in the [wire contract](../../docs/mobile-sdk/wire-contract.md#check-a-test-builds-events)
137
+ to inspect recent events. During identify transitions, also check the previous
138
+ assignment identity. The app's publishable key cannot read management APIs.
139
+
140
+ Content and configuration tests can use remote values. Layout and flow tests
141
+ still require both versions in your app code behind the flag. Apex cannot remove
142
+ that code from the app for you.
143
+
144
+ ## Typed objects and manual exposures
145
+
146
+ ```ts
147
+ type Welcome = { title: string; button: { label: string; enabled: boolean } };
148
+ const fallback: Welcome = { title: 'Welcome', button: { label: 'Start', enabled: false } };
149
+ const validate = (v: unknown): v is Welcome => {
150
+ if (!v || typeof v !== 'object') return false;
151
+ const x = v as Partial<Welcome>;
152
+ return typeof x.title === 'string' && typeof x.button?.label === 'string'
153
+ && typeof x.button?.enabled === 'boolean';
154
+ };
155
+ const content = Apex.flagObject('welcome', fallback, { validate, exposure: 'manual' });
156
+ // When the content is visible:
157
+ Apex.logExposure('welcome');
158
+ ```
159
+
160
+ A validator checks unknown JSON without throwing into app code. Without it,
161
+ `flagObject` checks the outer JSON type only; TypeScript types do not validate
162
+ nested fields at runtime. Returned values are copies. Unknown keys remain
163
+ present. Assigned object reads expose only after successful validation. Failed or
164
+ throwing validators return the default without exposure. This differs from the
165
+ native typed getter. Missing assignments do not expose. Primitive reads require
166
+ matching boolean/string/number types, finite numbers and safe integers; no string
167
+ coercion. Missing or incompatible reads return the default and log once per key
168
+ per session, only after an assignment set has loaded from cache or the network.
169
+ Reads before that first load use defaults without warnings.
170
+ `variation(key)` also exposes. A configured client returns a default variation
171
+ with `reason: "default"` when unassigned; the unconfigured facade returns null.
172
+
173
+ ## Storage and lifecycle
174
+
175
+ The default adapter uses localStorage. To use Capacitor Preferences, supply an
176
+ adapter; Capacitor remains an app dependency, not an SDK dependency:
177
+
178
+ ```ts
179
+ import { Preferences } from '@capacitor/preferences';
180
+ import { Apex, type ApexStorage } from '@drip-apex/app-sdk';
181
+
182
+ const storage: ApexStorage = {
183
+ async get(key) { return (await Preferences.get({ key })).value; },
184
+ async set(key, value) { await Preferences.set({ key, value }); },
185
+ };
186
+ await Apex.configure({ publishableKey: 'apx_pk_live_…', shopId: 'YOUR_SHOP_UUID', storage });
187
+ ```
188
+
189
+ Adapters must atomically replace a string value or reject without changing it.
190
+ The SDK stages queued events in memory and coalesces bursts into the latest queue
191
+ snapshot, with at most one storage write in flight and one pending snapshot.
192
+ `track()` and `trackPurchase()` wait for the write containing their events before
193
+ resolving. A failed write restores the previous durable queue and logs the failure;
194
+ purchase groups remain atomic. If an identify identity write fails, the SDK restores
195
+ the previous in-memory identity, logs once that the identity could not be saved, and stops assignment
196
+ fetches and event sends until a later identify successfully saves its identity.
197
+ Queue/cache read failures log and use available defaults.
198
+
199
+ Assignments refresh on configure, identify, explicit `refresh()`, and foreground
200
+ transitions, matching the native SDKs. There is no periodic assignment polling;
201
+ 60 seconds is the cached assignment freshness window, not a network interval.
202
+ Foreground refresh uses the configured jitter and respects retry deadlines.
203
+ Startup schedules delivery of a hydrated nonempty queue on the first tick.
204
+ Foreground transitions also flush a nonempty queue, respecting event retry deadlines.
205
+
206
+ The SDK listens to browser `visibilitychange`, Cordova document `pause`/`resume`,
207
+ and `globalThis.Capacitor?.Plugins?.App?.addListener('appStateChange', ...)` when
208
+ available. It does not import or require a shell plugin. If your Capacitor version
209
+ exposes App only as an imported module, forward its callback explicitly:
210
+
211
+ ```ts
212
+ import { App } from '@capacitor/app';
213
+ App.addListener('appStateChange', ({ isActive }) => {
214
+ Apex.setAppState(isActive ? 'foreground' : 'background');
215
+ });
216
+ // Or call Apex.setAppState('background' / 'foreground') from your own shell hooks.
217
+ ```
218
+
219
+ Repeated state signals and rapid overlay transitions coalesce within one second.
220
+ Backgrounding first completes any pending queue write, then flushes the persisted
221
+ queue with best-effort fetch keepalive if the UTF-8 request body fits 64 KiB; larger batches use normal fetch. Disk remains
222
+ authoritative until the server acknowledges a batch and the acknowledgement is
223
+ persisted. Thirty continuous minutes in the background rotate the session and
224
+ clear exposure/warning dedupe. Lifecycle listeners are removed on shutdown.
225
+ Every cold launch creates a new session ID and allows a new exposure for each
226
+ served assignment, even when relaunching within thirty minutes. This matches
227
+ both native clients: iOS initializes `sessionId` with `UUID().uuidString` and
228
+ Android with `UUID.randomUUID().toString()`; neither persists sessions across
229
+ launches. The thirty-minute rule applies within a running client.
230
+
231
+ `track`, `trackPurchase`, `refresh` and `flush` return promises for their local work,
232
+ not server delivery. `identify` additionally waits for matching assignments or
233
+ `initTimeoutMs`, without rejecting. `configure` resolves after storage hydration and
234
+ the first assignment fetch settles (including failures), or its configurable
235
+ 3,000 ms fetch wait expires. A late response still updates flags. Use `onChange`
236
+ to observe changed keys or a fatal stop (`{ type: "stopped", code }`). Subscriptions
237
+ have an idempotent `cancel()`.
238
+ To use a separate context, construct `new ApexClient(options)` and await its
239
+ `ready` promise. Call `shutdown()` when retiring it. Do not run multiple clients
240
+ with the same storage and shop/environment namespace simultaneously.
241
+
242
+ ## Delivery and options
243
+
244
+ | Option | Default / limit |
245
+ | --- | --- |
246
+ | `environment` | `production` |
247
+ | `qaMode` | `false`; other environments also stamp `qa_mode: true` |
248
+ | `flushIntervalSeconds` | 5; clamped to 0.1–86,400 |
249
+ | `batchSize` | 20; clamped to 1–20 |
250
+ | `queueCapacity` | 2,000; clamped to 1–2,000 |
251
+ | `queueRetentionHours` | 47; clamped to 0–47, below the 48-hour replay window |
252
+ | `refreshJitterSeconds` | `[0, 10]` |
253
+ | `initTimeoutMs` | 3,000; zero skips assignment waiting on configure and identify |
254
+ | `platform` | Detected `ios`, `android`, or `web`; explicit override takes precedence |
255
+ | `applicationId` / `appVersion` | `unknown` / empty string |
256
+ | `screen` | `home`; an event's string `properties.screen` takes precedence |
257
+ | `logger` | Optional; callback errors are isolated |
258
+ | `fetch` / `baseUrl` | WebView fetch / `https://events.drip-apex.com` |
259
+
260
+ Queue capacity evicts the oldest rows, dropping a whole older purchase group if
261
+ the cut would split it. A new purchase larger than capacity or 200 rows is dropped
262
+ whole with a diagnostic. Delivery keeps each purchase group in one request,
263
+ expanding the batch target when needed within the 200-event server request limit.
264
+ Expired rows are removed at enqueue/startup/flush. Future timestamps survive clock
265
+ rollback. Events use `app://<lowercase applicationId>/<encoded screen path>` with
266
+ no query or fragment. Platform detection uses Capacitor `getPlatform()`, then Cordova
267
+ `device.platform` (case-insensitive), then iPhone/iPad/iPod or Android user-agent
268
+ detection, finally `web`. Explicit `platform` overrides detection. The production
269
+ Worker already accepts all three values; no Worker change is required.
270
+
271
+ Fatal 401 codes `invalid_publishable_key` and `publishable_key_not_allowed` stop
272
+ both network lanes while reads retain values. Retryable responses respect
273
+ `Retry-After` seconds or HTTP dates. Missing lookup-unavailable retry headers
274
+ use five seconds; ordinary 429 responses use exponential backoff with jitter.
275
+ Other network/server failures use exponential 5/10/20/40/60-second
276
+ backoff with 0–20% positive jitter, capped at 60 seconds. Unknown 401/403 use
277
+ 300 seconds; explicitly retryable assignment 4xx use 30 seconds. Other
278
+ non-retryable assignment 4xx end that request without automatic retries. Event
279
+ non-retryable 4xx drop the
280
+ batch, except 401/403/408/429 which are retained. Only HTTP 202 with `ok: true`
281
+ acknowledges events. A failed acknowledgement write retains the rows for replay.
282
+ Requests time out after 30 seconds and refuse redirects.
283
+
284
+ Supply `logger: line => ...` to receive developer diagnostics. Assignment fetch
285
+ and event delivery failures report network errors or HTTP status, plus whether
286
+ a retry is scheduled or the request/batch was dropped. Each lane and failure
287
+ class (network error or distinct HTTP status) logs at most once per sixty seconds
288
+ per client. Fatal auth logs its reason and that the SDK stopped; an obviously
289
+ malformed publishable-key prefix also logs at configure. Keys, Authorization
290
+ headers, response bodies and exception details are never included in these
291
+ diagnostics. Without a logger the SDK emits no logs, including to the console.
292
+
293
+ `track` rejects malformed revenue and property keys `email`, `phone`, `password`,
294
+ `ssn`, `credit_card`, `card_number` (case/diacritic insensitive). Numeric revenue
295
+ strings must match the Worker's strict decimal grammar, including comma decimals.
296
+ Purchases strip caller order/revenue/currency aliases before adding explicit
297
+ arguments. Properties must be JSON, including finite numbers.
298
+
299
+ Authentication uses **only** `Authorization: Bearer <publishableKey>` on assignment
300
+ and event requests. Cookie auth does not work from `capacitor://localhost`.
301
+ The SDK never sends `X-Apex-Publishable-Key`; that header is not allowed by the
302
+ production WebView CORS policy. No cookie credentials are needed.
303
+
304
+ Importing or configuring the SDK during SSR is safe: it logs once per context,
305
+ performs no storage/network work, and returns default flags. Configure again in
306
+ the browser to activate it.
307
+
308
+ Builds are ESM and CJS targeting ES2019, with declarations compatible with
309
+ TypeScript 4.9+ and Angular 15+ webpack/esbuild builders, with no Node APIs in
310
+ either runtime bundle. The package tests bundle consumers with esbuild, verify
311
+ both declaration formats, and use offline TypeScript 4.9 when installed. If the
312
+ 4.9 check is skipped, the orchestrator must rerun the package tests with a locally
313
+ available TypeScript 4.9 installation in node_modules before claiming that compiler
314
+ was verified. Older Angular versions use `APP_INITIALIZER` as described above.
315
+ Publishing is gated on main and npm trusted publishing; the repository
316
+ owner must configure the `@drip-apex/app-sdk` trusted publisher for
317
+ `publish-app-sdk.yml` in the `npm-production` environment before the first release.
318
+
319
+ ## Public failure contracts
320
+
321
+ All instance and singleton methods contain invalid app input and app callback errors:
322
+ no synchronous throw and no rejected SDK promise. Invalid input leaves identity,
323
+ assignments, queue and lifecycle state unchanged. This is validation at the method
324
+ boundary; a callback failure has its own recovery behavior below.
325
+
326
+ | Method | Invalid input / failure outcome | Return |
327
+ | --- | --- | --- |
328
+ | `new ApexClient(options)`, `Apex.configure(options)` | Invalid options stop the new context before storage, listeners or network. One diagnostic through a callable logger. Reconfigure retires the previous context first. | `ready` / configure resolves `undefined` |
329
+ | `identify(id, attributes?)` | Only nonempty string IDs up to 1,024 characters or `null` (anonymous) are accepted. Attributes, when supplied, must be a JSON record of strings up to 1,024 characters; they remain unused by evaluation. Invalid input logs and does not start a pending identity change. | Resolves `undefined` |
330
+ | `flag(key, default, options?)` | Invalid key, primitive default or options returns the exact supplied default, without exposure. A missing or incompatible assignment warns once per key after assignments load. | Supplied default |
331
+ | `flagObject(key, default, options?)` | Invalid key, JSON default or options returns the exact supplied default without exposure. Failed or throwing validators return the default, warn once per key, and never expose. Successful decoding isolates the returned value from cached data. | Supplied default |
332
+ | `variation(key)` | Invalid key returns `null` without exposure. A valid missing key retains the existing `reason: 'default'` descriptor. Unsafe assigned JSON returns `null` with the typed warning. | `null` / default descriptor |
333
+ | `logExposure(key)` | Invalid key is ignored without exposure. Pending identify suppresses all exposure entry points. | `undefined` |
334
+ | `track(name, properties?)` | Nonempty names up to 256 characters; properties omitted/`undefined` mean `{}`. Non-record properties (including `null`, arrays and primitives), unsafe JSON, PII or invalid revenue log and drop before enqueue. | Resolves `undefined` |
335
+ | `trackPurchase(id, revenue, currency, properties?)` | Nonempty string ID up to 1,024 characters, finite safe nonnegative revenue, three-letter currency after trimming/uppercasing. Properties have the same record contract as `track`. Invalid input logs and drops the whole purchase before enqueue. | Resolves `undefined` |
336
+ | `onChange(handler)` | Nonfunctions log and return an inert subscription. A throwing handler logs; every other handler still runs. | `{ cancel() }`; cancel is idempotent |
337
+ | `setAppState(state)` | Only `foreground` and `background` are accepted. Invalid input logs and leaves state/timers unchanged. | `undefined` |
338
+ | `refresh()`, `flush()` | No arguments required; extra arguments ignored. Transport failure uses the documented lane retry policy; stopped contexts do nothing. | Resolves `undefined` |
339
+ | `shutdown()` | Extra arguments ignored; stops networking and settles waiters, then waits for outstanding serialized storage work. | Resolves `undefined` |
340
+ | `sdkVersion`, `deviceId`, `userId` | Read-only; no input or side effect. Singleton IDs are `undefined` before configure; instance IDs are empty before hydration. | String (or singleton `undefined`) |
341
+
342
+ JSON input rejects cycles, nonfinite/unsafe integer numbers, functions, custom
343
+ prototypes, accessors, and `__proto__`, `constructor`, or `prototype` keys at any
344
+ level. Limits are 32 nesting levels, 10,000 visited nodes, 10,000 array slots
345
+ (including sparse slots), and 1 MiB of combined
346
+ UTF-16 string/key characters. Primitive flag defaults follow these limits too.
347
+ Flag keys are nonempty strings up to 1,024 characters. Options are records with
348
+ only the documented keys; callbacks must be functions, numeric options finite
349
+ safe numbers, jitter a nonnegative ordered pair, and `baseUrl` an HTTP(S) URL
350
+ without embedded credentials. Existing numeric option clamping still applies.
351
+
352
+ Throwing loggers are isolated. Throwing `screen()` callbacks, unusable screen
353
+ strings, or malformed Unicode screen paths fall back to `home`. Storage adapter
354
+ get/set may throw synchronously or reject: device persistence failure stops
355
+ initialization, cache read failure gives defaults, event write failure keeps the
356
+ previous durable queue, acknowledgement write failure retains IDs for safe replay,
357
+ and identity write failure uses the suspension/recovery contract above. A queue
358
+ restored under a smaller capacity is activated only after successful trimming;
359
+ if that write fails, the new context stops with disk untouched. Reconfigure
360
+ after storage recovery to retry the restore. Atomic
361
+ replace-or-reject is required of the adapter; an adapter that hangs forever can
362
+ keep a storage operation (and shutdown) pending.
363
+
364
+ Capacity and retention removals log their drop reasons with each coalesced write.
365
+ Purchases larger than queue capacity or 200 rows are rejected whole with a diagnostic. A purchase group
366
+ is delivered in one request: the batch target may expand to include its last
367
+ whole group, without exceeding the server's 200-event request limit. Restored oversized
368
+ legacy groups are dropped whole with a diagnostic. Event IDs intentionally match
369
+ across the base and attributed purchase rows. Exactly-once server ingestion uses
370
+ `(event_id, experiment_id, variation_id)` as its delivery key; network retry or
371
+ failed local acknowledgement persistence can retransmit the same key.
372
+
373
+ Long sessions bound warning keys at 256, diagnostic rate-limit keys at 128,
374
+ listeners at 1,024, identity cache entries at four, and exposure dedupe keys at
375
+ 4,096 per session. Once the exposure bound is reached, new tuples are dropped
376
+ with a diagnostic, preserving dedupe for tuples already counted.
377
+ Session rotation clears exposure and typed-warning dedupe. At most three
378
+ assignment requests remain outstanding (older requests are aborted on saturation),
379
+ plus one event request; abort/timeout releases SDK bookkeeping even if a custom
380
+ fetch ignores its signal. A non-aborted superseded fatal auth reply still stops
381
+ both lanes.
382
+
383
+ ## Deterministic stress verification
384
+
385
+ `npm test` includes 2,000 seeds × 200 operations (400,000 operations), input and
386
+ assigned-value fuzzing, simulated process death with atomic disk survival, slow /
387
+ failed persistence, unordered and abort-ignoring transports, and all lifecycle
388
+ sources. Tracking-promise resolution and process death both check that resolved
389
+ events remain on disk, delivered, or removed by a logged drop rule. Slow-adapter
390
+ burst tests cover 500 and 2,000 events and require at most two writes, including
391
+ bursts arriving during an existing write. Failures report the seed and operation.
392
+ Replay one seed with
393
+ `APEX_CHAOS_FIRST_SEED=424 APEX_CHAOS_SEEDS=424 npm test`; run a larger soak with
394
+ `APEX_CHAOS_SEEDS=20000 APEX_CHAOS_OPERATIONS=1000 npm test`.
395
+
396
+ The CI performance guard starts with a saturated 2,000-row durable backlog,
397
+ then feeds 10,000 additional events through flush and atomic storage writes.
398
+ It requires all 12,000 rows delivered within a generous 15-second wall-clock
399
+ budget on the CI runner. This exercises the shipped 2,000-row capacity rather
400
+ than allowing an impossible 10,000-row simultaneous queue. A separate 5,000-cycle
401
+ session checks internal map/set, waiter, request and timer bounds.