@hot-updater/test-utils 1.0.0-rc.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,3569 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
2
+ import { NIL_UUID, createReleaseCatalogScopeKey, encodeChannelKey, selectDesiredRelease } from "@hot-updater/core";
3
+ import { DatabaseAdapterContractError, createMemoryAdapter, findPhysicalIndex, indexOrderTuple, verifyAdapter } from "@hot-updater/plugin-core/internal";
4
+ import { createServer } from "node:http";
5
+ //#region src/adminApiTestClient.ts
6
+ const jsonRequest = (method, body) => ({
7
+ method,
8
+ headers: { "content-type": "application/json" },
9
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
10
+ });
11
+ /** A bundle row as the deploy route takes it. */
12
+ const toDeployBundle = (row) => ({
13
+ id: row.id,
14
+ platform: row.platform,
15
+ gitCommitHash: row.git_commit_hash,
16
+ manifestStorageUri: row.manifest_storage_uri,
17
+ manifestFileHash: row.manifest_file_hash,
18
+ assetBaseStorageUri: row.asset_base_storage_uri,
19
+ ...row.metadata === null ? {} : { metadata: row.metadata }
20
+ });
21
+ /**
22
+ * Admin API protocol 2 over a test client. Each JSON call checks the answer
23
+ * is private and names the request when it fails. `track` records what a
24
+ * test wrote, and `cleanup` deletes it again: its releases, then its
25
+ * bundles, newest first.
26
+ */
27
+ const createAdminApiTestClient = (getClient) => {
28
+ const admin = (path, init) => getClient().admin(path, init);
29
+ const adminJson = async (path, init) => {
30
+ const response = await admin(path, init);
31
+ const text = await response.text();
32
+ expect(response.ok, `${init?.method ?? "GET"} ${path}: ${text}`).toBe(true);
33
+ expect(response.headers.get("cache-control")).toBe("private, no-store");
34
+ return text === "" ? void 0 : JSON.parse(text);
35
+ };
36
+ const releases = [];
37
+ const bundles = [];
38
+ /** One deployment through `POST /releases`: a new bundle, or a stored one's id. */
39
+ const deploy = async (deployment) => {
40
+ const { data } = await adminJson("/releases", jsonRequest("POST", { deployments: [deployment] }));
41
+ const release = data[0]?.release;
42
+ if (!release) throw new Error("The deploy answered no release.");
43
+ releases.push(release.id);
44
+ if ("bundle" in deployment) bundles.push(deployment.bundle.id);
45
+ return release;
46
+ };
47
+ const ensureChannel = async (name) => (await adminJson("/channels", jsonRequest("POST", { name }))).data;
48
+ /** Hard-deletes a release: disabled first, as the route requires. */
49
+ const removeRelease = async (id) => {
50
+ const current = await admin(`/releases/${encodeURIComponent(id)}`);
51
+ if (current.status === 404) {
52
+ await current.text();
53
+ return;
54
+ }
55
+ const { data: row } = await current.json();
56
+ if (row.enabled) await adminJson(`/releases/${encodeURIComponent(id)}`, jsonRequest("PATCH", { patch: { enabled: false } }));
57
+ await adminJson(`/releases/${encodeURIComponent(id)}?confirm=${encodeURIComponent(id)}`, jsonRequest("DELETE"));
58
+ };
59
+ const cleanup = async () => {
60
+ for (const id of releases.splice(0).reverse()) await removeRelease(id);
61
+ for (const id of bundles.splice(0).reverse()) await adminJson("/bundles/delete", jsonRequest("POST", { ids: [id] }));
62
+ };
63
+ return {
64
+ admin,
65
+ adminJson,
66
+ deploy,
67
+ ensureChannel,
68
+ removeRelease,
69
+ /** Tracks a release the server wrote for a test, such as a promotion's copy. */
70
+ track: (releaseId) => {
71
+ releases.push(releaseId);
72
+ },
73
+ /** Stops tracking a release a test already deleted. */
74
+ forget: (releaseId) => {
75
+ const index = releases.indexOf(releaseId);
76
+ if (index >= 0) releases.splice(index, 1);
77
+ },
78
+ cleanup
79
+ };
80
+ };
81
+ //#endregion
82
+ //#region src/databaseTestFixtures.ts
83
+ const fixtureId = (suffix) => `00000000-0000-7000-8000-${suffix.padStart(12, "0")}`;
84
+ const createBundleRowFixture = (suffix, _channel = "production") => ({
85
+ id: fixtureId(suffix),
86
+ platform: "ios",
87
+ git_commit_hash: null,
88
+ metadata: { app_version: suffix },
89
+ manifest_storage_uri: `storage://bundles/${suffix}/manifest.json`,
90
+ manifest_file_hash: `manifest-hash-${suffix}`,
91
+ asset_base_storage_uri: "storage://assets"
92
+ });
93
+ const createBundlePatchRowFixture = (suffix, bundleId, baseBundleId, orderIndex = 0) => ({
94
+ id: `patch-${suffix}`,
95
+ bundle_id: bundleId,
96
+ base_bundle_id: baseBundleId,
97
+ base_file_hash: `base-hash-${suffix}`,
98
+ patch_file_hash: `patch-hash-${suffix}`,
99
+ patch_storage_uri: `storage://patches/${suffix}.patch`,
100
+ byte_size: 3000000002,
101
+ order_index: orderIndex
102
+ });
103
+ const createBundleEventRowFixture = (suffix, receivedAtMs) => ({
104
+ id: fixtureId(suffix),
105
+ type: "UPDATE_APPLIED",
106
+ install_id: `install-${suffix}`,
107
+ user_id: null,
108
+ metadata: {
109
+ username: null,
110
+ cohort: "0",
111
+ update_strategy: "appVersion",
112
+ fingerprint_hash: null,
113
+ sdk_version: null
114
+ },
115
+ from_bundle_id: fixtureId(`${Number(suffix) + 1e3}`),
116
+ from_release_id: null,
117
+ to_bundle_id: fixtureId(`${Number(suffix) + 2e3}`),
118
+ to_release_id: null,
119
+ platform: "ios",
120
+ app_version: "1.0.0",
121
+ channel: "production",
122
+ received_at_ms: receivedAtMs
123
+ });
124
+ const createBundleFixture = (suffix, _channel = "production") => ({
125
+ id: fixtureId(suffix),
126
+ platform: "ios",
127
+ gitCommitHash: null,
128
+ metadata: { app_version: suffix },
129
+ manifestStorageUri: `storage://bundles/${suffix}/manifest.json`,
130
+ manifestFileHash: `manifest-hash-${suffix}`,
131
+ assetBaseStorageUri: "storage://assets"
132
+ });
133
+ //#endregion
134
+ //#region src/setupBundleMethodsTestSuite.ts
135
+ /**
136
+ * Bundles through admin API protocol 2: deployed with a release, read by
137
+ * id, paged by key, changed, and deleted. Each test deletes what it wrote.
138
+ */
139
+ const setupBundleMethodsTestSuite = (options) => {
140
+ describe("Bundle admin API", () => {
141
+ const api = createAdminApiTestClient(options.getClient);
142
+ const { admin, adminJson } = api;
143
+ let namespace;
144
+ beforeEach(() => {
145
+ namespace = crypto.randomUUID();
146
+ });
147
+ afterEach(async () => {
148
+ await api.cleanup();
149
+ });
150
+ /** A bundle id unique to the test, ordered by suffix. */
151
+ const bundleRow = (suffix, platform = "ios") => ({
152
+ ...createBundleRowFixture(suffix),
153
+ id: `${namespace.slice(0, 8)}-0000-7000-8000-${suffix.padStart(12, "0")}`,
154
+ platform
155
+ });
156
+ const deploy = (row) => api.deploy({
157
+ bundle: toDeployBundle(row),
158
+ release: {
159
+ channel: `bundles-${namespace}`,
160
+ enabled: false,
161
+ fingerprintHash: null,
162
+ message: null,
163
+ shouldForceUpdate: false,
164
+ targetAppVersion: "*"
165
+ }
166
+ });
167
+ const mine = (page) => page.data.map(({ bundle }) => bundle.id).filter((id) => id.startsWith(namespace.slice(0, 8)));
168
+ it("stores a deployed bundle's fields", async () => {
169
+ const input = bundleRow("10");
170
+ await deploy(input);
171
+ const { data } = await adminJson(`/bundles/${input.id}`);
172
+ expect(data).toEqual({
173
+ bundle: input,
174
+ patches: [],
175
+ childCount: 0
176
+ });
177
+ });
178
+ it("answers 404 for a missing bundle", async () => {
179
+ const response = await admin(`/bundles/${bundleRow("99").id}`);
180
+ expect(response.status).toBe(404);
181
+ await response.text();
182
+ });
183
+ it("pages bundles by key and filters by platform", async () => {
184
+ const first = bundleRow("40");
185
+ const second = bundleRow("41");
186
+ const android = bundleRow("42", "android");
187
+ for (const row of [
188
+ first,
189
+ second,
190
+ android
191
+ ]) await deploy(row);
192
+ const prefix = `${namespace.slice(0, 8)}-0000-7000-8000-000000000039`;
193
+ const page1 = await adminJson(`/bundles?order=asc&limit=1&cursor=${prefix}&platform=ios`);
194
+ const page2 = await adminJson(`/bundles?order=asc&limit=1&cursor=${page1.next}&platform=ios`);
195
+ const androidPage = await adminJson(`/bundles?order=asc&limit=100&cursor=${prefix}&platform=android`);
196
+ expect(mine(page1)).toEqual([first.id]);
197
+ expect(page1.next).toBe(first.id);
198
+ expect(mine(page2)).toEqual([second.id]);
199
+ expect(mine(androidPage)).toEqual([android.id]);
200
+ });
201
+ it("counts bundles with a page when asked", async () => {
202
+ const before = await adminJson("/bundles?limit=1&total=true");
203
+ await deploy(bundleRow("50"));
204
+ const after = await adminJson("/bundles?limit=1&total=true");
205
+ expect(after.total).toBe(before.total + 1);
206
+ });
207
+ it("changes a bundle's fields", async () => {
208
+ const input = bundleRow("60");
209
+ await deploy(input);
210
+ const response = await admin(`/bundles/${input.id}`, jsonRequest("PATCH", { gitCommitHash: "abc123" }));
211
+ expect(response.status).toBe(204);
212
+ expect((await adminJson(`/bundles/${input.id}`)).data.bundle.git_commit_hash).toBe("abc123");
213
+ });
214
+ it("refuses to delete a bundle a release uses, then deletes it", async () => {
215
+ const input = bundleRow("70");
216
+ const release = await deploy(input);
217
+ const refused = await admin("/bundles/delete", jsonRequest("POST", { ids: [input.id] }));
218
+ expect(refused.status).toBe(409);
219
+ await refused.text();
220
+ await api.removeRelease(release.id);
221
+ api.forget(release.id);
222
+ await adminJson("/bundles/delete", jsonRequest("POST", { ids: [input.id] }));
223
+ expect((await admin(`/bundles/${input.id}`)).status).toBe(404);
224
+ });
225
+ });
226
+ };
227
+ //#endregion
228
+ //#region src/setupCoreAdminTestSuite.ts
229
+ /**
230
+ * Core's operations through admin API protocol 2 that the bundle and catalog
231
+ * suites leave out: channels, deploy refusals, promotion, preflights, and a
232
+ * bundle's patch children. Each test deletes what it wrote.
233
+ */
234
+ const setupCoreAdminTestSuite = (options) => {
235
+ describe("Core admin API", () => {
236
+ const api = createAdminApiTestClient(options.getClient);
237
+ const { admin, adminJson } = api;
238
+ let namespace;
239
+ beforeEach(() => {
240
+ namespace = crypto.randomUUID();
241
+ });
242
+ afterEach(async () => {
243
+ await api.cleanup();
244
+ });
245
+ const bundleRow = (suffix) => ({
246
+ ...createBundleRowFixture(suffix),
247
+ id: `${namespace.slice(0, 8)}-0000-7000-8000-${suffix.padStart(12, "0")}`
248
+ });
249
+ const policy = (channel, enabled = true) => ({
250
+ channel: `${channel}-${namespace}`,
251
+ enabled,
252
+ fingerprintHash: null,
253
+ message: null,
254
+ shouldForceUpdate: false,
255
+ targetAppVersion: "1.0.x"
256
+ });
257
+ it("keeps one channel per name and deletes only an unused one", async () => {
258
+ const name = `preview-${namespace}`;
259
+ const [first, second] = await Promise.all([api.ensureChannel(name), api.ensureChannel(name)]);
260
+ expect(second).toEqual(first);
261
+ expect((await adminJson(`/channels?name=${encodeURIComponent(name)}`)).data).toEqual(first);
262
+ expect((await adminJson("/channels")).data.filter((channel) => channel.name === name)).toEqual([first]);
263
+ const used = await api.deploy({
264
+ bundle: toDeployBundle(bundleRow("10")),
265
+ release: policy("used")
266
+ });
267
+ const usedChannel = (await adminJson(`/channels?name=used-${namespace}`)).data;
268
+ const refused = await admin(`/channels/${usedChannel.id}`, jsonRequest("DELETE"));
269
+ expect(refused.status).toBe(409);
270
+ expect(await refused.json()).toEqual({ data: {
271
+ deleted: false,
272
+ reason: "not_empty"
273
+ } });
274
+ expect(used.channel_id).toBe(usedChannel.id);
275
+ const deleted = await admin(`/channels/${first.id}`, jsonRequest("DELETE"));
276
+ expect(deleted.status).toBe(204);
277
+ await deleted.text();
278
+ const missing = await admin(`/channels/${first.id}`, jsonRequest("DELETE"));
279
+ expect(missing.status).toBe(404);
280
+ await missing.text();
281
+ });
282
+ it("keeps a release's channel when a delete races the deploy into it", async () => {
283
+ const channel = await api.ensureChannel(`race-${namespace}`);
284
+ const [deleted, release] = await Promise.all([admin(`/channels/${channel.id}`, jsonRequest("DELETE")), api.deploy({
285
+ bundle: toDeployBundle(bundleRow("15")),
286
+ release: policy("race")
287
+ })]);
288
+ await deleted.text();
289
+ const channels = (await adminJson("/channels")).data;
290
+ expect(channels.map(({ id }) => id)).toContain(release.channel_id);
291
+ });
292
+ it("refuses a malformed deployment and a stored bundle it does not hold", async () => {
293
+ const malformed = await admin("/releases", jsonRequest("POST", { deployments: [{
294
+ bundle: {
295
+ ...toDeployBundle(bundleRow("20")),
296
+ platform: "web"
297
+ },
298
+ release: policy("production")
299
+ }] }));
300
+ const missing = await admin("/releases", jsonRequest("POST", { deployments: [{
301
+ bundleId: bundleRow("21").id,
302
+ release: policy("production")
303
+ }] }));
304
+ expect(malformed.status).toBe(400);
305
+ await malformed.text();
306
+ expect(missing.status).toBe(404);
307
+ expect(await missing.json()).toMatchObject({ code: "BUNDLE_NOT_FOUND" });
308
+ });
309
+ it("copies a release into another channel, and moves one", async () => {
310
+ const source = await api.deploy({
311
+ bundle: toDeployBundle(bundleRow("30")),
312
+ release: policy("production")
313
+ });
314
+ const copied = await adminJson(`/releases/${source.id}/promote`, jsonRequest("POST", { targetChannel: `beta-${namespace}` }));
315
+ const moved = await adminJson(`/releases/${source.id}/promote`, jsonRequest("POST", {
316
+ targetChannel: `staging-${namespace}`,
317
+ action: "move"
318
+ }));
319
+ for (const { data } of [copied, moved]) api.track(data.target.release.id);
320
+ expect(copied.data.source).toBeNull();
321
+ expect(copied.data.target.release).toMatchObject({
322
+ bundle_id: source.bundle_id,
323
+ enabled: true
324
+ });
325
+ expect(moved.data.target.release?.bundle_id).toBe(source.bundle_id);
326
+ expect(moved.data.source?.release?.enabled).toBe(false);
327
+ expect((await adminJson(`/releases/${source.id}`)).data.enabled).toBe(false);
328
+ });
329
+ it("previews a policy change and a catalog rebuild without writing", async () => {
330
+ const release = await api.deploy({
331
+ bundle: toDeployBundle(bundleRow("40")),
332
+ release: policy("production")
333
+ });
334
+ const catalogPath = `/release-catalogs/${encodeURIComponent(release.scope_key)}`;
335
+ const before = await adminJson(catalogPath);
336
+ const preflight = await adminJson(`/releases/${release.id}/preflight`, jsonRequest("POST", {
337
+ expectedRevision: 1,
338
+ patch: { enabled: false }
339
+ }));
340
+ const rebuild = await adminJson(`${catalogPath}/preflight`, jsonRequest("POST"));
341
+ expect(preflight.data).toBeDefined();
342
+ expect(rebuild.data).toBeDefined();
343
+ expect(await adminJson(catalogPath)).toEqual(before);
344
+ expect((await adminJson(`/releases/${release.id}`)).data).toEqual(release);
345
+ });
346
+ it("lists the patches that start from a bundle", async () => {
347
+ const base = bundleRow("50");
348
+ const target = bundleRow("51");
349
+ await api.deploy({
350
+ bundle: toDeployBundle(base),
351
+ release: policy("production", false)
352
+ });
353
+ await api.deploy({
354
+ bundle: {
355
+ ...toDeployBundle(target),
356
+ patches: [{
357
+ baseBundleId: base.id,
358
+ baseFileHash: "base-hash",
359
+ byteSize: 10,
360
+ patchFileHash: "patch-hash",
361
+ patchStorageUri: "storage://patches/51.patch"
362
+ }]
363
+ },
364
+ release: policy("production", false)
365
+ });
366
+ const children = await adminJson(`/bundles/${base.id}/children`);
367
+ const detail = await adminJson(`/bundles/${base.id}`);
368
+ expect(children.data).toEqual([expect.objectContaining({
369
+ bundle_id: target.id,
370
+ base_bundle_id: base.id
371
+ })]);
372
+ expect(detail.data.childCount).toBe(1);
373
+ });
374
+ });
375
+ };
376
+ //#endregion
377
+ //#region src/expectInsightsIndex.ts
378
+ /** Allow committed secondary indexes to catch up; query failures still fail. */
379
+ const expectInsightsIndex = async (query, expected) => {
380
+ const deadline = Date.now() + 5e3;
381
+ for (;;) {
382
+ const actual = await query();
383
+ try {
384
+ expect(actual).toEqual(expected);
385
+ return;
386
+ } catch (error) {
387
+ if (Date.now() >= deadline) throw error;
388
+ }
389
+ await new Promise((resolve) => setTimeout(resolve, 50));
390
+ }
391
+ };
392
+ //#endregion
393
+ //#region src/setupInsightsHttpTestSuite.ts
394
+ /**
395
+ * The Insights plugin's routes on a database: a client reports an event, and
396
+ * the admin routes read it back. The server must run `insights()`.
397
+ */
398
+ const setupInsightsHttpTestSuite = (options) => {
399
+ describe("Insights HTTP routes", () => {
400
+ it("records a reported event and reads it back", async () => {
401
+ const client = options.getClient();
402
+ const installId = `install-${crypto.randomUUID()}`;
403
+ const userId = `user-${crypto.randomUUID()}`;
404
+ const channel = `insights-${crypto.randomUUID()}`;
405
+ const event = {
406
+ appVersion: "1.0.0",
407
+ channel,
408
+ cohort: "default",
409
+ fingerprintHash: null,
410
+ fromBundleId: null,
411
+ fromReleaseId: null,
412
+ installId,
413
+ platform: "ios",
414
+ toBundleId: "00000000-0000-7000-8000-000000000001",
415
+ toReleaseId: null,
416
+ type: "UNCHANGED",
417
+ updateStrategy: null,
418
+ userId,
419
+ username: "Jane",
420
+ sdkVersion: "2.0.0"
421
+ };
422
+ const reported = await client.client("/events", jsonRequest("POST", event));
423
+ expect(reported.status).toBe(204);
424
+ expect(reported.headers.get("x-hot-updater-insights")).toBeNull();
425
+ await reported.text();
426
+ const installation = await client.admin(`/installations/${encodeURIComponent(installId)}`);
427
+ expect(installation.status).toBe(200);
428
+ expect(await installation.json()).toMatchObject({
429
+ installId,
430
+ latestStatus: "UNCHANGED",
431
+ lastKnownBundleId: event.toBundleId,
432
+ channel
433
+ });
434
+ await expectInsightsIndex(async () => {
435
+ return (await (await client.admin(`/installations?userId=${encodeURIComponent(userId)}`)).json()).data.map(({ installId: id }) => id);
436
+ }, [installId]);
437
+ await expectInsightsIndex(async () => {
438
+ return (await (await client.admin(`/overview?platform=ios&channel=${encodeURIComponent(channel)}&window=24h`)).json()).reportingInstallations.count;
439
+ }, 1);
440
+ });
441
+ it("refuses a malformed event", async () => {
442
+ const response = await options.getClient().client("/events", jsonRequest("POST", { type: "UNCHANGED" }));
443
+ expect(response.status).toBe(400);
444
+ await response.text();
445
+ });
446
+ });
447
+ };
448
+ //#endregion
449
+ //#region src/databaseTestRunner.ts
450
+ var DatabaseUnavailableError = class extends Error {
451
+ constructor() {
452
+ super("The database is unavailable outside the test lifecycle");
453
+ this.name = "DatabaseUnavailableError";
454
+ }
455
+ };
456
+ const setupDatabaseTestRunner = (lifecycle, registerTests) => {
457
+ describe(lifecycle.name, () => {
458
+ let database;
459
+ const getDatabase = () => {
460
+ if (database === void 0) throw new DatabaseUnavailableError();
461
+ return database;
462
+ };
463
+ beforeAll(async () => {
464
+ await lifecycle.migrate();
465
+ database = await lifecycle.createDatabase();
466
+ });
467
+ beforeEach(async () => {
468
+ await lifecycle.reset(getDatabase());
469
+ });
470
+ afterAll(async () => {
471
+ await lifecycle.dispose(getDatabase());
472
+ database = void 0;
473
+ });
474
+ registerTests({ getDatabase });
475
+ });
476
+ };
477
+ //#endregion
478
+ //#region src/insightsModelTests.ts
479
+ const record = (model, event) => model.recordEvent({ event });
480
+ const createMovementEvent = (suffix, receivedAtMs, type, installId) => {
481
+ const row = createBundleEventRowFixture(suffix, receivedAtMs);
482
+ return {
483
+ ...row,
484
+ type,
485
+ install_id: installId,
486
+ from_bundle_id: row.to_bundle_id,
487
+ metadata: {
488
+ ...row.metadata,
489
+ update_strategy: "appVersion"
490
+ }
491
+ };
492
+ };
493
+ /**
494
+ * The Insights report contract on `InsightsModel`: what the Insights plugin
495
+ * records and reads, through `createInsightsModel`, on one database.
496
+ */
497
+ const registerInsightsModelTests = (state) => {
498
+ describe("Insights report contract", () => {
499
+ it("preserves ancillary JSON through event history and latest-event reads", async () => {
500
+ const model = state.getDatabase();
501
+ const base = createBundleEventRowFixture("979", 100);
502
+ const event = {
503
+ ...base,
504
+ metadata: {
505
+ ...base.metadata,
506
+ device: {
507
+ locale: "ko-KR",
508
+ tags: [
509
+ "minor",
510
+ null,
511
+ true,
512
+ 2
513
+ ]
514
+ },
515
+ diagnostic: null
516
+ }
517
+ };
518
+ await record(model, event);
519
+ await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([event]);
520
+ await expectInsightsIndex(() => model.listEvents({
521
+ filter: { kind: "all" },
522
+ beforeReceivedAtMs: 101,
523
+ limit: 10
524
+ }), [event]);
525
+ });
526
+ it("selects the latest complete event across download, replacement, apply, and delayed reports", async () => {
527
+ const model = state.getDatabase();
528
+ const download = {
529
+ ...createBundleEventRowFixture("980", 100),
530
+ type: "UPDATE_DOWNLOADED",
531
+ from_bundle_id: "00000000-0000-7000-8000-000000001980",
532
+ metadata: {
533
+ ...createBundleEventRowFixture("980", 100).metadata,
534
+ update_strategy: "appVersion"
535
+ },
536
+ to_release_id: "00000000-0000-7000-8000-000000004980"
537
+ };
538
+ await record(model, download);
539
+ await record(model, download);
540
+ await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([download]);
541
+ await expectInsightsIndex(() => model.listEvents({
542
+ filter: {
543
+ kind: "bundle",
544
+ type: "UPDATE_DOWNLOADED",
545
+ toBundleId: download.to_bundle_id,
546
+ platform: "ios",
547
+ channel: "production"
548
+ },
549
+ beforeReceivedAtMs: 200,
550
+ limit: 10
551
+ }), [download]);
552
+ const replacement = {
553
+ ...download,
554
+ id: createBundleEventRowFixture("981", 110).id,
555
+ received_at_ms: 110,
556
+ to_bundle_id: "00000000-0000-7000-8000-000000002981"
557
+ };
558
+ await record(model, replacement);
559
+ await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([replacement]);
560
+ const applied = {
561
+ ...replacement,
562
+ type: "UPDATE_APPLIED",
563
+ id: createBundleEventRowFixture("982", 120).id,
564
+ received_at_ms: 120
565
+ };
566
+ await record(model, applied);
567
+ await record(model, {
568
+ ...download,
569
+ id: createBundleEventRowFixture("983", 105).id,
570
+ received_at_ms: 105
571
+ });
572
+ await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([applied]);
573
+ await expectInsightsIndex(() => model.listEvents({
574
+ filter: {
575
+ kind: "installationMovement",
576
+ installId: download.install_id
577
+ },
578
+ beforeReceivedAtMs: 200,
579
+ limit: 10
580
+ }), [
581
+ applied,
582
+ replacement,
583
+ {
584
+ ...download,
585
+ id: createBundleEventRowFixture("983", 105).id,
586
+ received_at_ms: 105
587
+ },
588
+ download
589
+ ]);
590
+ });
591
+ it("treats duplicate IDs as complete no-ops, including changed retry payloads", async () => {
592
+ const model = state.getDatabase();
593
+ const event = createBundleEventRowFixture("901", 100);
594
+ await Promise.all([
595
+ record(model, event),
596
+ record(model, event),
597
+ record(model, event)
598
+ ]);
599
+ await record(model, event);
600
+ const changed = {
601
+ ...event,
602
+ received_at_ms: 300,
603
+ user_id: "changed-user"
604
+ };
605
+ await record(model, changed);
606
+ const otherInstallation = {
607
+ ...changed,
608
+ install_id: "different-install"
609
+ };
610
+ await record(model, otherInstallation);
611
+ await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([event]);
612
+ await expect(model.findLatestEvents({ installId: otherInstallation.install_id })).resolves.toEqual([]);
613
+ await expectInsightsIndex(() => model.listEvents({
614
+ filter: { kind: "all" },
615
+ beforeReceivedAtMs: 400,
616
+ limit: 10
617
+ }), [event]);
618
+ });
619
+ it("keeps all concurrent events and the greatest timestamp/ID state, including logout", async () => {
620
+ const model = state.getDatabase();
621
+ const older = {
622
+ ...createBundleEventRowFixture("912", 100),
623
+ install_id: "concurrent",
624
+ user_id: "previous"
625
+ };
626
+ const tied = {
627
+ ...createBundleEventRowFixture("910", 200),
628
+ install_id: "concurrent",
629
+ user_id: "previous"
630
+ };
631
+ const newest = {
632
+ ...createBundleEventRowFixture("911", 200),
633
+ install_id: "concurrent",
634
+ user_id: null
635
+ };
636
+ await Promise.all([
637
+ record(model, newest),
638
+ record(model, older),
639
+ record(model, tied)
640
+ ]);
641
+ await record(model, older);
642
+ await expect(model.findLatestEvents({ installId: "concurrent" })).resolves.toEqual([newest]);
643
+ await expect(model.findLatestEvents({
644
+ userId: "previous",
645
+ limit: 10
646
+ })).resolves.toEqual([]);
647
+ await expectInsightsIndex(() => model.listEvents({
648
+ filter: { kind: "all" },
649
+ beforeReceivedAtMs: 201,
650
+ limit: 10
651
+ }), [
652
+ newest,
653
+ tied,
654
+ older
655
+ ]);
656
+ });
657
+ it("rejects invalid event metadata before persisting a report", async () => {
658
+ const model = state.getDatabase();
659
+ const event = createBundleEventRowFixture("920", 100);
660
+ await expect(model.recordEvent({ event: {
661
+ ...event,
662
+ metadata: {
663
+ ...event.metadata,
664
+ cohort: 123
665
+ }
666
+ } })).rejects.toMatchObject({ code: "invalid-data" });
667
+ await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([]);
668
+ await expect(model.listEvents({
669
+ filter: { kind: "all" },
670
+ beforeReceivedAtMs: 200,
671
+ limit: 10
672
+ })).resolves.toEqual([]);
673
+ });
674
+ it("shares scoped list/count predicates, recovery attribution, and half-open time bounds", async () => {
675
+ const model = state.getDatabase();
676
+ const bundleA = createBundleEventRowFixture("1", 0).to_bundle_id;
677
+ const bundleB = createBundleEventRowFixture("2", 0).to_bundle_id;
678
+ const bundleC = createBundleEventRowFixture("3", 0).to_bundle_id;
679
+ const applied = {
680
+ ...createBundleEventRowFixture("930", 100),
681
+ install_id: "target",
682
+ to_bundle_id: bundleB
683
+ };
684
+ const recovered = {
685
+ ...createBundleEventRowFixture("931", 120),
686
+ install_id: "target",
687
+ type: "RECOVERED",
688
+ from_bundle_id: bundleB,
689
+ to_bundle_id: bundleA,
690
+ metadata: {
691
+ ...createBundleEventRowFixture("931", 120).metadata,
692
+ update_strategy: "appVersion"
693
+ }
694
+ };
695
+ const unchanged = {
696
+ ...createBundleEventRowFixture("932", 130),
697
+ type: "UNCHANGED",
698
+ from_bundle_id: null,
699
+ to_bundle_id: bundleB,
700
+ metadata: {
701
+ ...createBundleEventRowFixture("932", 130).metadata,
702
+ update_strategy: null
703
+ }
704
+ };
705
+ const excluded = [
706
+ {
707
+ ...applied,
708
+ id: createBundleEventRowFixture("933", 99).id,
709
+ install_id: "before",
710
+ received_at_ms: 99
711
+ },
712
+ {
713
+ ...applied,
714
+ id: createBundleEventRowFixture("934", 200).id,
715
+ install_id: "after",
716
+ received_at_ms: 200
717
+ },
718
+ {
719
+ ...applied,
720
+ id: createBundleEventRowFixture("935", 110).id,
721
+ install_id: "android",
722
+ platform: "android"
723
+ },
724
+ {
725
+ ...applied,
726
+ id: createBundleEventRowFixture("936", 110).id,
727
+ install_id: "preview",
728
+ channel: "preview"
729
+ },
730
+ {
731
+ ...recovered,
732
+ id: createBundleEventRowFixture("937", 125).id,
733
+ install_id: "other-source",
734
+ from_bundle_id: bundleC,
735
+ to_bundle_id: bundleB
736
+ }
737
+ ];
738
+ for (const row of [
739
+ applied,
740
+ recovered,
741
+ unchanged,
742
+ ...excluded
743
+ ]) await record(model, row);
744
+ await expect(async () => record(model, {
745
+ ...applied,
746
+ type: "RELEASE_ADOPTED"
747
+ })).rejects.toThrow();
748
+ await expect(async () => record(model, {
749
+ ...applied,
750
+ type: "UNCHANGED",
751
+ from_bundle_id: bundleB,
752
+ metadata: {
753
+ ...applied.metadata,
754
+ update_strategy: "appVersion"
755
+ }
756
+ })).rejects.toThrow();
757
+ await expect(model.findLatestEvents({ installId: "target" })).resolves.toEqual([recovered]);
758
+ const cases = [
759
+ [{
760
+ platform: "ios",
761
+ channel: "production",
762
+ type: "UPDATE_APPLIED",
763
+ toBundleId: bundleB
764
+ }, applied],
765
+ [{
766
+ platform: "ios",
767
+ channel: "production",
768
+ type: "RECOVERED",
769
+ fromBundleId: bundleB
770
+ }, recovered],
771
+ [{
772
+ platform: "ios",
773
+ channel: "production",
774
+ type: "UNCHANGED",
775
+ toBundleId: bundleB
776
+ }, unchanged]
777
+ ];
778
+ for (const [filter, expected] of cases) {
779
+ await expectInsightsIndex(() => model.countEvents({
780
+ filter,
781
+ sinceMs: 100,
782
+ beforeReceivedAtMs: 200
783
+ }), 1);
784
+ await expectInsightsIndex(() => model.listEvents({
785
+ filter: {
786
+ kind: "bundle",
787
+ ...filter
788
+ },
789
+ sinceMs: 100,
790
+ beforeReceivedAtMs: 200,
791
+ limit: 1
792
+ }), [expected]);
793
+ }
794
+ await expectInsightsIndex(() => model.countLatestEvents({
795
+ platform: "ios",
796
+ channel: "production",
797
+ sinceMs: 100,
798
+ bundle: [{
799
+ field: "to_bundle_id",
800
+ value: bundleA,
801
+ types: [
802
+ "UNCHANGED",
803
+ "UPDATE_APPLIED",
804
+ "RECOVERED"
805
+ ]
806
+ }]
807
+ }), 1);
808
+ await expectInsightsIndex(() => model.countLatestEvents({
809
+ platform: "ios",
810
+ channel: "production",
811
+ sinceMs: 100
812
+ }), 4);
813
+ });
814
+ it("uses exact identity and UTF-8 cursor order for user installations", async () => {
815
+ const model = state.getDatabase();
816
+ const ids = [
817
+ "Install-a",
818
+ "é",
819
+ "install-a",
820
+ "install-a ",
821
+ "é",
822
+ "",
823
+ "😀"
824
+ ];
825
+ const events = ids.map((install_id, index) => ({
826
+ ...createBundleEventRowFixture(String(950 + index), 100),
827
+ install_id,
828
+ user_id: "User-é"
829
+ }));
830
+ for (const event of events.toReversed()) await record(model, event);
831
+ const spacedUser = {
832
+ ...createBundleEventRowFixture("960", 100),
833
+ install_id: "spaced-user-install",
834
+ user_id: "User-é "
835
+ };
836
+ await record(model, spacedUser);
837
+ await expectInsightsIndex(async () => {
838
+ const found = [];
839
+ let afterInstallId;
840
+ for (let pageIndex = 0; pageIndex <= ids.length; pageIndex++) {
841
+ const page = await model.findLatestEvents({
842
+ userId: "User-é",
843
+ afterInstallId,
844
+ limit: 2
845
+ });
846
+ found.push(...page.map((row) => row.install_id));
847
+ if (page.length < 2) return found;
848
+ const nextCursor = page[page.length - 1].install_id;
849
+ expect(nextCursor, "Insights cursor must advance").not.toBe(afterInstallId);
850
+ afterInstallId = nextCursor;
851
+ }
852
+ throw new Error("Insights pagination did not terminate");
853
+ }, ids);
854
+ await expectInsightsIndex(() => model.findLatestEvents({
855
+ userId: "User-é ",
856
+ limit: 10
857
+ }), [spacedUser]);
858
+ await expect(model.findLatestEvents({
859
+ userId: "user-é",
860
+ limit: 10
861
+ })).resolves.toEqual([]);
862
+ await expect(model.findLatestEvents({ installId: "INSTALL-a" })).resolves.toEqual([]);
863
+ await expect(model.findLatestEvents({ installId: "Install-a" })).resolves.toEqual([events[0]]);
864
+ await expect(model.findLatestEvents({ installId: "install-a " })).resolves.toEqual([events[3]]);
865
+ await expect(model.findLatestEvents({ installId: "install-a" })).resolves.toEqual([events[2]]);
866
+ });
867
+ it("counts overlapping bundle predicates once per latest installation", async () => {
868
+ const model = state.getDatabase();
869
+ const event = createBundleEventRowFixture("9700", 100);
870
+ await record(model, event);
871
+ const predicate = {
872
+ field: "to_bundle_id",
873
+ value: event.to_bundle_id,
874
+ types: [event.type]
875
+ };
876
+ await expectInsightsIndex(() => model.countLatestEvents({
877
+ platform: event.platform,
878
+ channel: event.channel,
879
+ sinceMs: 0,
880
+ bundle: [predicate, predicate]
881
+ }), 1);
882
+ });
883
+ it("returns zero for successful empty scalar queries", async () => {
884
+ const model = state.getDatabase();
885
+ await expect(model.countLatestEvents({
886
+ platform: "ios",
887
+ channel: "production",
888
+ sinceMs: 0
889
+ })).resolves.toBe(0);
890
+ await expect(model.countEvents({
891
+ filter: {
892
+ platform: "ios",
893
+ channel: "production",
894
+ type: "RECOVERED",
895
+ fromBundleId: createBundleEventRowFixture("1", 0).to_bundle_id
896
+ },
897
+ sinceMs: 0,
898
+ beforeReceivedAtMs: 100
899
+ })).resolves.toBe(0);
900
+ });
901
+ it("maintains idempotent release, scope, usage, and latest-distribution summaries", async () => {
902
+ const model = state.getDatabase();
903
+ const releaseA = "00000000-0000-7000-8000-000000008001";
904
+ const releaseB = "00000000-0000-7000-8000-000000008002";
905
+ const download = {
906
+ ...createBundleEventRowFixture("9801", 1e3),
907
+ type: "UPDATE_DOWNLOADED",
908
+ install_id: "summary-install-a",
909
+ from_release_id: releaseA,
910
+ to_release_id: releaseB
911
+ };
912
+ const appliedA = {
913
+ ...download,
914
+ id: createBundleEventRowFixture("9802", 2e3).id,
915
+ type: "UPDATE_APPLIED",
916
+ received_at_ms: 2e3
917
+ };
918
+ const appliedB = {
919
+ ...appliedA,
920
+ id: createBundleEventRowFixture("9803", 2100).id,
921
+ install_id: "summary-install-b",
922
+ received_at_ms: 2100
923
+ };
924
+ const recovered = {
925
+ ...appliedA,
926
+ id: createBundleEventRowFixture("9804", 3e3).id,
927
+ type: "RECOVERED",
928
+ from_release_id: releaseB,
929
+ to_release_id: releaseA,
930
+ received_at_ms: 3e3
931
+ };
932
+ for (const event of [
933
+ download,
934
+ appliedA,
935
+ appliedB,
936
+ appliedB,
937
+ recovered
938
+ ]) await record(model, event);
939
+ const releases = [{
940
+ releaseId: releaseA,
941
+ platform: "ios",
942
+ channel: "production"
943
+ }, {
944
+ releaseId: releaseB,
945
+ platform: "ios",
946
+ channel: "production"
947
+ }];
948
+ const lifetime = await model.getReleaseActivity({ releases });
949
+ expect(lifetime.data.map(({ metrics }) => metrics)).toEqual([{
950
+ downloads: 0,
951
+ launches: 1,
952
+ failedLaunches: 0
953
+ }, {
954
+ downloads: 1,
955
+ launches: 2,
956
+ failedLaunches: 1
957
+ }]);
958
+ const scope = await model.getReleaseActivity({
959
+ scope: {
960
+ platform: "ios",
961
+ channel: "production"
962
+ },
963
+ timeRange: {
964
+ start: 0,
965
+ end: 36e5
966
+ }
967
+ });
968
+ expect(scope.data[0]?.metrics).toMatchObject({
969
+ downloads: 1,
970
+ launches: 3,
971
+ failedLaunches: 1,
972
+ uniqueUsers: 2
973
+ });
974
+ const usage = await model.getAppUsage({
975
+ channel: "production",
976
+ platform: "all",
977
+ timeRange: {
978
+ start: 0,
979
+ end: 36e5
980
+ },
981
+ intervalMs: 36e5
982
+ });
983
+ expect(usage.activeInstallations).toBe(2);
984
+ expect(usage.points).toEqual([{
985
+ startMs: 0,
986
+ installations: 2
987
+ }]);
988
+ expect(usage.bundleDistribution).toEqual(expect.arrayContaining([{
989
+ appVersion: "1.0.0",
990
+ platform: "ios",
991
+ releaseId: releaseA,
992
+ installations: 1
993
+ }, {
994
+ appVersion: "1.0.0",
995
+ platform: "ios",
996
+ releaseId: releaseB,
997
+ installations: 1
998
+ }]));
999
+ });
1000
+ it("pages insights events newest first with a stable cursor", async () => {
1001
+ const model = state.getDatabase();
1002
+ const first = createBundleEventRowFixture("701", 100);
1003
+ const second = createBundleEventRowFixture("702", 100);
1004
+ const third = createBundleEventRowFixture("703", 200);
1005
+ await model.recordEvent({ event: third });
1006
+ await model.recordEvent({ event: second });
1007
+ await model.recordEvent({ event: first });
1008
+ await expectInsightsIndex(() => model.listEvents({
1009
+ filter: { kind: "all" },
1010
+ beforeReceivedAtMs: 201,
1011
+ limit: 2
1012
+ }), [third, second]);
1013
+ await expectInsightsIndex(() => model.listEvents({
1014
+ filter: { kind: "all" },
1015
+ after: {
1016
+ receivedAtMs: second.received_at_ms,
1017
+ id: second.id
1018
+ },
1019
+ beforeReceivedAtMs: 201,
1020
+ limit: 2
1021
+ }), [first]);
1022
+ await expectInsightsIndex(() => model.listEvents({
1023
+ filter: { kind: "all" },
1024
+ beforeReceivedAtMs: 200,
1025
+ limit: 10
1026
+ }), [second, first]);
1027
+ });
1028
+ it("filters installation movements before applying the page limit", async () => {
1029
+ const model = state.getDatabase();
1030
+ const unchanged = {
1031
+ ...createBundleEventRowFixture("711", 300),
1032
+ type: "UNCHANGED",
1033
+ install_id: "install-target",
1034
+ from_bundle_id: null,
1035
+ metadata: {
1036
+ ...createBundleEventRowFixture("711", 300).metadata,
1037
+ update_strategy: null
1038
+ }
1039
+ };
1040
+ const unrelated = createMovementEvent("712", 250, "UPDATE_APPLIED", "install-other");
1041
+ const applied = createMovementEvent("713", 200, "UPDATE_APPLIED", "install-target");
1042
+ const recovered = createMovementEvent("714", 100, "RECOVERED", "install-target");
1043
+ for (const row of [
1044
+ unchanged,
1045
+ unrelated,
1046
+ applied,
1047
+ recovered
1048
+ ]) await model.recordEvent({ event: row });
1049
+ await expectInsightsIndex(() => model.listEvents({
1050
+ filter: {
1051
+ kind: "installationMovement",
1052
+ installId: "install-target"
1053
+ },
1054
+ beforeReceivedAtMs: 301,
1055
+ limit: 1
1056
+ }), [applied]);
1057
+ await expectInsightsIndex(() => model.listEvents({
1058
+ filter: {
1059
+ kind: "installationMovement",
1060
+ installId: "install-target"
1061
+ },
1062
+ after: {
1063
+ receivedAtMs: applied.received_at_ms,
1064
+ id: applied.id
1065
+ },
1066
+ beforeReceivedAtMs: 301,
1067
+ limit: 1
1068
+ }), [recovered]);
1069
+ });
1070
+ });
1071
+ };
1072
+ //#endregion
1073
+ //#region src/setupInsightsModelTestSuite.ts
1074
+ /**
1075
+ * The Insights report contract on one database: pass the Insights plugin's
1076
+ * model over it, `createInsightsModel(createDatabasePluginApis(database,
1077
+ * [insights()]).insights)`, as `createDatabase`.
1078
+ */
1079
+ const setupInsightsModelTestSuite = (lifecycle) => setupDatabaseTestRunner(lifecycle, registerInsightsModelTests);
1080
+ //#endregion
1081
+ //#region src/setupSemverSatisfiesTestSuite.ts
1082
+ /**
1083
+ *
1084
+ * Filters based on semver. And sorts by the highest bundle version.
1085
+ *
1086
+ * * Range Expression Table:
1087
+ *
1088
+ * | Range Expression | Who gets the update |
1089
+ * |------------------|------------------------------------------------------------------------|
1090
+ * | 1.2.3 | Only devices running the specific binary app store version 1.2.3 of your app |
1091
+ * | * | Any device configured to consume updates from your CodePush app |
1092
+ * | 1.2.x | Devices running major version 1, minor version 2 and any patch version of your app |
1093
+ * | 1.2.3 - 1.2.7 | Devices running any binary version between 1.2.3 (inclusive) and 1.2.7 (inclusive) |
1094
+ * | >=1.2.3 <1.2.7 | Devices running any binary version between 1.2.3 (inclusive) and 1.2.7 (exclusive) |
1095
+ * | 1.2 | Equivalent to >=1.2.0 <1.3.0 |
1096
+ * | ~1.2.3 | Equivalent to >=1.2.3 <1.3.0 |
1097
+ * | ^1.2.3 | Equivalent to >=1.2.3 <2.0.0 |
1098
+ */
1099
+ const setupSemverSatisfiesTestSuite = ({ semverSatisfies }) => {
1100
+ it("version 1.2.3 should satisfy version 1.2.3", async () => {
1101
+ expect(await semverSatisfies("1.2.3", "1.2.3")).toBe(true);
1102
+ });
1103
+ it("version 1.2.4 should not satisfy version 1.2.3", async () => {
1104
+ expect(await semverSatisfies("1.2.3", "1.2.4")).toBe(false);
1105
+ });
1106
+ it("version 1.2.4 should not satisfy version 1.2.3", async () => {
1107
+ expect(await semverSatisfies("1.2.3", "1.2.4")).toBe(false);
1108
+ });
1109
+ it("version 1.2.4 should not satisfy version 1.2.3", async () => {
1110
+ expect(await semverSatisfies("1.2.3", "1.2.4")).toBe(false);
1111
+ });
1112
+ it("1.x.x should satisfy version 1.0", async () => {
1113
+ expect(await semverSatisfies("1.x.x", "1.0")).toBe(true);
1114
+ });
1115
+ it("1.x.x should satisfy version 1.12", async () => {
1116
+ expect(await semverSatisfies("1.x.x", "1.12")).toBe(true);
1117
+ });
1118
+ it("1.x.x should satisfy version 1.0.0", async () => {
1119
+ expect(await semverSatisfies("1.x.x", "1.0.0")).toBe(true);
1120
+ });
1121
+ it("1.x.x should satisfy version 1.2.3", async () => {
1122
+ expect(await semverSatisfies("1.x.x", "1.2.3")).toBe(true);
1123
+ });
1124
+ it("1.x.x should not satisfy version 2.0.0", async () => {
1125
+ expect(await semverSatisfies("1.x.x", "2.0.0")).toBe(false);
1126
+ });
1127
+ it("1.x.x should not satisfy version 2.0.0", async () => {
1128
+ expect(await semverSatisfies("1.x.x", "2.0.0")).toBe(false);
1129
+ });
1130
+ it("1.2.x should satisfy version 1.2.5", async () => {
1131
+ expect(await semverSatisfies("1.2.x", "1.2.5")).toBe(true);
1132
+ });
1133
+ it("1.2.x should not satisfy version 1.3.0", async () => {
1134
+ expect(await semverSatisfies("1.2.x", "1.3.0")).toBe(false);
1135
+ });
1136
+ it("1.2.x should not satisfy version 1.3.0", async () => {
1137
+ expect(await semverSatisfies("1.2.x", "1.3.0")).toBe(false);
1138
+ });
1139
+ it("range 1.2.3-1.2.7 should satisfy version 1.2.5", async () => {
1140
+ expect(await semverSatisfies("1.2.3 - 1.2.7", "1.2.5")).toBe(true);
1141
+ });
1142
+ it("range 1.2.3-1.2.7 should satisfy version 1.2.5", async () => {
1143
+ expect(await semverSatisfies("1.2.3 - 1.2.7", "1.2.5")).toBe(true);
1144
+ });
1145
+ it("range 1.2.3-1.2.7 should not satisfy version 1.3.0", async () => {
1146
+ expect(await semverSatisfies("1.2.3 - 1.2.7", "1.3.0")).toBe(false);
1147
+ });
1148
+ it("range >=1.2.3 <1.2.7 should satisfy version 1.2.5", async () => {
1149
+ expect(await semverSatisfies(">=1.2.3 <1.2.7", "1.2.5")).toBe(true);
1150
+ });
1151
+ it("range >=1.2.3 <1.2.7 should satisfy version 1.2.5", async () => {
1152
+ expect(await semverSatisfies(">=1.2.3 <1.2.7", "1.2.5")).toBe(true);
1153
+ });
1154
+ it("range >=1.2.3 <1.2.7 should not satisfy version 1.2.7", async () => {
1155
+ expect(await semverSatisfies(">=1.2.3 <1.2.7", "1.2.7")).toBe(false);
1156
+ });
1157
+ it("~1.2.3 should satisfy version 1.2.3", async () => {
1158
+ expect(await semverSatisfies("~1.2.3", "1.2.3")).toBe(true);
1159
+ });
1160
+ it("~1.2.3 should satisfy version 1.2.4", async () => {
1161
+ expect(await semverSatisfies("~1.2.3", "1.2.4")).toBe(true);
1162
+ });
1163
+ it("~1.2.3 should not satisfy version 1.3.0", async () => {
1164
+ expect(await semverSatisfies("~1.2.3", "1.3.0")).toBe(false);
1165
+ });
1166
+ it("~1.2.3 should satisfy version 1.2.3", async () => {
1167
+ expect(await semverSatisfies("~1.2.3", "1.2.3")).toBe(true);
1168
+ });
1169
+ it("~1.2.3 should satisfy version 1.2.4", async () => {
1170
+ expect(await semverSatisfies("~1.2.3", "1.2.4")).toBe(true);
1171
+ });
1172
+ it("~1.2.3 should not satisfy version 1.3.0", async () => {
1173
+ expect(await semverSatisfies("~1.2.3", "1.3.0")).toBe(false);
1174
+ });
1175
+ it("^1.2.3 should satisfy version 1.3.0", async () => {
1176
+ expect(await semverSatisfies("^1.2.3", "1.3.0")).toBe(true);
1177
+ });
1178
+ it("^1.2.3 should satisfy version 1.3.0", async () => {
1179
+ expect(await semverSatisfies("^1.2.3", "1.3.0")).toBe(true);
1180
+ });
1181
+ it("^1.2.3 should not satisfy version 2.0.0", async () => {
1182
+ expect(await semverSatisfies("^1.2.3", "2.0.0")).toBe(false);
1183
+ });
1184
+ it("version 1.2 should satisfy version 1.2.0 (equivalent to >=1.2.0 <1.3.0)", async () => {
1185
+ expect(await semverSatisfies("1.2", "1.2.0")).toBe(true);
1186
+ });
1187
+ it("version 1.2 should satisfy version 1.2.99 (equivalent to >=1.2.0 <1.3.0)", async () => {
1188
+ expect(await semverSatisfies("1.2", "1.2.99")).toBe(true);
1189
+ });
1190
+ it("version 1.2 should not satisfy version 1.3.0", async () => {
1191
+ expect(await semverSatisfies("1.2", "1.3.0")).toBe(false);
1192
+ });
1193
+ it("single-major '1' should satisfy version 1.0.0 (equivalent to >=1.0.0 <2.0.0)", async () => {
1194
+ expect(await semverSatisfies("1", "1.0.0")).toBe(true);
1195
+ });
1196
+ it("single-major '1' should satisfy version 1.99.99", async () => {
1197
+ expect(await semverSatisfies("1", "1.99.99")).toBe(true);
1198
+ });
1199
+ it("single-major '1' should not satisfy version 2.0.0", async () => {
1200
+ expect(await semverSatisfies("1", "2.0.0")).toBe(false);
1201
+ });
1202
+ it("0.x.x should satisfy version 0.5.10", async () => {
1203
+ expect(await semverSatisfies("0.x.x", "0.5.10")).toBe(true);
1204
+ });
1205
+ it("0.x.x should not satisfy version 1.0.0", async () => {
1206
+ expect(await semverSatisfies("0.x.x", "1.0.0")).toBe(false);
1207
+ });
1208
+ it("2.x should satisfy version 2.0.0", async () => {
1209
+ expect(await semverSatisfies("2.x", "2.0.0")).toBe(true);
1210
+ });
1211
+ it("2.x should satisfy version 2.10.1", async () => {
1212
+ expect(await semverSatisfies("2.x", "2.10.1")).toBe(true);
1213
+ });
1214
+ it("2.x should not satisfy version 3.0.0", async () => {
1215
+ expect(await semverSatisfies("2.x", "3.0.0")).toBe(false);
1216
+ });
1217
+ it("simple star * should satisfy any version, e.g. 10.11.12", async () => {
1218
+ expect(await semverSatisfies("*", "10.11.12")).toBe(true);
1219
+ });
1220
+ it("simple star * should satisfy any version, e.g. 0.0.1", async () => {
1221
+ expect(await semverSatisfies("*", "0.0.1")).toBe(true);
1222
+ });
1223
+ it("2.x.x should satisfy version 2.0.0", async () => {
1224
+ expect(await semverSatisfies("2.x.x", "2.0.0")).toBe(true);
1225
+ });
1226
+ it("2.x.x should satisfy version 2.9.9", async () => {
1227
+ expect(await semverSatisfies("2.x.x", "2.9.9")).toBe(true);
1228
+ });
1229
+ it("2.x.x should not satisfy version 3.0.0", async () => {
1230
+ expect(await semverSatisfies("2.x.x", "3.0.0")).toBe(false);
1231
+ });
1232
+ it("0.x.x should satisfy version 0.1.0", async () => {
1233
+ expect(await semverSatisfies("0.x.x", "0.1.0")).toBe(true);
1234
+ });
1235
+ it("0.x.x should satisfy version 0.9.9", async () => {
1236
+ expect(await semverSatisfies("0.x.x", "0.9.9")).toBe(true);
1237
+ });
1238
+ it("0.x.x should not satisfy version 1.0.0", async () => {
1239
+ expect(await semverSatisfies("0.x.x", "1.0.0")).toBe(false);
1240
+ });
1241
+ it("range 1.2.3 - 2.0.0 should satisfy version 1.2.4", async () => {
1242
+ expect(await semverSatisfies("1.2.3 - 2.0.0", "1.2.4")).toBe(true);
1243
+ });
1244
+ it("range 1.2.3 - 2.0.0 should satisfy version 2.0.0", async () => {
1245
+ expect(await semverSatisfies("1.2.3 - 2.0.0", "2.0.0")).toBe(true);
1246
+ });
1247
+ it("range 1.2.3 - 2.0.0 should satisfy version 1.5.0", async () => {
1248
+ expect(await semverSatisfies("1.2.3 - 2.0.0", "1.5.0")).toBe(true);
1249
+ });
1250
+ it("range 1.2.3 - 2.0.0 should not satisfy version 2.0.1", async () => {
1251
+ expect(await semverSatisfies("1.2.3 - 2.0.0", "2.0.1")).toBe(false);
1252
+ });
1253
+ it("range >= 5.7.0 <= 5.7.4 should satisfy version 5.7.3", async () => {
1254
+ expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.7.3")).toBe(true);
1255
+ });
1256
+ it("range >= 5.7.0 <= 5.7.4 should satisfy version 5.7.0", async () => {
1257
+ expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.7.0")).toBe(true);
1258
+ });
1259
+ it("range >= 5.7.0 <= 5.7.4 should satisfy version 5.7.4", async () => {
1260
+ expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.7.4")).toBe(true);
1261
+ });
1262
+ it("range >= 5.7.0 <= 5.7.4 should not satisfy version 5.6.9", async () => {
1263
+ expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.6.9")).toBe(false);
1264
+ });
1265
+ it("range >= 5.7.0 <= 5.7.4 should not satisfy version 5.7.5", async () => {
1266
+ expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.7.5")).toBe(false);
1267
+ });
1268
+ it("range >=5.7.0 <=5.7.4 (no spaces around operators) should satisfy version 5.7.3", async () => {
1269
+ expect(await semverSatisfies(">=5.7.0 <=5.7.4", "5.7.3")).toBe(true);
1270
+ });
1271
+ it("range >= 5.7.0 <= 5.7.4 (multiple spaces) should satisfy version 5.7.3", async () => {
1272
+ expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.7.3")).toBe(true);
1273
+ });
1274
+ it("range >= 1.0.0 <= 2.0.0 should satisfy version 1.5.0", async () => {
1275
+ expect(await semverSatisfies(">= 1.0.0 <= 2.0.0", "1.5.0")).toBe(true);
1276
+ });
1277
+ it("range >= 1.0.0 <= 2.0.0 should satisfy version 1.0.0", async () => {
1278
+ expect(await semverSatisfies(">= 1.0.0 <= 2.0.0", "1.0.0")).toBe(true);
1279
+ });
1280
+ it("range >= 1.0.0 <= 2.0.0 should satisfy version 2.0.0", async () => {
1281
+ expect(await semverSatisfies(">= 1.0.0 <= 2.0.0", "2.0.0")).toBe(true);
1282
+ });
1283
+ it("range >= 1.0.0 <= 2.0.0 should not satisfy version 0.9.9", async () => {
1284
+ expect(await semverSatisfies(">= 1.0.0 <= 2.0.0", "0.9.9")).toBe(false);
1285
+ });
1286
+ it("range >= 1.0.0 <= 2.0.0 should not satisfy version 2.0.1", async () => {
1287
+ expect(await semverSatisfies(">= 1.0.0 <= 2.0.0", "2.0.1")).toBe(false);
1288
+ });
1289
+ };
1290
+ //#endregion
1291
+ //#region src/releaseCatalogHttpFixtures.ts
1292
+ const releaseCatalogDownloadUrl = (uri) => `https://storage.example.com/${encodeURIComponent(uri)}`;
1293
+ const targetId = createBundleRowFixture("302").id;
1294
+ const RELEASE_CATALOG_MANIFEST_URIS = {
1295
+ small: `storage://test-bucket/small/bundles/${targetId}/manifest.json`,
1296
+ large: `storage://test-bucket/large/bundles/${targetId}/manifest.json`
1297
+ };
1298
+ const RELEASE_CATALOG_DOWNLOAD_HASH = "a".repeat(64);
1299
+ const manifestAssets = { "index.ios.bundle": {
1300
+ fileHash: "target-hbc-hash",
1301
+ downloadFileHash: RELEASE_CATALOG_DOWNLOAD_HASH,
1302
+ downloadByteSize: 1e3
1303
+ } };
1304
+ const releaseCatalogArtifact = (bundle) => ({
1305
+ artifactProtocolVersion: 1,
1306
+ manifestUrl: releaseCatalogDownloadUrl(bundle.manifest_storage_uri),
1307
+ manifestFileHash: bundle.manifest_file_hash,
1308
+ assets: { "index.ios.bundle": {
1309
+ fileHash: "target-hbc-hash",
1310
+ file: {
1311
+ compression: "br",
1312
+ url: releaseCatalogDownloadUrl(`${bundle.asset_base_storage_uri}/sha256/aa/${RELEASE_CATALOG_DOWNLOAD_HASH}.br`)
1313
+ }
1314
+ } }
1315
+ });
1316
+ /** Install these storage objects in the test server, independently of its DB. */
1317
+ const RELEASE_CATALOG_STORAGE_FIXTURES = Object.fromEntries(Object.entries(RELEASE_CATALOG_MANIFEST_URIS).map(([size, uri]) => [uri, JSON.stringify({
1318
+ bundleId: targetId,
1319
+ assets: manifestAssets,
1320
+ archive: {
1321
+ downloadFileHash: "b".repeat(64),
1322
+ downloadByteSize: size === "small" ? 1 : 1e4,
1323
+ tarByteSize: 2e4
1324
+ }
1325
+ })]));
1326
+ /** Storage boundary shared by provider and separately spawned server tests. */
1327
+ const createReleaseCatalogTestStorage = () => ({
1328
+ name: "catalog-test-storage",
1329
+ protocol: "storage",
1330
+ async get({ storageUri }) {
1331
+ const suffix = /^storage:\/\/bundles\/([^/]+)\/manifest\.json$/.exec(storageUri)?.[1];
1332
+ const body = RELEASE_CATALOG_STORAGE_FIXTURES[storageUri] ?? (suffix === void 0 ? void 0 : JSON.stringify({
1333
+ bundleId: createBundleRowFixture(suffix).id,
1334
+ assets: manifestAssets
1335
+ }));
1336
+ return { response: body === void 0 ? null : new Response(body) };
1337
+ },
1338
+ async getDownloadUrl({ storageUri }) {
1339
+ return { url: releaseCatalogDownloadUrl(storageUri) };
1340
+ }
1341
+ });
1342
+ //#endregion
1343
+ //#region src/releaseCatalogLifecycleTests.ts
1344
+ function setupReleaseCatalogLifecycleTests(options) {
1345
+ const device = (minimumReleaseId = NIL_UUID) => ({
1346
+ builtInBundleId: minimumReleaseId,
1347
+ currentBundleId: minimumReleaseId,
1348
+ activeReleaseId: null,
1349
+ minimumReleaseId,
1350
+ cohort: "1",
1351
+ crashedBundleIds: []
1352
+ });
1353
+ const refresh = async (previous) => {
1354
+ let next = previous;
1355
+ await expect.poll(async () => {
1356
+ next = await options.readCatalog();
1357
+ return next.generation;
1358
+ }, {
1359
+ interval: 100,
1360
+ timeout: 1e4
1361
+ }).toBeGreaterThan(previous.generation);
1362
+ expect(next.catalogId).toBe(previous.catalogId);
1363
+ expect(next.catalogHash).not.toBe(previous.catalogHash);
1364
+ return next;
1365
+ };
1366
+ const install = async (catalog, app, target, status) => {
1367
+ const desired = selectDesiredRelease(catalog, app);
1368
+ expect(desired).toMatchObject({
1369
+ kind: "BUNDLE",
1370
+ releaseId: target.release.id,
1371
+ bundleId: target.bundle.id,
1372
+ status,
1373
+ release: {
1374
+ message: target.release.message,
1375
+ shouldForceUpdate: target.release.should_force_update
1376
+ }
1377
+ });
1378
+ const response = await options.request(`/artifacts/v1/${desired.bundleId}/from/${app.currentBundleId}`);
1379
+ expect(response.status).toBe(200);
1380
+ expect(await response.json()).toEqual(releaseCatalogArtifact(target.bundle));
1381
+ app.currentBundleId = desired.bundleId;
1382
+ app.activeReleaseId = desired.releaseId;
1383
+ };
1384
+ const useBuiltin = (catalog, app) => {
1385
+ expect(selectDesiredRelease(catalog, app)).toEqual({
1386
+ kind: "BUILTIN",
1387
+ releaseId: null,
1388
+ bundleId: app.builtInBundleId,
1389
+ release: null,
1390
+ status: "ROLLBACK"
1391
+ });
1392
+ app.currentBundleId = app.builtInBundleId;
1393
+ app.activeReleaseId = null;
1394
+ };
1395
+ const staysOn = (catalog, app) => {
1396
+ expect(selectDesiredRelease(catalog, app)).toMatchObject({
1397
+ bundleId: app.currentBundleId,
1398
+ releaseId: app.activeReleaseId
1399
+ });
1400
+ };
1401
+ describe("OTA lifecycle through HTTP and the client selector", () => {
1402
+ it("runs built-in → OTA A → OTA B → rollback A → built-in after policy changes", async () => {
1403
+ const app = device();
1404
+ const first = await options.publish("801");
1405
+ const second = await options.publish("802", {
1406
+ enabled: false,
1407
+ should_force_update: true
1408
+ });
1409
+ let catalog = await options.readCatalog();
1410
+ await install(catalog, app, first, "UPDATE");
1411
+ staysOn(catalog, app);
1412
+ await options.update(second.release.id, { enabled: true });
1413
+ catalog = await refresh(catalog);
1414
+ await install(catalog, app, second, "UPDATE");
1415
+ staysOn(catalog, app);
1416
+ await options.update(second.release.id, { enabled: false });
1417
+ catalog = await refresh(catalog);
1418
+ await install(catalog, app, first, "ROLLBACK");
1419
+ staysOn(catalog, app);
1420
+ await options.update(first.release.id, { enabled: false });
1421
+ catalog = await refresh(catalog);
1422
+ useBuiltin(catalog, app);
1423
+ staysOn(catalog, app);
1424
+ }, 4e4);
1425
+ it("rolls a deleted active Release back to older OTA bytes, then to built-in", async () => {
1426
+ const app = device();
1427
+ const first = await options.publish("811");
1428
+ const second = await options.publish("812");
1429
+ let catalog = await options.readCatalog();
1430
+ await install(catalog, app, second, "UPDATE");
1431
+ await options.remove(second.release.id);
1432
+ catalog = await refresh(catalog);
1433
+ await install(catalog, app, first, "ROLLBACK");
1434
+ await options.remove(first.release.id);
1435
+ catalog = await refresh(catalog);
1436
+ useBuiltin(catalog, app);
1437
+ staysOn(catalog, app);
1438
+ }, 3e4);
1439
+ it("respects the native minimum on fresh install and rollback with or without a Release receipt", async () => {
1440
+ await options.publish("821");
1441
+ const current = await options.publish("823", { enabled: false });
1442
+ const app = device(createBundleRowFixture("822").id);
1443
+ const catalog = await options.readCatalog();
1444
+ useBuiltin(catalog, app);
1445
+ for (const activeReleaseId of [current.release.id, null]) {
1446
+ app.currentBundleId = current.bundle.id;
1447
+ app.activeReleaseId = activeReleaseId;
1448
+ useBuiltin(catalog, app);
1449
+ }
1450
+ expect(selectDesiredRelease(catalog, {
1451
+ ...device(current.bundle.id),
1452
+ activeReleaseId: current.release.id
1453
+ })).toBeNull();
1454
+ });
1455
+ it("re-evaluates cohort changes and rolls back even when the predecessor rollout is closed", async () => {
1456
+ const app = {
1457
+ ...device(),
1458
+ cohort: "staff"
1459
+ };
1460
+ const first = await options.publish("831", {
1461
+ rollout_cohort_count: 0,
1462
+ target_cohorts: ["staff"]
1463
+ });
1464
+ const second = await options.publish("832", {
1465
+ rollout_cohort_count: 0,
1466
+ target_cohorts: ["staff"]
1467
+ });
1468
+ const catalog = await options.readCatalog();
1469
+ await install(catalog, app, second, "UPDATE");
1470
+ app.cohort = "1";
1471
+ await install(catalog, app, first, "ROLLBACK");
1472
+ useBuiltin(catalog, app);
1473
+ staysOn(catalog, app);
1474
+ });
1475
+ it("does not reinstall crashed OTAs after native recovery to a previous OTA or built-in", async () => {
1476
+ const app = device();
1477
+ const first = await options.publish("841");
1478
+ const second = await options.publish("842", { enabled: false });
1479
+ let catalog = await options.readCatalog();
1480
+ await install(catalog, app, first, "UPDATE");
1481
+ await options.update(second.release.id, { enabled: true });
1482
+ catalog = await refresh(catalog);
1483
+ await install(catalog, app, second, "UPDATE");
1484
+ app.currentBundleId = first.bundle.id;
1485
+ app.activeReleaseId = first.release.id;
1486
+ app.crashedBundleIds = [second.bundle.id];
1487
+ staysOn(catalog, app);
1488
+ app.currentBundleId = app.builtInBundleId;
1489
+ app.activeReleaseId = null;
1490
+ app.crashedBundleIds = [first.bundle.id, second.bundle.id];
1491
+ useBuiltin(catalog, app);
1492
+ staysOn(catalog, app);
1493
+ const fixed = await options.publish("843");
1494
+ catalog = await refresh(catalog);
1495
+ await install(catalog, app, fixed, "UPDATE");
1496
+ staysOn(catalog, app);
1497
+ }, 25e3);
1498
+ it("selects a compatible OTA and stays on it despite a newer incompatible Release", async () => {
1499
+ const app = device();
1500
+ const compatible = await options.publish("851");
1501
+ await options.publishIncompatible("852");
1502
+ const catalog = await options.readCatalog();
1503
+ await install(catalog, app, compatible, "UPDATE");
1504
+ staysOn(catalog, app);
1505
+ });
1506
+ });
1507
+ }
1508
+ //#endregion
1509
+ //#region src/setupReleaseCatalogTestSuite.ts
1510
+ const NIL_UUID$1 = "00000000-0000-0000-0000-000000000000";
1511
+ const FALLBACK_POLICY = "BUILTIN_IF_ACTIVE_INELIGIBLE";
1512
+ const channelKey = (channel) => btoa(String.fromCharCode(...new TextEncoder().encode(channel))).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
1513
+ /**
1514
+ * Server conformance through HTTP only, on admin API protocol 2. The caller
1515
+ * owns server, database, and storage setup. Releases are written with
1516
+ * `POST /releases` and changed with `PATCH /releases/:id`; lifecycle scenarios
1517
+ * feed HTTP catalogs to the production client selector. Each test deletes
1518
+ * what it wrote, so a long-lived server can run the suite again.
1519
+ */
1520
+ const setupReleaseCatalogTestSuite = (options) => {
1521
+ describe("Release Catalog HTTP contract", () => {
1522
+ let channelNamespace;
1523
+ const api = createAdminApiTestClient(options.getClient);
1524
+ const { admin, adminJson } = api;
1525
+ beforeEach(() => {
1526
+ channelNamespace = crypto.randomUUID();
1527
+ });
1528
+ afterEach(async () => {
1529
+ await api.cleanup();
1530
+ }, 18e4);
1531
+ const request = (path, init) => options.getClient().client(path, init);
1532
+ const bundleRow = (suffix) => createBundleRowFixture(suffix);
1533
+ for (const strategy of ["APP_VERSION", "FINGERPRINT"]) describe(strategy, () => {
1534
+ const scope = (channel = "production", platform = "ios", fingerprint = "fingerprint-a") => {
1535
+ channel = `${channel}-${channelNamespace}`;
1536
+ const encodedChannel = channelKey(channel);
1537
+ return {
1538
+ channel,
1539
+ channelKey: encodedChannel,
1540
+ platform,
1541
+ fingerprint: strategy === "FINGERPRINT" ? fingerprint : null,
1542
+ key: strategy === "APP_VERSION" ? `v1:app-version:${platform}:${encodedChannel}` : `v1:fingerprint:${platform}:${encodedChannel}:${fingerprint}`
1543
+ };
1544
+ };
1545
+ const catalogPath = (target = scope(), appVersion = "1.2.3") => `/release-catalogs/${strategy === "APP_VERSION" ? "app-version" : "fingerprint"}/${target.platform}/${target.channelKey}/${strategy === "APP_VERSION" ? appVersion : target.fingerprint}`;
1546
+ const catalogRowPath = (target = scope()) => `/release-catalogs/${encodeURIComponent(target.key)}`;
1547
+ const readCatalog = async (target = scope(), appVersion = "1.2.3") => {
1548
+ const response = await request(catalogPath(target, appVersion));
1549
+ expect(response.status).toBe(200);
1550
+ expect(response.headers.get("content-type")).toBe("application/vnd.hot-updater.release-catalog+json; version=1");
1551
+ expect(response.headers.get("cache-control")).toBe("public, max-age=0, s-maxage=5");
1552
+ const catalog = await response.json();
1553
+ expect(catalog).toMatchObject({
1554
+ schemaVersion: 1,
1555
+ scopeKey: target.key,
1556
+ fallbackPolicy: FALLBACK_POLICY,
1557
+ catalogId: expect.any(String),
1558
+ catalogHash: expect.stringMatching(/^sha256:[0-9a-f]{64}$/)
1559
+ });
1560
+ return catalog;
1561
+ };
1562
+ /** Deploys a new bundle, or a stored one named by `bundle_id`, into `target`. */
1563
+ const publish = async (suffix, patch = {}, target = scope()) => {
1564
+ const bundle = {
1565
+ ...bundleRow(suffix),
1566
+ platform: target.platform,
1567
+ asset_base_storage_uri: "storage://test-bucket/assets"
1568
+ };
1569
+ const policy = {
1570
+ channel: target.channel,
1571
+ enabled: patch.enabled ?? true,
1572
+ fingerprintHash: target.fingerprint,
1573
+ message: patch.message === void 0 ? `release-${suffix}` : patch.message,
1574
+ shouldForceUpdate: patch.should_force_update ?? false,
1575
+ targetAppVersion: strategy === "APP_VERSION" ? patch.target_app_version ?? "*" : null,
1576
+ ...patch.rollout_cohort_count === void 0 ? {} : { rolloutCohortCount: patch.rollout_cohort_count },
1577
+ ...patch.target_cohorts === void 0 ? {} : { targetCohorts: [...patch.target_cohorts] }
1578
+ };
1579
+ const release = await api.deploy(patch.bundle_id === void 0 || patch.bundle_id === null ? {
1580
+ bundle: toDeployBundle(bundle),
1581
+ release: policy
1582
+ } : {
1583
+ bundleId: patch.bundle_id,
1584
+ release: policy
1585
+ });
1586
+ return {
1587
+ bundle: patch.bundle_id === void 0 || patch.bundle_id === null ? bundle : {
1588
+ ...bundle,
1589
+ id: patch.bundle_id
1590
+ },
1591
+ release
1592
+ };
1593
+ };
1594
+ const update = (releaseId, patch) => adminJson(`/releases/${releaseId}`, jsonRequest("PATCH", { patch }));
1595
+ setupReleaseCatalogLifecycleTests({
1596
+ readCatalog,
1597
+ publish,
1598
+ publishIncompatible: (suffix) => strategy === "APP_VERSION" ? publish(suffix, { target_app_version: ">=1.2.4" }) : publish(suffix, {}, scope("production", "ios", "fingerprint-b")),
1599
+ update,
1600
+ remove: async (id) => {
1601
+ await update(id, { enabled: false });
1602
+ await adminJson(`/releases/${id}?confirm=${id}&expectedRevision=2`, jsonRequest("DELETE"));
1603
+ expect((await admin(`/releases/${id}`)).status).toBe(404);
1604
+ api.forget(id);
1605
+ },
1606
+ request
1607
+ });
1608
+ it("pages all scoped Releases including disabled rows with an exclusive cursor", async () => {
1609
+ const first = await publish("701");
1610
+ const disabled = await publish("702", { enabled: false });
1611
+ await publish("703", {}, scope("other"));
1612
+ const path = `/releases?scopeKey=${encodeURIComponent(scope().key)}&limit=1&order=asc`;
1613
+ const page1 = await adminJson(path);
1614
+ expect(page1.data).toEqual([first.release]);
1615
+ expect(page1.next).toBe(first.release.id);
1616
+ const page2 = await adminJson(`${path}&cursor=${page1.next}`);
1617
+ expect(page2.data).toEqual([disabled.release]);
1618
+ expect((await adminJson(`${path}&cursor=${page2.next}`)).data).toEqual([]);
1619
+ expect((await adminJson(`/releases?scopeKey=${encodeURIComponent(scope().key)}&enabled=false`)).data).toEqual([disabled.release]);
1620
+ });
1621
+ it("lets one of two concurrent policy changes at the same revision win", async () => {
1622
+ const current = await publish("721");
1623
+ const catalog = (await adminJson(catalogRowPath())).data;
1624
+ const responses = await Promise.all(["first", "second"].map((message) => admin(`/releases/${current.release.id}`, jsonRequest("PATCH", {
1625
+ expectedRevision: 1,
1626
+ patch: { message }
1627
+ }))));
1628
+ const statuses = responses.map(({ status }) => status).sort();
1629
+ expect(statuses).toEqual([200, 409]);
1630
+ const winner = responses.findIndex(({ status }) => status === 200);
1631
+ const loser = responses[1 - winner];
1632
+ expect(await loser.json()).toMatchObject({ code: "VERSION_CONFLICT" });
1633
+ await responses[winner].text();
1634
+ expect((await adminJson(`/releases/${current.release.id}`)).data).toMatchObject({
1635
+ revision: 2,
1636
+ message: ["first", "second"][winner]
1637
+ });
1638
+ expect((await adminJson(catalogRowPath())).data).toMatchObject({ generation: catalog.generation + 1 });
1639
+ });
1640
+ it("hard deletes a Release, rebuilds its Catalog, and retains Bundle bytes", async () => {
1641
+ const current = await publish("741", { enabled: false });
1642
+ await adminJson(`/releases/${current.release.id}?confirm=${current.release.id}&expectedRevision=1`, jsonRequest("DELETE"));
1643
+ api.forget(current.release.id);
1644
+ expect((await admin(`/releases/${current.release.id}`)).status).toBe(404);
1645
+ expect(await readCatalog()).toMatchObject({
1646
+ releases: [],
1647
+ rollbackReleases: []
1648
+ });
1649
+ const artifact = await request(`/artifacts/v1/${current.bundle.id}/from/${NIL_UUID$1}`);
1650
+ expect(artifact.status).toBe(200);
1651
+ expect(await artifact.json()).toMatchObject({ manifestUrl: releaseCatalogDownloadUrl(current.bundle.manifest_storage_uri) });
1652
+ });
1653
+ it("serves enabled Releases newest first and resolves artifacts by Bundle identity", async () => {
1654
+ const first = await publish("101");
1655
+ const { bundle, release } = await publish("102", {
1656
+ message: "업데이트",
1657
+ should_force_update: true
1658
+ });
1659
+ await publish("103", { enabled: false });
1660
+ const catalog = await readCatalog();
1661
+ expect(catalog.releases.map((item) => item.releaseId)).toEqual([release.id, first.release.id]);
1662
+ expect(catalog.releases[0]).toMatchObject({
1663
+ bundleId: bundle.id,
1664
+ message: "업데이트",
1665
+ shouldForceUpdate: true
1666
+ });
1667
+ const response = await request(`/artifacts/v1/${bundle.id}/from/${NIL_UUID$1}`);
1668
+ expect(response.status).toBe(200);
1669
+ expect(response.headers.get("cache-control")).toBe("private, no-store");
1670
+ expect(await response.json()).toEqual(releaseCatalogArtifact(bundle));
1671
+ expect((await request(`/artifacts/v1/${release.id}/from/${NIL_UUID$1}`)).status).toBe(404);
1672
+ });
1673
+ it("isolates channels, platforms, and update strategies", async () => {
1674
+ const stable = await publish("111");
1675
+ const beta = await publish("112", {}, scope("beta / 한글"));
1676
+ const android = await publish("113", {}, scope("production", "android"));
1677
+ for (const [target, release] of [
1678
+ [scope(), stable.release],
1679
+ [scope("beta / 한글"), beta.release],
1680
+ [scope("production", "android"), android.release]
1681
+ ]) expect((await readCatalog(target)).releases.map((item) => item.releaseId)).toEqual([release.id]);
1682
+ const otherPath = strategy === "APP_VERSION" ? `/release-catalogs/fingerprint/ios/${scope().channelKey}/fingerprint-a` : `/release-catalogs/app-version/ios/${scope().channelKey}/1.2.3`;
1683
+ expect((await request(otherPath)).status).toBe(404);
1684
+ });
1685
+ it("returns an uncached 404 for an unknown scope", async () => {
1686
+ const response = await request(catalogPath());
1687
+ expect(response.status).toBe(404);
1688
+ expect(response.headers.get("cache-control")).toBe("private, no-store");
1689
+ await publish("121");
1690
+ expect((await readCatalog()).releases).toHaveLength(1);
1691
+ });
1692
+ it("preserves rollout and named cohort policy without server-side cohort selection", async () => {
1693
+ const stable = await publish("131");
1694
+ const partial = await publish("132", { rollout_cohort_count: 500 });
1695
+ const targeted = await publish("133", {
1696
+ rollout_cohort_count: 0,
1697
+ target_cohorts: ["qa"]
1698
+ });
1699
+ const catalog = await readCatalog();
1700
+ expect(catalog.releases.map(({ releaseId, rolloutCohortCount, targetCohorts }) => ({
1701
+ releaseId,
1702
+ rolloutCohortCount,
1703
+ targetCohorts
1704
+ }))).toEqual([
1705
+ {
1706
+ releaseId: targeted.release.id,
1707
+ rolloutCohortCount: 0,
1708
+ targetCohorts: ["qa"]
1709
+ },
1710
+ {
1711
+ releaseId: partial.release.id,
1712
+ rolloutCohortCount: 500,
1713
+ targetCohorts: []
1714
+ },
1715
+ {
1716
+ releaseId: stable.release.id,
1717
+ rolloutCohortCount: 1e3,
1718
+ targetCohorts: []
1719
+ }
1720
+ ]);
1721
+ });
1722
+ it("keeps the newest Release policy when Bundle bytes are reused", async () => {
1723
+ const first = await publish("141");
1724
+ const second = await publish("142", {
1725
+ bundle_id: first.bundle.id,
1726
+ message: "new policy"
1727
+ });
1728
+ const catalog = await readCatalog();
1729
+ expect(catalog.releases[0]).toMatchObject({
1730
+ releaseId: second.release.id,
1731
+ bundleId: first.bundle.id,
1732
+ message: "new policy"
1733
+ });
1734
+ expect(catalog.rollbackReleases?.map((item) => item.releaseId)).toEqual([second.release.id, first.release.id]);
1735
+ });
1736
+ it("revalidates immutable responses against the persisted Catalog", async () => {
1737
+ await publish("161");
1738
+ const response = await request(catalogPath());
1739
+ const catalog = await response.json();
1740
+ const etag = response.headers.get("etag");
1741
+ expect(etag).toMatch(/^"sha256:[0-9a-f]{64}"$/);
1742
+ const revalidated = await request(catalogPath(), { headers: { "if-none-match": etag } });
1743
+ expect(revalidated.status).toBe(304);
1744
+ expect(await revalidated.text()).toBe("");
1745
+ const persisted = await adminJson(catalogRowPath());
1746
+ expect(persisted.data).toMatchObject({
1747
+ catalog_id: catalog.catalogId,
1748
+ catalog_hash: catalog.catalogHash,
1749
+ generation: catalog.generation
1750
+ });
1751
+ const rebuild = await adminJson(`${catalogRowPath()}/rebuild`, jsonRequest("POST"));
1752
+ expect(rebuild.data.changed).toBe(false);
1753
+ expect(rebuild.data.catalog.generation).toBe(catalog.generation);
1754
+ });
1755
+ it("refreshes a cached Catalog after HTTP policy changes and retains rollback candidates", async () => {
1756
+ const predecessor = await publish("171", { rollout_cohort_count: 0 });
1757
+ const current = await publish("172");
1758
+ const response = await request(catalogPath());
1759
+ const previous = await response.json();
1760
+ await update(current.release.id, { enabled: false });
1761
+ let refreshed;
1762
+ await expect.poll(async () => {
1763
+ const next = await request(catalogPath(), { headers: { "if-none-match": response.headers.get("etag") } });
1764
+ const text = await next.text();
1765
+ if (next.status === 200) refreshed = JSON.parse(text);
1766
+ return next.status;
1767
+ }, {
1768
+ interval: 100,
1769
+ timeout: 1e4
1770
+ }).toBe(200);
1771
+ expect(refreshed).toMatchObject({
1772
+ catalogId: previous.catalogId,
1773
+ generation: previous.generation + 1,
1774
+ releases: []
1775
+ });
1776
+ expect(refreshed?.catalogHash).not.toBe(previous.catalogHash);
1777
+ expect(refreshed?.rollbackReleases).toEqual([expect.objectContaining({
1778
+ releaseId: predecessor.release.id,
1779
+ bundleId: predecessor.bundle.id,
1780
+ rolloutCohortCount: 0
1781
+ })]);
1782
+ }, 15e3);
1783
+ it("publishes an empty tombstone after the last Release is disabled", async () => {
1784
+ const current = await publish("181");
1785
+ await update(current.release.id, { enabled: false });
1786
+ expect(await readCatalog()).toMatchObject({
1787
+ releases: [],
1788
+ rollbackReleases: [],
1789
+ fallbackPolicy: FALLBACK_POLICY
1790
+ });
1791
+ expect((await adminJson(catalogRowPath())).data.is_tombstone).toBe(true);
1792
+ });
1793
+ it("returns HTTP 409 for stale policy revisions without changing the persisted Catalog", async () => {
1794
+ const current = await publish("191");
1795
+ await update(current.release.id, { message: "new policy" });
1796
+ const before = await adminJson(catalogRowPath());
1797
+ const response = await admin(`/releases/${current.release.id}`, jsonRequest("PATCH", {
1798
+ expectedRevision: 1,
1799
+ patch: { enabled: false }
1800
+ }));
1801
+ expect(response.status).toBe(409);
1802
+ expect(await response.json()).toMatchObject({ code: "VERSION_CONFLICT" });
1803
+ expect(await adminJson(catalogRowPath())).toEqual(before);
1804
+ expect((await adminJson(`/releases/${current.release.id}`)).data).toMatchObject({
1805
+ enabled: true,
1806
+ revision: 2,
1807
+ message: "new policy"
1808
+ });
1809
+ });
1810
+ if (strategy === "APP_VERSION") {
1811
+ it("pages each release filter set by key, newest first", async () => {
1812
+ const first = await publish("751", { enabled: false });
1813
+ const second = await publish("752", {
1814
+ bundle_id: first.bundle.id,
1815
+ enabled: false
1816
+ });
1817
+ const third = await publish("753");
1818
+ const elsewhere = await publish("754", { bundle_id: first.bundle.id }, scope("other"));
1819
+ await publish("755", {}, scope("production", "android"));
1820
+ const { data: channel } = await adminJson(`/channels?name=${encodeURIComponent(scope().channel)}`);
1821
+ /** Every page of one filter set, one release per page. */
1822
+ const pages = async (query) => {
1823
+ const ids = [];
1824
+ let cursor;
1825
+ do {
1826
+ const page = await adminJson(`/releases?${query}&limit=1${cursor === void 0 ? "" : `&cursor=${cursor}`}`);
1827
+ ids.push(...page.data.map(({ id }) => id));
1828
+ cursor = page.next;
1829
+ } while (cursor !== void 0);
1830
+ return ids;
1831
+ };
1832
+ const newestFirst = (...published) => published.map(({ release }) => release.id).sort().reverse();
1833
+ const byChannel = `channelId=${channel.id}&platform=ios`;
1834
+ expect(await pages(byChannel)).toEqual(newestFirst(first, second, third));
1835
+ expect(await pages(`${byChannel}&enabled=false`)).toEqual(newestFirst(first, second));
1836
+ expect(await pages(`bundleId=${first.bundle.id}`)).toEqual(newestFirst(first, second, elsewhere));
1837
+ expect(await pages(`scopeKey=${encodeURIComponent(scope().key)}&enabled=true`)).toEqual(newestFirst(third));
1838
+ expect((await admin(`/releases?platform=ios&enabled=false`)).status).toBe(400);
1839
+ });
1840
+ it("serves the newest Release among 200 distinct compatible version ranges", async () => {
1841
+ await publish("400", { target_app_version: ">=0.0.0" });
1842
+ for (let index = 1; index < 200; index++) await publish(String(400 + index), { target_app_version: `>=0.${index}.0` });
1843
+ const catalog = await readCatalog(scope(), "1.0.0");
1844
+ expect(catalog.releases[0]?.bundleId).toBe(bundleRow("599").id);
1845
+ expect(catalog.rollbackReleases).toHaveLength(200);
1846
+ }, 18e4);
1847
+ it("keeps version projections separate in the HTTP cache", async () => {
1848
+ const first = await publish("231", { target_app_version: "^1.0.0" });
1849
+ const second = await publish("232", { target_app_version: "^2.0.0" });
1850
+ for (const [version, releaseId] of [
1851
+ ["1.2.3", first.release.id],
1852
+ ["2.1.0", second.release.id],
1853
+ ["1.2.3", first.release.id]
1854
+ ]) expect((await readCatalog(scope(), version)).releases.map((release) => release.releaseId)).toEqual([releaseId]);
1855
+ });
1856
+ it.each([
1857
+ [
1858
+ "*",
1859
+ "1.2.3",
1860
+ true
1861
+ ],
1862
+ [
1863
+ "1.2.3",
1864
+ "1.2.3",
1865
+ true
1866
+ ],
1867
+ [
1868
+ "^1.0.0",
1869
+ "1.2.3",
1870
+ true
1871
+ ],
1872
+ [
1873
+ ">=1.0.0 <2.0.0",
1874
+ "2.0.0",
1875
+ false
1876
+ ],
1877
+ [
1878
+ "~1.2.0",
1879
+ "1.3.0",
1880
+ false
1881
+ ],
1882
+ [
1883
+ "^1.0.0 || ^3.0.0",
1884
+ "3.1.0",
1885
+ true
1886
+ ],
1887
+ [
1888
+ ">= 5.7.0 <= 5.7.4",
1889
+ "5.7.3",
1890
+ true
1891
+ ],
1892
+ [
1893
+ ">= 5.7.0 <= 5.7.4",
1894
+ "5.7.5",
1895
+ false
1896
+ ]
1897
+ ])("projects %s for app version %s (compatible: %s)", async (range, version, compatible) => {
1898
+ const current = await publish("201", { target_app_version: range });
1899
+ expect((await readCatalog(scope(), version)).releases.map((item) => item.releaseId)).toEqual(compatible ? [current.release.id] : []);
1900
+ });
1901
+ it("rejects noncanonical versions and unsupported platforms with HTTP 400", async () => {
1902
+ await publish("211");
1903
+ for (const path of [catalogPath(scope(), "v1.2"), catalogPath().replace("/ios/", "/windows/")]) {
1904
+ const response = await request(path);
1905
+ expect(response.status).toBe(400);
1906
+ expect(response.headers.get("cache-control")).toBe("private, no-store");
1907
+ }
1908
+ });
1909
+ } else {
1910
+ it("pages Catalogs in scope order with an exclusive cursor and limit", async () => {
1911
+ const first = scope("production", "ios", "paging-a");
1912
+ const second = scope("production", "ios", "paging-b");
1913
+ await publish("761", {}, second);
1914
+ await publish("762", {}, first);
1915
+ const prefix = first.key.slice(0, -1);
1916
+ const page1 = await adminJson(`/release-catalogs?limit=1&order=asc&cursor=${encodeURIComponent(prefix)}`);
1917
+ expect(page1.data).toEqual([(await adminJson(catalogRowPath(first))).data]);
1918
+ expect(page1.next).toBe(first.key);
1919
+ expect((await adminJson(`/release-catalogs?limit=1&order=asc&cursor=${encodeURIComponent(page1.next)}`)).data).toEqual([(await adminJson(catalogRowPath(second))).data]);
1920
+ });
1921
+ it("isolates exact fingerprints", async () => {
1922
+ const first = await publish("221");
1923
+ const second = await publish("222", {}, scope("production", "ios", "fingerprint-b"));
1924
+ expect((await readCatalog()).releases.map((item) => item.releaseId)).toEqual([first.release.id]);
1925
+ expect((await readCatalog(scope("production", "ios", "fingerprint-b"))).releases.map((item) => item.releaseId)).toEqual([second.release.id]);
1926
+ expect((await request(catalogPath(scope("production", "ios", "unknown")))).status).toBe(404);
1927
+ });
1928
+ }
1929
+ });
1930
+ it.each(["small", "large"])("retains original and patch plans alongside a %s optional archive", async (size) => {
1931
+ const policy = {
1932
+ channel: `artifacts-${channelNamespace}`,
1933
+ enabled: false,
1934
+ fingerprintHash: null,
1935
+ message: null,
1936
+ shouldForceUpdate: false,
1937
+ targetAppVersion: "*"
1938
+ };
1939
+ const base = bundleRow("301");
1940
+ const target = {
1941
+ ...bundleRow("302"),
1942
+ manifest_storage_uri: RELEASE_CATALOG_MANIFEST_URIS[size],
1943
+ manifest_file_hash: "manifest-hash",
1944
+ asset_base_storage_uri: "storage://test-bucket/assets"
1945
+ };
1946
+ const patch = {
1947
+ ...createBundlePatchRowFixture("302", target.id, base.id),
1948
+ byte_size: 10
1949
+ };
1950
+ await api.deploy({
1951
+ bundle: toDeployBundle(base),
1952
+ release: policy
1953
+ });
1954
+ await api.deploy({
1955
+ bundle: {
1956
+ ...toDeployBundle(target),
1957
+ patches: [{
1958
+ baseBundleId: base.id,
1959
+ baseFileHash: patch.base_file_hash,
1960
+ byteSize: patch.byte_size,
1961
+ patchFileHash: patch.patch_file_hash,
1962
+ patchStorageUri: patch.patch_storage_uri
1963
+ }]
1964
+ },
1965
+ release: policy
1966
+ });
1967
+ const response = await request(`/artifacts/v1/${target.id}/from/${base.id}`);
1968
+ expect(response.status).toBe(200);
1969
+ const artifact = await response.json();
1970
+ const original = releaseCatalogArtifact(target);
1971
+ const archiveUrl = releaseCatalogDownloadUrl(target.manifest_storage_uri.replace("manifest.json", "bundle.tar.br"));
1972
+ expect(artifact).toEqual({
1973
+ ...original,
1974
+ archiveUrl,
1975
+ assets: { "index.ios.bundle": {
1976
+ ...original.assets["index.ios.bundle"],
1977
+ patch: {
1978
+ algorithm: "bsdiff",
1979
+ baseBundleId: base.id,
1980
+ baseFileHash: patch.base_file_hash,
1981
+ patchFileHash: patch.patch_file_hash,
1982
+ patchUrl: releaseCatalogDownloadUrl(patch.patch_storage_uri),
1983
+ byteSize: patch.byte_size
1984
+ }
1985
+ } }
1986
+ });
1987
+ const freshInstall = await request(`/artifacts/v1/${target.id}/from/${NIL_UUID$1}`);
1988
+ expect(freshInstall.status).toBe(200);
1989
+ expect(await freshInstall.json()).toEqual({
1990
+ ...original,
1991
+ archiveUrl
1992
+ });
1993
+ });
1994
+ });
1995
+ };
1996
+ //#endregion
1997
+ //#region src/setupDatabaseTestSuite.ts
1998
+ /**
1999
+ * A provider's database under Hot Updater's server: core's operations, the
2000
+ * Release Catalog contract, bundles, and Insights, all through HTTP, on the
2001
+ * tables the provider's tooling creates; with `createInsightsModel`, also the
2002
+ * Insights report contract in process.
2003
+ */
2004
+ const setupDatabaseTestSuite = (options) => {
2005
+ setupDatabaseTestRunner(options, ({ getDatabase }) => {
2006
+ let client;
2007
+ beforeEach(async () => {
2008
+ client = await options.createHttpClient({
2009
+ database: getDatabase(),
2010
+ storage: [createReleaseCatalogTestStorage()]
2011
+ });
2012
+ });
2013
+ afterEach(async () => {
2014
+ await client?.close();
2015
+ client = void 0;
2016
+ });
2017
+ const getClient = () => {
2018
+ if (client === void 0) throw new Error("HTTP test server has not started");
2019
+ return client;
2020
+ };
2021
+ setupCoreAdminTestSuite({ getClient });
2022
+ setupBundleMethodsTestSuite({ getClient });
2023
+ setupReleaseCatalogTestSuite({ getClient });
2024
+ setupInsightsHttpTestSuite({ getClient });
2025
+ const { createInsightsModel } = options;
2026
+ if (createInsightsModel !== void 0) describe("Insights model", () => {
2027
+ registerInsightsModelTests({ getDatabase: () => createInsightsModel(getDatabase()) });
2028
+ });
2029
+ });
2030
+ };
2031
+ //#endregion
2032
+ //#region src/setupDatabaseAdapterConformanceSuite.ts
2033
+ const conformanceItems = {
2034
+ name: "conformance_items",
2035
+ columns: [
2036
+ {
2037
+ name: "id",
2038
+ type: "string",
2039
+ nullable: false,
2040
+ maxLength: 64
2041
+ },
2042
+ {
2043
+ name: "grp",
2044
+ type: "string",
2045
+ nullable: false,
2046
+ maxLength: 64
2047
+ },
2048
+ {
2049
+ name: "score",
2050
+ type: "integer",
2051
+ nullable: false
2052
+ },
2053
+ {
2054
+ name: "ratio",
2055
+ type: "number",
2056
+ nullable: true
2057
+ },
2058
+ {
2059
+ name: "flag",
2060
+ type: "boolean",
2061
+ nullable: false
2062
+ },
2063
+ {
2064
+ name: "label",
2065
+ type: "string",
2066
+ nullable: true,
2067
+ maxLength: 255
2068
+ },
2069
+ {
2070
+ name: "tags",
2071
+ type: "string",
2072
+ nullable: true,
2073
+ maxLength: 64,
2074
+ multi: true
2075
+ },
2076
+ {
2077
+ name: "payload",
2078
+ type: "json",
2079
+ nullable: true
2080
+ },
2081
+ {
2082
+ name: "note",
2083
+ type: "string",
2084
+ nullable: true
2085
+ },
2086
+ {
2087
+ name: "_v",
2088
+ type: "integer",
2089
+ nullable: false
2090
+ }
2091
+ ],
2092
+ key: ["id"],
2093
+ indexes: [
2094
+ {
2095
+ name: "all",
2096
+ eq: [],
2097
+ sort: ["id"]
2098
+ },
2099
+ {
2100
+ name: "byGroup",
2101
+ eq: ["grp"],
2102
+ sort: ["score"]
2103
+ },
2104
+ {
2105
+ name: "byGroupFlag",
2106
+ eq: ["grp", "flag"],
2107
+ sort: ["score"]
2108
+ },
2109
+ {
2110
+ name: "byLabel",
2111
+ eq: ["label"],
2112
+ sort: [],
2113
+ unique: true
2114
+ },
2115
+ {
2116
+ name: "byTag",
2117
+ eq: ["tags"],
2118
+ sort: ["score"]
2119
+ }
2120
+ ]
2121
+ };
2122
+ const conformanceCounters = {
2123
+ name: "conformance_counters",
2124
+ columns: [
2125
+ {
2126
+ name: "scope",
2127
+ type: "string",
2128
+ nullable: false,
2129
+ maxLength: 64
2130
+ },
2131
+ {
2132
+ name: "shard",
2133
+ type: "integer",
2134
+ nullable: false
2135
+ },
2136
+ {
2137
+ name: "hits",
2138
+ type: "integer",
2139
+ nullable: false
2140
+ },
2141
+ {
2142
+ name: "_v",
2143
+ type: "integer",
2144
+ nullable: false
2145
+ }
2146
+ ],
2147
+ key: ["scope", "shard"],
2148
+ indexes: [{
2149
+ name: "byScope",
2150
+ eq: ["scope"],
2151
+ sort: ["shard"]
2152
+ }]
2153
+ };
2154
+ const tables = [conformanceItems, conformanceCounters];
2155
+ const item = (id, values = {}) => ({
2156
+ id,
2157
+ grp: "g",
2158
+ score: 0,
2159
+ ratio: null,
2160
+ flag: false,
2161
+ label: null,
2162
+ tags: null,
2163
+ payload: null,
2164
+ note: null,
2165
+ _v: 0,
2166
+ ...values
2167
+ });
2168
+ const counterKey = (scope, shard = 0) => [scope, shard];
2169
+ const counterInit = (scope, shard = 0) => ({
2170
+ scope,
2171
+ shard,
2172
+ hits: 0,
2173
+ _v: 0
2174
+ });
2175
+ const insert = (row) => ({
2176
+ type: "insert",
2177
+ table: conformanceItems,
2178
+ row
2179
+ });
2180
+ const shuffled = (values) => values.map((value) => ({
2181
+ value,
2182
+ order: Math.random()
2183
+ })).sort((left, right) => left.order - right.order).map(({ value }) => value);
2184
+ /**
2185
+ * The storage-adapter conformance contract (PRD S3 and S4 cases). Every
2186
+ * adapter runs it through `verifyAdapter`, which also checks each read and
2187
+ * write at the adapter boundary.
2188
+ */
2189
+ const setupDatabaseAdapterConformanceSuite = (options) => {
2190
+ const writers = options.writers ?? 32;
2191
+ let cleanup;
2192
+ const setup = async (context = {}) => {
2193
+ const created = await options.createAdapter({
2194
+ tables,
2195
+ ...context
2196
+ });
2197
+ cleanup = created.cleanup;
2198
+ return verifyAdapter(created.adapter, { probeShortPages: true });
2199
+ };
2200
+ const ok = async (adapter, ops) => expect(await adapter.write(ops)).toEqual({ ok: true });
2201
+ const one = async (adapter, id) => (await adapter.get(conformanceItems, [[id]]))[0] ?? null;
2202
+ const query = (adapter, request) => adapter.query(conformanceItems, {
2203
+ order: "asc",
2204
+ limit: 500,
2205
+ ...request
2206
+ });
2207
+ const ids = (rows) => rows.map((row) => row.id);
2208
+ const snapshot = async (adapter) => ({
2209
+ items: await query(adapter, {
2210
+ index: "all",
2211
+ eq: []
2212
+ }),
2213
+ counters: await adapter.query(conformanceCounters, {
2214
+ index: "byScope",
2215
+ eq: ["c"],
2216
+ order: "asc",
2217
+ limit: 500
2218
+ })
2219
+ });
2220
+ describe(`${options.name} database adapter conformance`, () => {
2221
+ afterEach(async () => {
2222
+ await cleanup?.();
2223
+ cleanup = void 0;
2224
+ });
2225
+ it("round-trips every value type", async () => {
2226
+ const adapter = await setup();
2227
+ const rows = [item("round-trip", {
2228
+ grp: "Ωmega 😀",
2229
+ score: Number.MAX_SAFE_INTEGER,
2230
+ ratio: -1.25,
2231
+ flag: true,
2232
+ label: "",
2233
+ tags: ["b", "a"],
2234
+ payload: {
2235
+ nested: {
2236
+ list: [
2237
+ 1,
2238
+ "two",
2239
+ null,
2240
+ true
2241
+ ],
2242
+ ключ: "значение"
2243
+ },
2244
+ empty: {},
2245
+ none: []
2246
+ },
2247
+ note: "x".repeat(1e4)
2248
+ }), item("round-trip-min", {
2249
+ score: Number.MIN_SAFE_INTEGER,
2250
+ ratio: .5
2251
+ })];
2252
+ await ok(adapter, rows.map(insert));
2253
+ expect(await adapter.get(conformanceItems, [["round-trip"], ["round-trip-min"]])).toEqual(rows);
2254
+ });
2255
+ it("reads keys in request order with null for missing keys", async () => {
2256
+ const adapter = await setup();
2257
+ await ok(adapter, [insert(item("a")), insert(item("b"))]);
2258
+ expect(await adapter.get(conformanceItems, [
2259
+ ["b"],
2260
+ ["missing"],
2261
+ ["a"]
2262
+ ])).toEqual([
2263
+ item("b"),
2264
+ null,
2265
+ item("a")
2266
+ ]);
2267
+ expect(await adapter.get(conformanceItems, [])).toEqual([]);
2268
+ });
2269
+ it("refuses an insert over an existing key", async () => {
2270
+ const adapter = await setup();
2271
+ await ok(adapter, [insert(item("a", { score: 1 }))]);
2272
+ expect(await adapter.write([insert(item("a", { score: 2 }))])).toEqual({
2273
+ ok: false,
2274
+ failedOp: 0
2275
+ });
2276
+ expect(await one(adapter, "a")).toEqual(item("a", { score: 1 }));
2277
+ });
2278
+ it("patches named columns, bumps _v, and refuses a stale guard", async () => {
2279
+ const adapter = await setup();
2280
+ const row = item("a", { note: "keep" });
2281
+ await ok(adapter, [insert(row)]);
2282
+ const patch = (v, id = "a") => ({
2283
+ type: "patch",
2284
+ table: conformanceItems,
2285
+ key: [id],
2286
+ set: { score: 5 },
2287
+ guard: { v },
2288
+ previous: row
2289
+ });
2290
+ await ok(adapter, [patch(0)]);
2291
+ expect(await one(adapter, "a")).toEqual({
2292
+ ...row,
2293
+ score: 5,
2294
+ _v: 1
2295
+ });
2296
+ expect(await adapter.write([patch(0)])).toEqual({
2297
+ ok: false,
2298
+ failedOp: 0
2299
+ });
2300
+ expect(await adapter.write([patch(0, "missing")])).toEqual({
2301
+ ok: false,
2302
+ failedOp: 0
2303
+ });
2304
+ });
2305
+ it("deletes only with a matching guard", async () => {
2306
+ const adapter = await setup();
2307
+ const row = item("a");
2308
+ await ok(adapter, [insert(row)]);
2309
+ const remove = (v) => ({
2310
+ type: "delete",
2311
+ table: conformanceItems,
2312
+ key: ["a"],
2313
+ guard: { v },
2314
+ previous: row
2315
+ });
2316
+ expect(await adapter.write([remove(1)])).toEqual({
2317
+ ok: false,
2318
+ failedOp: 0
2319
+ });
2320
+ await ok(adapter, [remove(0)]);
2321
+ expect(await one(adapter, "a")).toBeNull();
2322
+ expect(await adapter.write([remove(0)])).toEqual({
2323
+ ok: false,
2324
+ failedOp: 0
2325
+ });
2326
+ });
2327
+ it("increments counters, creating a missing row from init", async () => {
2328
+ const adapter = await setup();
2329
+ const increment = (values = {}) => ({
2330
+ type: "increment",
2331
+ table: conformanceCounters,
2332
+ key: counterKey("c"),
2333
+ by: { hits: 2 },
2334
+ init: counterInit("c"),
2335
+ ...values
2336
+ });
2337
+ await ok(adapter, [increment()]);
2338
+ await ok(adapter, [increment({ by: { hits: 3 } })]);
2339
+ expect(await adapter.get(conformanceCounters, [counterKey("c")])).toEqual([{
2340
+ scope: "c",
2341
+ shard: 0,
2342
+ hits: 5,
2343
+ _v: 2
2344
+ }]);
2345
+ expect(await adapter.write([increment({ guard: { v: 1 } })])).toEqual({
2346
+ ok: false,
2347
+ failedOp: 0
2348
+ });
2349
+ expect(await adapter.write([increment({
2350
+ key: counterKey("c", 1),
2351
+ init: void 0
2352
+ })])).toEqual({
2353
+ ok: false,
2354
+ failedOp: 0
2355
+ });
2356
+ });
2357
+ it("checks a row version without writing it", async () => {
2358
+ const adapter = await setup();
2359
+ await ok(adapter, [insert(item("a"))]);
2360
+ const check = (v, id = "a") => ({
2361
+ type: "check",
2362
+ table: conformanceItems,
2363
+ key: [id],
2364
+ guard: { v }
2365
+ });
2366
+ await ok(adapter, [check(0)]);
2367
+ expect(await one(adapter, "a")).toEqual(item("a"));
2368
+ expect(await adapter.write([check(1)])).toEqual({
2369
+ ok: false,
2370
+ failedOp: 0
2371
+ });
2372
+ expect(await adapter.write([check(0, "missing")])).toEqual({
2373
+ ok: false,
2374
+ failedOp: 0
2375
+ });
2376
+ });
2377
+ it("applies nothing when an op at any position fails", async () => {
2378
+ const adapter = await setup();
2379
+ const rows = [
2380
+ item("a"),
2381
+ item("b"),
2382
+ item("d"),
2383
+ item("e")
2384
+ ];
2385
+ await ok(adapter, [...rows.map(insert), {
2386
+ type: "increment",
2387
+ table: conformanceCounters,
2388
+ key: counterKey("c"),
2389
+ by: { hits: 1 },
2390
+ init: counterInit("c")
2391
+ }]);
2392
+ const before = await snapshot(adapter);
2393
+ const batch = (stale) => [
2394
+ insert(item(stale === 0 ? "e" : "x")),
2395
+ {
2396
+ type: "patch",
2397
+ table: conformanceItems,
2398
+ key: ["a"],
2399
+ set: { score: 9 },
2400
+ guard: { v: stale === 1 ? 7 : 0 },
2401
+ previous: rows[0]
2402
+ },
2403
+ {
2404
+ type: "delete",
2405
+ table: conformanceItems,
2406
+ key: ["b"],
2407
+ guard: { v: stale === 2 ? 7 : 0 },
2408
+ previous: rows[1]
2409
+ },
2410
+ {
2411
+ type: "increment",
2412
+ table: conformanceCounters,
2413
+ key: counterKey("c"),
2414
+ by: { hits: 1 },
2415
+ guard: { v: stale === 3 ? 7 : 1 }
2416
+ },
2417
+ {
2418
+ type: "check",
2419
+ table: conformanceItems,
2420
+ key: ["d"],
2421
+ guard: { v: stale === 4 ? 7 : 0 }
2422
+ }
2423
+ ];
2424
+ for (let stale = 0; stale < 5; stale += 1) {
2425
+ expect(await adapter.write(batch(stale))).toEqual({
2426
+ ok: false,
2427
+ failedOp: stale
2428
+ });
2429
+ expect(await snapshot(adapter)).toEqual(before);
2430
+ }
2431
+ await ok(adapter, batch(-1));
2432
+ expect(ids(await query(adapter, {
2433
+ index: "all",
2434
+ eq: []
2435
+ }))).toEqual([
2436
+ "a",
2437
+ "d",
2438
+ "e",
2439
+ "x"
2440
+ ]);
2441
+ });
2442
+ it("refuses a duplicate on a unique index with the failing op", async () => {
2443
+ const adapter = await setup();
2444
+ await ok(adapter, [insert(item("a", { label: "L" }))]);
2445
+ expect(await adapter.write([insert(item("b", { label: "M" })), insert(item("c", { label: "L" }))])).toEqual({
2446
+ ok: false,
2447
+ failedOp: 1
2448
+ });
2449
+ expect(await one(adapter, "b")).toBeNull();
2450
+ await ok(adapter, [insert(item("b", { label: "M" }))]);
2451
+ expect(await adapter.write([{
2452
+ type: "patch",
2453
+ table: conformanceItems,
2454
+ key: ["b"],
2455
+ set: { label: "L" },
2456
+ guard: { v: 0 },
2457
+ previous: item("b", { label: "M" })
2458
+ }])).toEqual({
2459
+ ok: false,
2460
+ failedOp: 0
2461
+ });
2462
+ await ok(adapter, [insert(item("d")), insert(item("e"))]);
2463
+ await ok(adapter, [{
2464
+ type: "delete",
2465
+ table: conformanceItems,
2466
+ key: ["a"],
2467
+ guard: { v: 0 },
2468
+ previous: item("a", { label: "L" })
2469
+ }, insert(item("f", { label: "L" }))]);
2470
+ expect(ids(await query(adapter, {
2471
+ index: "byLabel",
2472
+ eq: ["L"]
2473
+ }))).toEqual(["f"]);
2474
+ });
2475
+ it("orders strings by UTF-8 bytes and numbers numerically", async () => {
2476
+ const adapter = await setup();
2477
+ const names = [
2478
+ "b",
2479
+ "B",
2480
+ "a",
2481
+ "a ",
2482
+ "é",
2483
+ "z",
2484
+ "😀",
2485
+ "ff",
2486
+ "10",
2487
+ "9"
2488
+ ];
2489
+ const scores = [
2490
+ 10,
2491
+ -5,
2492
+ 2,
2493
+ 0,
2494
+ Number.MAX_SAFE_INTEGER
2495
+ ];
2496
+ await ok(adapter, [...names.map((id) => insert(item(id, { grp: "names" }))), ...scores.map((score) => insert(item(`n${score}`, {
2497
+ grp: "n",
2498
+ score
2499
+ })))]);
2500
+ expect(ids(await query(adapter, {
2501
+ index: "byGroup",
2502
+ eq: ["names"]
2503
+ }))).toEqual([
2504
+ "10",
2505
+ "9",
2506
+ "B",
2507
+ "a",
2508
+ "a ",
2509
+ "b",
2510
+ "z",
2511
+ "é",
2512
+ "ff",
2513
+ "😀"
2514
+ ]);
2515
+ const byScore = await query(adapter, {
2516
+ index: "byGroup",
2517
+ eq: ["n"]
2518
+ });
2519
+ expect(byScore.map((row) => row.score)).toEqual([
2520
+ -5,
2521
+ 0,
2522
+ 2,
2523
+ 10,
2524
+ Number.MAX_SAFE_INTEGER
2525
+ ]);
2526
+ const reversed = await query(adapter, {
2527
+ index: "byGroup",
2528
+ eq: ["n"],
2529
+ order: "desc"
2530
+ });
2531
+ expect(ids(reversed)).toEqual(ids(byScore).toReversed());
2532
+ });
2533
+ it("pages through eq and bounds from a cursor without gaps or repeats", async () => {
2534
+ const adapter = await setup();
2535
+ const rows = Array.from({ length: 23 }, (_, index) => item(`p${String(index).padStart(2, "0")}`, {
2536
+ grp: "p",
2537
+ score: Math.floor(index / 2),
2538
+ flag: index % 3 === 0
2539
+ }));
2540
+ await ok(adapter, [...rows.map(insert), insert(item("q", {
2541
+ grp: "q",
2542
+ score: 3
2543
+ }))]);
2544
+ for (const order of ["asc", "desc"]) {
2545
+ const read = [];
2546
+ for (;;) {
2547
+ const last = read.at(-1);
2548
+ const bound = last === void 0 ? {} : { [order === "asc" ? "lower" : "upper"]: {
2549
+ values: indexOrderTuple(conformanceItems, findPhysicalIndex(conformanceItems, "byGroup"), last),
2550
+ inclusive: false
2551
+ } };
2552
+ const page = await query(adapter, {
2553
+ index: "byGroup",
2554
+ eq: ["p"],
2555
+ order,
2556
+ limit: 3,
2557
+ ...bound
2558
+ });
2559
+ read.push(...page);
2560
+ if (page.length < 3) break;
2561
+ }
2562
+ const expected = ids(rows);
2563
+ expect(ids(read)).toEqual(order === "asc" ? expected : expected.toReversed());
2564
+ }
2565
+ expect(ids(await query(adapter, {
2566
+ index: "byGroup",
2567
+ eq: ["p"],
2568
+ lower: {
2569
+ values: [3],
2570
+ inclusive: true
2571
+ },
2572
+ upper: {
2573
+ values: [5],
2574
+ inclusive: false
2575
+ }
2576
+ }))).toEqual([
2577
+ "p06",
2578
+ "p07",
2579
+ "p08",
2580
+ "p09"
2581
+ ]);
2582
+ expect(ids(await query(adapter, {
2583
+ index: "byGroupFlag",
2584
+ eq: ["p", true],
2585
+ lower: {
2586
+ values: [6, "p12"],
2587
+ inclusive: false
2588
+ }
2589
+ }))).toEqual([
2590
+ "p15",
2591
+ "p18",
2592
+ "p21"
2593
+ ]);
2594
+ });
2595
+ it("indexes every value of a multi-valued field", async () => {
2596
+ const adapter = await setup();
2597
+ const a = item("a", {
2598
+ tags: ["x", "y"],
2599
+ score: 2
2600
+ });
2601
+ await ok(adapter, [
2602
+ insert(a),
2603
+ insert(item("b", {
2604
+ tags: ["y"],
2605
+ score: 1
2606
+ })),
2607
+ insert(item("c", { tags: [] })),
2608
+ insert(item("d"))
2609
+ ]);
2610
+ const tagged = async (tag) => ids(await query(adapter, {
2611
+ index: "byTag",
2612
+ eq: [tag]
2613
+ }));
2614
+ expect(await tagged("x")).toEqual(["a"]);
2615
+ expect(await tagged("y")).toEqual(["b", "a"]);
2616
+ await ok(adapter, [{
2617
+ type: "patch",
2618
+ table: conformanceItems,
2619
+ key: ["a"],
2620
+ set: { tags: ["z"] },
2621
+ guard: { v: 0 },
2622
+ previous: a
2623
+ }]);
2624
+ expect(await tagged("x")).toEqual([]);
2625
+ expect(await tagged("z")).toEqual(["a"]);
2626
+ await ok(adapter, [{
2627
+ type: "delete",
2628
+ table: conformanceItems,
2629
+ key: ["a"],
2630
+ guard: { v: 1 },
2631
+ previous: {
2632
+ ...a,
2633
+ tags: ["z"],
2634
+ _v: 1
2635
+ }
2636
+ }]);
2637
+ expect(await tagged("z")).toEqual([]);
2638
+ });
2639
+ it("fills every page when native pages are capped", async () => {
2640
+ const adapter = await setup({ nativePageSize: 2 });
2641
+ await ok(adapter, Array.from({ length: 9 }, (_, index) => insert(item(`r${index}`))));
2642
+ const first = await query(adapter, {
2643
+ index: "all",
2644
+ eq: [],
2645
+ limit: 5
2646
+ });
2647
+ expect(ids(first)).toEqual([
2648
+ "r0",
2649
+ "r1",
2650
+ "r2",
2651
+ "r3",
2652
+ "r4"
2653
+ ]);
2654
+ const second = await query(adapter, {
2655
+ index: "all",
2656
+ eq: [],
2657
+ limit: 5,
2658
+ lower: {
2659
+ values: ["r4"],
2660
+ inclusive: false
2661
+ }
2662
+ });
2663
+ expect(ids(second)).toEqual([
2664
+ "r5",
2665
+ "r6",
2666
+ "r7",
2667
+ "r8"
2668
+ ]);
2669
+ });
2670
+ it(`lets exactly one of ${writers} concurrent writers win a contested key`, async () => {
2671
+ const adapter = await setup();
2672
+ const row = item("a");
2673
+ await ok(adapter, [insert(row)]);
2674
+ const results = await Promise.all(shuffled(Array.from({ length: writers }, (_, index) => index)).map((index) => adapter.write([{
2675
+ type: "patch",
2676
+ table: conformanceItems,
2677
+ key: ["a"],
2678
+ set: { score: index + 1 },
2679
+ guard: { v: 0 },
2680
+ previous: row
2681
+ }])));
2682
+ expect(results.filter((result) => result.ok)).toHaveLength(1);
2683
+ expect(results.every((result) => result.ok || "retry" in result || result.failedOp === 0)).toBe(true);
2684
+ expect(await one(adapter, "a")).toMatchObject({ _v: 1 });
2685
+ });
2686
+ it(`loses no increments under ${writers} concurrent writers`, async () => {
2687
+ const adapter = await setup();
2688
+ const incrementUntilCommitted = async () => {
2689
+ for (let attempt = 0; attempt < 50; attempt += 1) {
2690
+ const result = await adapter.write([{
2691
+ type: "increment",
2692
+ table: conformanceCounters,
2693
+ key: counterKey("c"),
2694
+ by: { hits: 1 },
2695
+ init: counterInit("c")
2696
+ }]);
2697
+ if (result.ok) return;
2698
+ if (!("retry" in result)) throw new Error("increment failed");
2699
+ }
2700
+ throw new Error("increment kept retrying");
2701
+ };
2702
+ await Promise.all(Array.from({ length: writers }, () => incrementUntilCommitted()));
2703
+ expect(await adapter.get(conformanceCounters, [counterKey("c")])).toEqual([{
2704
+ scope: "c",
2705
+ shard: 0,
2706
+ hits: writers,
2707
+ _v: writers
2708
+ }]);
2709
+ });
2710
+ it("rejects write skew between two checked writers", async () => {
2711
+ const adapter = await setup();
2712
+ const a = item("a");
2713
+ const b = item("b");
2714
+ await ok(adapter, [insert(a), insert(b)]);
2715
+ const skew = (read, written) => [{
2716
+ type: "check",
2717
+ table: conformanceItems,
2718
+ key: [read.id],
2719
+ guard: { v: 0 }
2720
+ }, {
2721
+ type: "patch",
2722
+ table: conformanceItems,
2723
+ key: [written.id],
2724
+ set: { score: 1 },
2725
+ guard: { v: 0 },
2726
+ previous: written
2727
+ }];
2728
+ const results = await Promise.all([adapter.write(skew(a, b)), adapter.write(skew(b, a))]);
2729
+ expect(results.filter((result) => result.ok).length).toBeLessThanOrEqual(1);
2730
+ });
2731
+ it.skipIf(options.maxOps === void 0)("rejects an over-limit write before sending it", async () => {
2732
+ const adapter = await setup();
2733
+ const ops = Array.from({ length: (options.maxOps ?? 0) + 1 }, (_, index) => insert(item(`over${index}`)));
2734
+ expect(adapter.fits(ops)).toBe(false);
2735
+ await expect(adapter.write(ops)).rejects.toThrow(DatabaseAdapterContractError);
2736
+ expect(await query(adapter, {
2737
+ index: "all",
2738
+ eq: []
2739
+ })).toEqual([]);
2740
+ });
2741
+ });
2742
+ };
2743
+ //#endregion
2744
+ //#region src/setupReadBudgetTestSuite.ts
2745
+ const DAY = 864e5;
2746
+ const HOUR = 36e5;
2747
+ /** The UTC midnight Insights events start at. */
2748
+ const T0 = Date.UTC(2026, 8, 20);
2749
+ const NATIVE_PAGE_SIZE = 2;
2750
+ const bundleOf = (suffix, platform = "ios", bases = []) => ({
2751
+ ...createBundleFixture(suffix),
2752
+ platform,
2753
+ assetBaseStorageUri: "storage://read-budgets/assets",
2754
+ patches: bases.map((base) => ({
2755
+ baseBundleId: createBundleFixture(base).id,
2756
+ baseFileHash: `base-hash-${base}`,
2757
+ patchFileHash: `patch-hash-${suffix}-${base}`,
2758
+ patchStorageUri: `storage://patches/${suffix}-${base}.patch`,
2759
+ byteSize: 1e3
2760
+ }))
2761
+ });
2762
+ /** Insights event `n`, from install `n` by default, `n` × 10 minutes after T0. */
2763
+ const eventOf = (n, overrides = {}) => ({
2764
+ id: createBundleFixture(String(9e3 + n)).id,
2765
+ type: "UPDATE_APPLIED",
2766
+ install_id: `install-${n}`,
2767
+ user_id: n % 2 === 0 ? "user-even" : "user-odd",
2768
+ from_release_id: "release-a",
2769
+ from_bundle_id: "bundle-a",
2770
+ to_release_id: "release-b",
2771
+ to_bundle_id: "bundle-b",
2772
+ platform: "ios",
2773
+ app_version: "1.0.0",
2774
+ channel: "production",
2775
+ metadata: {
2776
+ username: null,
2777
+ cohort: "1",
2778
+ update_strategy: "appVersion",
2779
+ fingerprint_hash: null,
2780
+ sdk_version: null
2781
+ },
2782
+ received_at_ms: T0 + n * 10 * 6e4,
2783
+ ...overrides
2784
+ });
2785
+ /**
2786
+ * History event `n` of the day before T0, three an hour, on its own release
2787
+ * and bundles: every windowed read, which starts at T0 or later, skips it.
2788
+ */
2789
+ const historyOf = (n) => eventOf(100 + n, {
2790
+ install_id: `history-${n}`,
2791
+ user_id: null,
2792
+ from_bundle_id: "bundle-history-a",
2793
+ to_release_id: "release-history",
2794
+ to_bundle_id: "bundle-history-b",
2795
+ received_at_ms: T0 - DAY + Math.floor((n - 1) / 3) * HOUR + (n - 1) % 3 * 6e5
2796
+ });
2797
+ const production = {
2798
+ channel: "production",
2799
+ enabled: true,
2800
+ fingerprintHash: null,
2801
+ message: null,
2802
+ shouldForceUpdate: false,
2803
+ targetAppVersion: "1.0.0"
2804
+ };
2805
+ const scopeKey = createReleaseCatalogScopeKey({
2806
+ channelKey: encodeChannelKey("production"),
2807
+ platform: "ios",
2808
+ strategy: "APP_VERSION"
2809
+ });
2810
+ const bundleEvents = {
2811
+ kind: "bundle",
2812
+ platform: "ios",
2813
+ channel: "production",
2814
+ type: "UPDATE_APPLIED",
2815
+ toBundleId: "bundle-b"
2816
+ };
2817
+ /** The target bundle's manifest, which artifact resolution reads from storage. */
2818
+ const storage = {
2819
+ readStorageText: async (storageUri) => storageUri === createBundleFixture("106").manifestStorageUri ? JSON.stringify({
2820
+ bundleId: createBundleFixture("106").id,
2821
+ assets: { "index.ios.bundle": { fileHash: "hbc-hash-106" } }
2822
+ }) : null,
2823
+ resolveFileUrl: async (storageUri) => storageUri === null ? null : `https://storage.example.com/${encodeURIComponent(storageUri)}`
2824
+ };
2825
+ /**
2826
+ * Core's bundles, releases, channels, and a client API key, and 25 Insights
2827
+ * events: installs 1–24 ten minutes apart from T0, then install 1 again two
2828
+ * days later; and the day of history before T0, as a production database
2829
+ * holds, so a read that scans beyond its window examines more rows.
2830
+ */
2831
+ const seed = async (database, server) => {
2832
+ const { core } = database;
2833
+ const api = database.api;
2834
+ for (const name of [
2835
+ "staging",
2836
+ "beta",
2837
+ "canary",
2838
+ "nightly"
2839
+ ]) await core.ensureChannel(name);
2840
+ const deploy = async (bundle, channel = "production") => {
2841
+ const [deployed] = await core.deploy([{
2842
+ bundle,
2843
+ release: {
2844
+ ...production,
2845
+ channel
2846
+ }
2847
+ }]);
2848
+ return deployed.release.id;
2849
+ };
2850
+ const releases = [];
2851
+ for (const suffix of [
2852
+ "101",
2853
+ "102",
2854
+ "103",
2855
+ "104",
2856
+ "105"
2857
+ ]) releases.push(await deploy(bundleOf(suffix)));
2858
+ releases.push(await deploy(bundleOf("106", "ios", ["105", "104"])));
2859
+ for (const releaseId of releases.slice(1, 3)) await core.updateReleasePolicy({
2860
+ releaseId,
2861
+ patch: { enabled: false }
2862
+ });
2863
+ await core.promoteRelease({
2864
+ releaseId: releases[4],
2865
+ targetChannel: "beta"
2866
+ });
2867
+ await deploy(bundleOf("201", "android"));
2868
+ await deploy(bundleOf("202", "android"));
2869
+ await deploy(bundleOf("091"), "staging");
2870
+ await deploy(bundleOf("092"), "staging");
2871
+ const { apiKey } = await api.apiKeys.create({ name: "Read budgets" });
2872
+ for (let n = 1; n <= 24; n += 1) await api.insights.recordEvent(eventOf(n));
2873
+ await api.insights.recordEvent(eventOf(25, {
2874
+ install_id: "install-1",
2875
+ received_at_ms: T0 + 2 * DAY
2876
+ }));
2877
+ for (let n = 1; n <= 72; n += 1) await api.insights.recordEvent(historyOf(n));
2878
+ const channel = (name) => core.findChannelByName(name).then((found) => found);
2879
+ const productionId = (await channel("production")).id;
2880
+ return {
2881
+ core,
2882
+ insights: api.insights,
2883
+ clientAuth: database.clientAuth,
2884
+ apiKey,
2885
+ productionId,
2886
+ nightlyId: (await channel("nightly")).id,
2887
+ /** The auto-patch base key of a new production iOS 1.0.0 bundle. */
2888
+ candidateKey: server.targetBaseCandidateKey({
2889
+ channelId: productionId,
2890
+ platform: "ios",
2891
+ fingerprintHash: null,
2892
+ appVersion: "1.0.0"
2893
+ })
2894
+ };
2895
+ };
2896
+ const budget = (entry) => entry;
2897
+ const reads = (gets, keys, queries, rows) => ({
2898
+ gets,
2899
+ keys,
2900
+ queries,
2901
+ rows
2902
+ });
2903
+ const updateCheck = {
2904
+ strategy: "APP_VERSION",
2905
+ platform: "ios",
2906
+ channelKey: encodeChannelKey("production"),
2907
+ appVersion: "1.0.0"
2908
+ };
2909
+ /** The PRD's read-budget list, each count exact at both boundaries. */
2910
+ const READ_BUDGETS = [
2911
+ budget({
2912
+ api: "update check: 1 catalog point read",
2913
+ read: ({ core }) => core.getReleaseCatalog(updateCheck),
2914
+ adapter: reads(1, 1, 0, 0),
2915
+ engine: {
2916
+ calls: 1,
2917
+ rows: 1
2918
+ },
2919
+ check: (catalog) => expect(catalog?.releases).toHaveLength(4)
2920
+ }),
2921
+ budget({
2922
+ api: "update check with api-keys: 1 catalog point read and 1 API-key read",
2923
+ read: async ({ core, clientAuth, apiKey }) => {
2924
+ const headers = new Headers({ "x-api-key": apiKey });
2925
+ if (!await clientAuth.authenticate(headers)) return null;
2926
+ return core.getReleaseCatalog(updateCheck);
2927
+ },
2928
+ adapter: reads(1, 1, 1, 1),
2929
+ engine: {
2930
+ calls: 2,
2931
+ rows: 2
2932
+ },
2933
+ check: (catalog) => expect(catalog?.releases).toHaveLength(4)
2934
+ }),
2935
+ budget({
2936
+ api: "artifact resolution: 1 batch get of 2 bundles and 1 unique patch read",
2937
+ read: ({ core }) => core.getArtifactInfo(createBundleFixture("106").id, createBundleFixture("105").id, 1),
2938
+ adapter: reads(1, 2, 1, 1),
2939
+ engine: {
2940
+ calls: 2,
2941
+ rows: 3
2942
+ },
2943
+ check: (artifact) => expect(artifact).toMatchObject({
2944
+ artifactProtocolVersion: 1,
2945
+ manifestFileHash: createBundleFixture("106").manifestFileHash
2946
+ })
2947
+ }),
2948
+ budget({
2949
+ api: "bundle list page: limit rows and one byBundle query per returned bundle with patches",
2950
+ read: ({ core }) => core.listBundles({
2951
+ platform: "ios",
2952
+ order: "desc",
2953
+ limit: 3
2954
+ }),
2955
+ adapter: reads(0, 0, 2, 5),
2956
+ engine: {
2957
+ calls: 2,
2958
+ rows: 5
2959
+ },
2960
+ returned: (page) => page.reduce((sum, detail) => sum + 1 + detail.patches.length, 0),
2961
+ check: (page) => expect(page.map(({ bundle }) => bundle.id)).toEqual([
2962
+ "106",
2963
+ "105",
2964
+ "104"
2965
+ ].map((suffix) => createBundleFixture(suffix).id))
2966
+ }),
2967
+ budget({
2968
+ api: "bundle total: 1 counter row",
2969
+ read: ({ core }) => core.countBundles("ios"),
2970
+ adapter: reads(0, 0, 1, 1),
2971
+ engine: {
2972
+ calls: 1,
2973
+ rows: 1
2974
+ },
2975
+ check: (total) => expect(total).toBe(8)
2976
+ }),
2977
+ budget({
2978
+ api: "bundle children: nothing extra, the count is on the bundle row",
2979
+ read: ({ core }) => core.getBundle(createBundleFixture("105").id),
2980
+ adapter: reads(1, 1, 0, 0),
2981
+ engine: {
2982
+ calls: 1,
2983
+ rows: 1
2984
+ },
2985
+ check: (detail) => expect(detail).toMatchObject({
2986
+ patches: [],
2987
+ childCount: 1
2988
+ })
2989
+ }),
2990
+ budget({
2991
+ api: "release list: limit rows from one enumerated index",
2992
+ read: ({ core, productionId }) => core.listReleases({
2993
+ filter: {
2994
+ kind: "channelPlatform",
2995
+ channelId: productionId,
2996
+ platform: "ios",
2997
+ enabled: true
2998
+ },
2999
+ limit: 3
3000
+ }),
3001
+ adapter: reads(0, 0, 1, 3),
3002
+ engine: {
3003
+ calls: 1,
3004
+ rows: 3
3005
+ },
3006
+ returned: (releases) => releases.length
3007
+ }),
3008
+ budget({
3009
+ api: "deploy base candidates: at most maxBaseBundles rows",
3010
+ read: ({ core, candidateKey }) => core.findBaseBundleIds(candidateKey, createBundleFixture("107").id, 2),
3011
+ adapter: reads(0, 0, 1, 2),
3012
+ engine: {
3013
+ calls: 1,
3014
+ rows: 2
3015
+ },
3016
+ check: (ids) => expect(ids).toEqual(["106", "105"].map((suffix) => createBundleFixture(suffix).id))
3017
+ }),
3018
+ budget({
3019
+ api: "deploy latest release id: byScope descending, limit 1",
3020
+ read: ({ core }) => core.latestReleaseId(scopeKey),
3021
+ adapter: reads(0, 0, 1, 1),
3022
+ engine: {
3023
+ calls: 1,
3024
+ rows: 1
3025
+ }
3026
+ }),
3027
+ budget({
3028
+ api: "channel by name: 1 read",
3029
+ read: ({ core }) => core.findChannelByName("staging"),
3030
+ adapter: reads(0, 0, 1, 1),
3031
+ engine: {
3032
+ calls: 1,
3033
+ rows: 1
3034
+ },
3035
+ returned: (channel) => channel === null ? 0 : 1
3036
+ }),
3037
+ budget({
3038
+ api: "list events: limit rows, and one zero-row query per empty day",
3039
+ read: ({ insights }) => insights.listEvents({
3040
+ filter: bundleEvents,
3041
+ sinceMs: T0,
3042
+ beforeReceivedAtMs: T0 + 3 * DAY,
3043
+ limit: 5
3044
+ }),
3045
+ adapter: reads(0, 0, 3, 5),
3046
+ engine: {
3047
+ calls: 3,
3048
+ rows: 5
3049
+ },
3050
+ returned: (events) => events.length
3051
+ }),
3052
+ budget({
3053
+ api: "latest events by install: 1 point read",
3054
+ read: ({ insights }) => insights.findLatestEvents({ installId: "install-1" }),
3055
+ adapter: reads(1, 1, 0, 0),
3056
+ engine: {
3057
+ calls: 1,
3058
+ rows: 1
3059
+ },
3060
+ returned: (events) => events.length
3061
+ }),
3062
+ budget({
3063
+ api: "latest events by user: limit rows",
3064
+ read: ({ insights }) => insights.findLatestEvents({
3065
+ userId: "user-even",
3066
+ limit: 3
3067
+ }),
3068
+ adapter: reads(0, 0, 1, 3),
3069
+ engine: {
3070
+ calls: 1,
3071
+ rows: 3
3072
+ },
3073
+ returned: (events) => events.length
3074
+ }),
3075
+ budget({
3076
+ api: "countEvents: buckets in the window × shards, all used",
3077
+ read: ({ insights }) => insights.countEvents({
3078
+ filter: bundleEvents,
3079
+ sinceMs: T0,
3080
+ beforeReceivedAtMs: T0 + 2 * HOUR
3081
+ }),
3082
+ adapter: reads(0, 0, 1, 11),
3083
+ engine: {
3084
+ calls: 1,
3085
+ rows: 2
3086
+ },
3087
+ check: (count) => expect(count).toBe(11)
3088
+ }),
3089
+ budget({
3090
+ api: "countLatestEvents: buckets in the window × shards, all used",
3091
+ read: ({ insights }) => insights.countLatestEvents({
3092
+ platform: "ios",
3093
+ channel: "production",
3094
+ sinceMs: T0 + HOUR
3095
+ }),
3096
+ adapter: reads(0, 0, 1, 20),
3097
+ engine: {
3098
+ calls: 1,
3099
+ rows: 5
3100
+ },
3101
+ check: (count) => expect(count).toBe(20)
3102
+ }),
3103
+ budget({
3104
+ api: "countLatestEvents by bundle: buckets in the window × shards, all used",
3105
+ read: ({ insights }) => insights.countLatestEvents({
3106
+ platform: "ios",
3107
+ channel: "production",
3108
+ sinceMs: T0,
3109
+ bundle: [{
3110
+ field: "to_bundle_id",
3111
+ value: "bundle-b",
3112
+ types: ["UPDATE_APPLIED"]
3113
+ }]
3114
+ }),
3115
+ adapter: reads(0, 0, 1, 24),
3116
+ engine: {
3117
+ calls: 1,
3118
+ rows: 6
3119
+ },
3120
+ check: (count) => expect(count).toBe(24)
3121
+ }),
3122
+ budget({
3123
+ api: "release activity: buckets × shards, all used",
3124
+ read: ({ insights }) => insights.getReleaseActivity({ releases: [{
3125
+ releaseId: "release-b",
3126
+ platform: "ios",
3127
+ channel: "production"
3128
+ }] }),
3129
+ adapter: reads(0, 0, 1, 8),
3130
+ engine: {
3131
+ calls: 1,
3132
+ rows: 1
3133
+ },
3134
+ check: ({ data }) => expect(data[0].metrics.launches).toBe(25)
3135
+ }),
3136
+ budget({
3137
+ api: "release activity over a window: buckets × shards, all used",
3138
+ read: ({ insights }) => insights.getReleaseActivity({
3139
+ scope: {
3140
+ platform: "ios",
3141
+ channel: "production"
3142
+ },
3143
+ timeRange: {
3144
+ start: T0,
3145
+ end: T0 + 3 * DAY
3146
+ }
3147
+ }),
3148
+ adapter: reads(0, 0, 2, 24),
3149
+ engine: {
3150
+ calls: 2,
3151
+ rows: 4
3152
+ },
3153
+ check: ({ data }) => expect(data[0].metrics.launches).toBe(25)
3154
+ }),
3155
+ budget({
3156
+ api: "app usage: nonzero distribution and usage-sketch rows in the window, all used",
3157
+ read: ({ insights }) => insights.getAppUsage({
3158
+ channel: "production",
3159
+ platform: "all",
3160
+ timeRange: {
3161
+ start: T0,
3162
+ end: T0 + 3 * DAY
3163
+ },
3164
+ intervalMs: DAY
3165
+ }),
3166
+ adapter: reads(0, 0, 3, 39),
3167
+ engine: {
3168
+ calls: 3,
3169
+ rows: 8
3170
+ },
3171
+ check: ({ versions }) => expect(versions).toEqual([{
3172
+ name: "1.0.0",
3173
+ installations: 24
3174
+ }])
3175
+ }),
3176
+ budget({
3177
+ api: "catalog compile: the scope's enabled releases, all used",
3178
+ read: ({ core }) => core.deploy([{
3179
+ bundle: bundleOf("107"),
3180
+ release: production
3181
+ }]),
3182
+ adapter: (copies) => reads(copies ? 6 : 4, copies ? 9 : 4, 3, 6),
3183
+ engine: {
3184
+ calls: 6,
3185
+ rows: 9
3186
+ },
3187
+ writes: 1
3188
+ }),
3189
+ budget({
3190
+ api: "channel insert: 1 write, after the name's read finds nothing",
3191
+ read: ({ core }) => core.ensureChannel("read-budgets"),
3192
+ adapter: reads(0, 0, 1, 0),
3193
+ engine: {
3194
+ calls: 1,
3195
+ rows: 0
3196
+ },
3197
+ writes: 1
3198
+ }),
3199
+ budget({
3200
+ api: "channel delete: 1 read and 1 write",
3201
+ read: ({ core, nightlyId }) => core.deleteChannel(nightlyId),
3202
+ adapter: reads(1, 1, 0, 0),
3203
+ engine: {
3204
+ calls: 1,
3205
+ rows: 1
3206
+ },
3207
+ writes: 1,
3208
+ check: (result) => expect(result).toEqual({ deleted: true })
3209
+ }),
3210
+ budget({
3211
+ api: "record an insights event: 2 dependent rounds of batch gets and 1 write",
3212
+ read: ({ insights }) => insights.recordEvent(eventOf(26, {
3213
+ install_id: "install-2",
3214
+ received_at_ms: T0 + 3 * HOUR
3215
+ })),
3216
+ adapter: reads(5, 19, 0, 0),
3217
+ engine: {
3218
+ calls: 2,
3219
+ rows: 1
3220
+ },
3221
+ writes: 1
3222
+ })
3223
+ ];
3224
+ /**
3225
+ * The read-budget suite (PRD S4) on one backend: core and the built-in
3226
+ * plugins in verify mode over the backend's adapter, native pages capped at
3227
+ * two rows. Every API in the read-budget list reads exactly its budget at the
3228
+ * adapter and at the engine; with `examined`, the database examines no more
3229
+ * rows than the adapter read, within the backend's native multipliers.
3230
+ */
3231
+ const setupReadBudgetTestSuite = (options) => {
3232
+ describe(`${options.name} read budgets`, () => {
3233
+ let created;
3234
+ let database;
3235
+ let seeded;
3236
+ let writes = 0;
3237
+ beforeAll(async () => {
3238
+ created = await options.createAdapter({
3239
+ tables: options.server.builtInSchema.tables,
3240
+ nativePageSize: NATIVE_PAGE_SIZE
3241
+ });
3242
+ const { adapter } = created;
3243
+ database = options.server.createMeasuredDatabase({
3244
+ ...adapter,
3245
+ write: (ops) => {
3246
+ writes += 1;
3247
+ return adapter.write(ops);
3248
+ }
3249
+ }, options.server.plugins, {
3250
+ now: () => T0 + 10 * DAY,
3251
+ storage
3252
+ });
3253
+ seeded = await seed(database, options.server);
3254
+ }, 3e5);
3255
+ afterAll(async () => {
3256
+ await created?.cleanup?.();
3257
+ });
3258
+ for (const budget of READ_BUDGETS) it(budget.api, async () => {
3259
+ const { examined, indexCopies = false } = created;
3260
+ writes = 0;
3261
+ examined?.reset();
3262
+ const measured = await database.measureReads(() => budget.read(seeded));
3263
+ const adapter = typeof budget.adapter === "function" ? budget.adapter(indexCopies) : budget.adapter;
3264
+ expect({
3265
+ adapter: measured.adapter,
3266
+ engine: measured.engine
3267
+ }).toEqual({
3268
+ adapter,
3269
+ engine: budget.engine
3270
+ });
3271
+ expect(writes, "writes").toBe(budget.writes ?? 0);
3272
+ if (budget.returned) expect(budget.returned(measured.result), "rows returned").toBe(measured.engine.rows);
3273
+ budget.check?.(measured.result);
3274
+ if (examined) {
3275
+ const { gets, keys, queries, rows } = measured.adapter;
3276
+ expect(await examined.total(), "rows examined").toBeLessThanOrEqual((examined.perRow ?? 1) * (keys + rows) + (examined.perRead ?? 0) * (gets + queries));
3277
+ }
3278
+ });
3279
+ });
3280
+ };
3281
+ //#endregion
3282
+ //#region src/sqlRowsExamined.ts
3283
+ /** Wraps executors so each read they run while counting is examined first. */
3284
+ const examineReads = (hooks) => {
3285
+ let counting = false;
3286
+ let examined = 0;
3287
+ return {
3288
+ /** The executor, examining each SELECT it runs outside a transaction. */
3289
+ wrap: (executor) => ({
3290
+ ...executor,
3291
+ async execute(statement) {
3292
+ if (counting && /^\s*SELECT\b/iu.test(statement.sql)) {
3293
+ const rows = await hooks.examine(statement);
3294
+ examined += rows;
3295
+ }
3296
+ return executor.execute(statement);
3297
+ }
3298
+ }),
3299
+ examined: {
3300
+ reset() {
3301
+ counting = true;
3302
+ examined = 0;
3303
+ hooks.onReset?.();
3304
+ },
3305
+ async total() {
3306
+ counting = false;
3307
+ return examined;
3308
+ }
3309
+ }
3310
+ };
3311
+ };
3312
+ /** Rows each table read of a plan examined: rows it produced, filtered out, or rechecked away, over all its loops. */
3313
+ const postgresTableReads = (node, reads = /* @__PURE__ */ new Map()) => {
3314
+ const relation = node["Relation Name"];
3315
+ if (relation !== void 0) {
3316
+ const table = node.Alias ?? relation;
3317
+ const rows = (node["Actual Rows"] ?? 0) + (node["Rows Removed by Filter"] ?? 0) + (node["Rows Removed by Index Recheck"] ?? 0);
3318
+ reads.set(table, (reads.get(table) ?? 0) + rows * (node["Actual Loops"] ?? 0));
3319
+ }
3320
+ for (const child of node.Plans ?? []) postgresTableReads(child, reads);
3321
+ return reads;
3322
+ };
3323
+ /**
3324
+ * Planner settings under which a test's small tables plan as production-sized
3325
+ * ones: no sequential scan, which only a small table affords, and an index
3326
+ * entry weighted like a row, so a plan reads no more entries than the read
3327
+ * needs, as a large index forces.
3328
+ */
3329
+ const POSTGRES_PLANNER = ["enable_seqscan = off", "cpu_index_tuple_cost = 1"];
3330
+ /**
3331
+ * Rows examined on PostgreSQL (PGlite or a server). Each SELECT a wrapped
3332
+ * executor runs is first run under `EXPLAIN (ANALYZE, FORMAT JSON)` on
3333
+ * `query`, one session with the planner settings above; each `reset` first
3334
+ * vacuums and analyzes, as autovacuum keeps a production database. A plan
3335
+ * then takes the index path a production-sized table takes, and a read no
3336
+ * index serves still scans. A statement counts the rows its busiest table
3337
+ * read examined, so an exact plan counts the rows it returns, a multi-valued
3338
+ * index's own table included.
3339
+ */
3340
+ const postgresRowsExamined = (query) => {
3341
+ let planner;
3342
+ let stale = true;
3343
+ return examineReads({
3344
+ onReset: () => {
3345
+ stale = true;
3346
+ },
3347
+ async examine({ sql, params }) {
3348
+ planner ??= (async () => {
3349
+ for (const setting of POSTGRES_PLANNER) await query(`SET ${setting}`, []);
3350
+ })();
3351
+ await planner;
3352
+ if (stale) {
3353
+ stale = false;
3354
+ await query("VACUUM ANALYZE", []);
3355
+ }
3356
+ const [row] = await query(`EXPLAIN (ANALYZE, FORMAT JSON) ${sql}`, params);
3357
+ const output = row?.["QUERY PLAN"];
3358
+ const [plan] = typeof output === "string" ? JSON.parse(output) : output;
3359
+ return Math.max(0, ...postgresTableReads(plan.Plan).values());
3360
+ }
3361
+ });
3362
+ };
3363
+ /**
3364
+ * An `EXPLAIN ANALYZE` iterator that reads a table: its table, and its actual
3365
+ * rows and loops. A primary-key read of constants runs while MySQL plans the
3366
+ * statement, and names no table.
3367
+ */
3368
+ const MYSQL_TABLE_READ = /-> (?:(?:Table scan|(?:Covering )?[Ii]ndex (?:scan|lookup|range scan|skip scan)|Single-row (?:covering )?index lookup|Multi-range index scan) on (\S+)|Rows fetched before execution).*\(actual time=[\d.e+-]+\.\.[\d.e+-]+ rows=([\d.e+-]+) loops=(\d+)\)/u;
3369
+ /**
3370
+ * Rows examined on MySQL. Each SELECT a wrapped executor runs is first run
3371
+ * under `EXPLAIN ANALYZE` on `query`, one session, after `ANALYZE TABLE` of
3372
+ * the tables it reads once per `reset`, and with multi-range reads always
3373
+ * on, so a secondary index's range is costed with sorted row reads, as a
3374
+ * production-sized table's is. A statement counts the rows its busiest table
3375
+ * read examined; a filter above a table read drops rows that read produced.
3376
+ */
3377
+ const mysqlRowsExamined = (query) => {
3378
+ let planner;
3379
+ const analyzed = /* @__PURE__ */ new Set();
3380
+ return examineReads({
3381
+ onReset: () => {
3382
+ analyzed.clear();
3383
+ },
3384
+ async examine({ sql, params }) {
3385
+ planner ??= query("SET SESSION optimizer_switch = 'mrr=on,mrr_cost_based=off'", []);
3386
+ await planner;
3387
+ const tables = [...sql.matchAll(/(?:FROM|JOIN) (`[^`]+`)/gu)].map(([, table]) => table).filter((table) => !analyzed.has(table));
3388
+ for (const table of tables) analyzed.add(table);
3389
+ if (tables.length > 0) await query(`ANALYZE TABLE ${tables.join(", ")}`, []);
3390
+ const [row] = await query(`EXPLAIN ANALYZE ${sql}`, params);
3391
+ const reads = /* @__PURE__ */ new Map();
3392
+ for (const line of String(Object.values(row ?? {})[0]).split("\n")) {
3393
+ const read = MYSQL_TABLE_READ.exec(line);
3394
+ if (read === null) continue;
3395
+ const [, table = "", rows, loops] = read;
3396
+ reads.set(table, (reads.get(table) ?? 0) + Number(rows) * Number(loops));
3397
+ }
3398
+ return Math.max(0, ...reads.values());
3399
+ }
3400
+ });
3401
+ };
3402
+ //#endregion
3403
+ //#region src/runContentionHarness.ts
3404
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3405
+ /** Delays every adapter call by `ms`, like a round trip to a remote database. */
3406
+ const withAdapterLatency = (adapter, ms) => ({
3407
+ ...adapter,
3408
+ get: async (table, keys) => {
3409
+ await sleep(ms);
3410
+ return adapter.get(table, keys);
3411
+ },
3412
+ query: async (table, request) => {
3413
+ await sleep(ms);
3414
+ return adapter.query(table, request);
3415
+ },
3416
+ write: async (ops) => {
3417
+ await sleep(ms);
3418
+ return adapter.write(ops);
3419
+ }
3420
+ });
3421
+ /**
3422
+ * Starts `transactions` runs at a steady arrival rate through a fixed pool and
3423
+ * reports how they ended. Retry counts come from the engine's `onRetry`.
3424
+ */
3425
+ const runContentionHarness = async (options) => {
3426
+ const started = Date.now();
3427
+ const errors = {};
3428
+ const waiting = [];
3429
+ let free = options.concurrency;
3430
+ let committed = 0;
3431
+ const acquire = () => free > 0 ? (free -= 1, Promise.resolve()) : new Promise((resolve) => waiting.push(resolve));
3432
+ const release = () => {
3433
+ const next = waiting.shift();
3434
+ if (next) next();
3435
+ else free += 1;
3436
+ };
3437
+ await Promise.all(Array.from({ length: options.transactions }, async (_, index) => {
3438
+ await sleep(index * 1e3 / options.ratePerSecond);
3439
+ await acquire();
3440
+ try {
3441
+ await options.run(index);
3442
+ committed += 1;
3443
+ } catch (error) {
3444
+ const name = error instanceof Error ? error.name : "unknown";
3445
+ errors[name] = (errors[name] ?? 0) + 1;
3446
+ } finally {
3447
+ release();
3448
+ }
3449
+ }));
3450
+ return {
3451
+ transactions: options.transactions,
3452
+ committed,
3453
+ errors,
3454
+ durationMs: Date.now() - started
3455
+ };
3456
+ };
3457
+ //#endregion
3458
+ //#region src/createPluginTestHarness.ts
3459
+ const builtInPlugin = Symbol.for("@hot-updater/server/built-in-plugin");
3460
+ /**
3461
+ * Runs one plugin the way `createHotUpdater` does, on a memory adapter in
3462
+ * verify mode, so tests can call its API and measure what each call reads.
3463
+ */
3464
+ const createPluginTestHarness = async (plugin, options) => {
3465
+ const adapter = options.adapter ?? createMemoryAdapter();
3466
+ const module = {
3467
+ id: plugin.id,
3468
+ schema: plugin.schema,
3469
+ ...builtInPlugin in plugin ? {} : { namespace: plugin.id }
3470
+ };
3471
+ const schema = options.engine.resolveSchema([module]);
3472
+ await adapter.migrations?.apply(schema.tables);
3473
+ const engine = options.engine.createDatabaseEngine({
3474
+ adapter,
3475
+ schema,
3476
+ verify: true
3477
+ });
3478
+ let clock = options.now ?? Date.now;
3479
+ const db = engine.database(module);
3480
+ const instance = plugin.init({
3481
+ db,
3482
+ core: {},
3483
+ now: () => clock()
3484
+ });
3485
+ return {
3486
+ api: instance.api,
3487
+ instance,
3488
+ /** The plugin's own database handle; cast it to its `HotUpdaterDatabase` type. */
3489
+ db,
3490
+ adapter,
3491
+ tables: schema.tables,
3492
+ /** Reads at both boundaries while `read` runs. */
3493
+ measureReads: (read) => engine.measureReads(read),
3494
+ setNow: (now) => {
3495
+ clock = now;
3496
+ }
3497
+ };
3498
+ };
3499
+ //#endregion
3500
+ //#region src/httpTestClient.ts
3501
+ /** Connect the same contract suite to any running HTTP server. */
3502
+ function createHttpTestClient(options) {
3503
+ const endpoint = (baseUrl, headers = {}) => (path, init) => {
3504
+ const requestHeaders = new Headers(headers);
3505
+ new Headers(init?.headers).forEach((value, key) => requestHeaders.set(key, value));
3506
+ return fetch(`${baseUrl.replace(/\/$/, "")}${path}`, {
3507
+ ...init,
3508
+ headers: requestHeaders
3509
+ });
3510
+ };
3511
+ return {
3512
+ client: endpoint(options.clientBaseUrl, options.clientHeaders),
3513
+ admin: endpoint(options.adminBaseUrl, options.adminHeaders)
3514
+ };
3515
+ }
3516
+ /** Workers run the same HTTP contract through their Web Request/Response boundary. */
3517
+ function createHandlerHttpTestClient(handlers) {
3518
+ const endpoint = (handler) => (path, init) => handler(new Request(`https://updates.example.com${path}`, init));
3519
+ return {
3520
+ client: endpoint(handlers.client),
3521
+ admin: endpoint(handlers.admin),
3522
+ close: async () => {}
3523
+ };
3524
+ }
3525
+ //#endregion
3526
+ //#region src/httpTestServer.ts
3527
+ /** Serve Web handlers over loopback TCP; contract tests use real fetch requests. */
3528
+ async function startHttpTestServer(handlers) {
3529
+ const server = createServer(async (incoming, outgoing) => {
3530
+ try {
3531
+ const url = new URL(incoming.url, "http://127.0.0.1");
3532
+ const target = url.pathname.startsWith("/admin/") ? "admin" : "client";
3533
+ url.pathname = url.pathname.slice(target.length + 1);
3534
+ const headers = new Headers();
3535
+ for (const [key, value] of Object.entries(incoming.headers)) if (value !== void 0) headers.set(key, Array.isArray(value) ? value.join(", ") : value);
3536
+ const chunks = [];
3537
+ for await (const chunk of incoming) chunks.push(Buffer.from(chunk));
3538
+ const response = await handlers[target](new Request(url, {
3539
+ method: incoming.method,
3540
+ headers,
3541
+ body: chunks.length === 0 ? void 0 : Buffer.concat(chunks)
3542
+ }));
3543
+ outgoing.writeHead(response.status, Object.fromEntries(response.headers));
3544
+ outgoing.end(Buffer.from(await response.arrayBuffer()));
3545
+ } catch (error) {
3546
+ outgoing.writeHead(500);
3547
+ outgoing.end(String(error));
3548
+ }
3549
+ });
3550
+ await new Promise((resolve, reject) => {
3551
+ server.once("error", reject);
3552
+ server.listen(0, "127.0.0.1", resolve);
3553
+ });
3554
+ const address = server.address();
3555
+ if (address === null || typeof address === "string") throw new Error("Missing HTTP test server address");
3556
+ const baseUrl = `http://127.0.0.1:${address.port}`;
3557
+ return {
3558
+ ...createHttpTestClient({
3559
+ clientBaseUrl: `${baseUrl}/client`,
3560
+ adminBaseUrl: `${baseUrl}/admin`
3561
+ }),
3562
+ close: () => new Promise((resolve, reject) => {
3563
+ server.close((error) => error ? reject(error) : resolve());
3564
+ server.closeIdleConnections();
3565
+ })
3566
+ };
3567
+ }
3568
+ //#endregion
3569
+ export { RELEASE_CATALOG_DOWNLOAD_HASH, RELEASE_CATALOG_MANIFEST_URIS, RELEASE_CATALOG_STORAGE_FIXTURES, conformanceCounters, conformanceItems, createAdminApiTestClient, createHandlerHttpTestClient, createHttpTestClient, createPluginTestHarness, createReleaseCatalogTestStorage, jsonRequest, mysqlRowsExamined, postgresRowsExamined, releaseCatalogArtifact, releaseCatalogDownloadUrl, runContentionHarness, setupBundleMethodsTestSuite, setupCoreAdminTestSuite, setupDatabaseAdapterConformanceSuite, setupDatabaseTestSuite, setupInsightsHttpTestSuite, setupInsightsModelTestSuite, setupReadBudgetTestSuite, setupReleaseCatalogTestSuite, setupSemverSatisfiesTestSuite, startHttpTestServer, toDeployBundle, withAdapterLatency };