@hot-updater/test-utils 1.0.0-rc.17 → 1.0.0-rc.21

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 CHANGED
@@ -1,6 +1,6 @@
1
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, addDistinct, countDistinct, createMemoryAdapter, findPhysicalIndex, indexOrderTuple, verifyAdapter } from "@hot-updater/plugin-core/internal";
2
+ import { NIL_UUID, createReleaseCatalogScopeKey, encodeChannelKey, selectDesiredRelease } from "@hot-updater/protocol";
3
+ import { DATABASE_VERSION_COLUMN, DatabaseAdapterContractError, SETTINGS_TABLE, addDistinct, aggregateBatchingTables, assertStorageOperations, bundleToPatchRows, bundleToRow, compareUtf8, coreSchema, countDistinct, createBundleStorageKey, createEngine, createMemoryAdapter, createStorageKeyBuilder, createStorageRootUriWithPath, createStorageUri, createStorageUriWithRelativePath, findPhysicalIndex, getBundleArchiveStorageUri, getContentAddressedAssetStoragePath, indexOrderColumns, meterReads, parseStorageDownloadPath, parseStorageUri, targetBaseCandidateKey, toolingTargetOf, verifyAdapter } from "@hot-updater/plugin-core";
4
4
  import { createServer } from "node:http";
5
5
  //#region src/adminApiTestClient.ts
6
6
  const jsonRequest = (method, body) => ({
@@ -80,9 +80,18 @@ const createAdminApiTestClient = (getClient) => {
80
80
  };
81
81
  //#endregion
82
82
  //#region src/databaseTestFixtures.ts
83
- const fixtureId = (suffix) => `00000000-0000-7000-8000-${suffix.padStart(12, "0")}`;
83
+ const fixtureId$1 = (suffix) => `00000000-0000-7000-8000-${suffix.padStart(12, "0")}`;
84
+ const channelFixtureSuffix = (name) => {
85
+ let hash = 0;
86
+ for (const character of name) hash = (hash * 31 + character.codePointAt(0)) % 0xe8d4a51000;
87
+ return String(hash);
88
+ };
89
+ const createChannelRowFixture = (name = "production") => ({
90
+ id: fixtureId$1(channelFixtureSuffix(name)),
91
+ name
92
+ });
84
93
  const createBundleRowFixture = (suffix, _channel = "production") => ({
85
- id: fixtureId(suffix),
94
+ id: fixtureId$1(suffix),
86
95
  platform: "ios",
87
96
  git_commit_hash: null,
88
97
  metadata: { app_version: suffix },
@@ -100,8 +109,29 @@ const createBundlePatchRowFixture = (suffix, bundleId, baseBundleId, orderIndex
100
109
  byte_size: 3000000002,
101
110
  order_index: orderIndex
102
111
  });
112
+ const createReleaseRowFixture = (suffix, bundle, channel) => ({
113
+ id: fixtureId$1(`${Number(suffix) + 5e3}`),
114
+ scope_key: `v1:app-version:ios:${channel.name}`,
115
+ channel_id: channel.id,
116
+ platform: bundle.platform,
117
+ kind: "BUNDLE",
118
+ bundle_id: bundle.id,
119
+ strategy: "APP_VERSION",
120
+ target_app_version: "1.0.0",
121
+ fingerprint_hash: null,
122
+ enabled: true,
123
+ should_force_update: false,
124
+ message: `release-${suffix}`,
125
+ rollout_cohort_count: 1e3,
126
+ target_cohorts: [],
127
+ operation: "DEPLOY",
128
+ source_release_id: null,
129
+ revision: 1,
130
+ created_at_ms: Number(suffix),
131
+ updated_at_ms: Number(suffix)
132
+ });
103
133
  const createBundleFixture = (suffix, _channel = "production") => ({
104
- id: fixtureId(suffix),
134
+ id: fixtureId$1(suffix),
105
135
  platform: "ios",
106
136
  gitCommitHash: null,
107
137
  metadata: { app_version: suffix },
@@ -941,18 +971,32 @@ const setupReleaseCatalogTestSuite = (options) => {
941
971
  });
942
972
  expect((await adminJson(catalogRowPath())).data).toMatchObject({ generation: catalog.generation + 1 });
943
973
  });
944
- it("hard deletes a Release, rebuilds its Catalog, and retains Bundle bytes", async () => {
974
+ const hardDelete = async (release) => {
975
+ await adminJson(`/releases/${release.id}?confirm=${release.id}&expectedRevision=${release.revision}`, jsonRequest("DELETE"));
976
+ api.forget(release.id);
977
+ };
978
+ it("hard deletes a Release, rebuilds its Catalog, and deletes the artifact no other Release uses", async () => {
945
979
  const current = await publish("741", { enabled: false });
946
- await adminJson(`/releases/${current.release.id}?confirm=${current.release.id}&expectedRevision=1`, jsonRequest("DELETE"));
947
- api.forget(current.release.id);
980
+ await hardDelete(current.release);
948
981
  expect((await admin(`/releases/${current.release.id}`)).status).toBe(404);
949
982
  expect(await readCatalog()).toMatchObject({
950
983
  releases: [],
951
984
  rollbackReleases: []
952
985
  });
953
- const artifact = await request(`/artifacts/v1/${current.bundle.id}/from/${NIL_UUID$1}`);
986
+ expect((await request(`/artifacts/v1/${current.bundle.id}/from/${NIL_UUID$1}`)).status).toBe(404);
987
+ });
988
+ it("keeps serving an artifact while another Release uses it", async () => {
989
+ const first = await publish("742", { enabled: false });
990
+ const second = await publish("742", {
991
+ bundle_id: first.bundle.id,
992
+ enabled: false
993
+ }, scope("other"));
994
+ await hardDelete(first.release);
995
+ const artifact = await request(`/artifacts/v1/${first.bundle.id}/from/${NIL_UUID$1}`);
954
996
  expect(artifact.status).toBe(200);
955
- expect(await artifact.json()).toMatchObject({ manifestUrl: releaseCatalogDownloadUrl(current.bundle.manifest_storage_uri) });
997
+ expect(await artifact.json()).toMatchObject({ manifestUrl: releaseCatalogDownloadUrl(first.bundle.manifest_storage_uri) });
998
+ await hardDelete(second.release);
999
+ expect((await request(`/artifacts/v1/${first.bundle.id}/from/${NIL_UUID$1}`)).status).toBe(404);
956
1000
  });
957
1001
  it("serves enabled Releases newest first and resolves artifacts by Bundle identity", async () => {
958
1002
  const first = await publish("101");
@@ -1915,7 +1959,7 @@ const setupDatabaseAdapterConformanceSuite = (options) => {
1915
1959
  for (;;) {
1916
1960
  const last = read.at(-1);
1917
1961
  const bound = last === void 0 ? {} : { [order === "asc" ? "lower" : "upper"]: {
1918
- values: indexOrderTuple(conformanceItems, findPhysicalIndex(conformanceItems, "byGroup"), last),
1962
+ values: indexOrderColumns(conformanceItems, findPhysicalIndex(conformanceItems, "byGroup")).map((column) => last[column]),
1919
1963
  inclusive: false
1920
1964
  } };
1921
1965
  const page = await query(adapter, {
@@ -2183,6 +2227,568 @@ const setupDatabaseAdapterConformanceSuite = (options) => {
2183
2227
  });
2184
2228
  };
2185
2229
  //#endregion
2230
+ //#region src/storageAdapterTestCases.ts
2231
+ const BUNDLE_ID = "0199a0c3-6f6e-7c3a-9a3e-1b2c3d4e5f60";
2232
+ /** `sha256/9f/<hash>.png`: where deploy keeps a shared asset below `assets/`. */
2233
+ const ASSET_PATH = getContentAddressedAssetStoragePath({
2234
+ assetPath: "assets/logo.png",
2235
+ fileHash: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
2236
+ });
2237
+ const MANIFEST_KEY = createBundleStorageKey(BUNDLE_ID, "manifest.json");
2238
+ const ARCHIVE_KEY = createBundleStorageKey(BUNDLE_ID, "bundle.tar.br");
2239
+ const ASSET_KEY = `assets/${ASSET_PATH}`;
2240
+ const UNICODE_FOLDER = "unicode/한글 폴더";
2241
+ const UNICODE_KEY = `${UNICODE_FOLDER}/ünïcödé 😀.txt`;
2242
+ /** Keys whose URIs encode spaces, `#`, `%`, `@`, `+`, and Unicode. */
2243
+ const SPECIAL_KEYS = [
2244
+ "with space/file name.txt",
2245
+ "hash#1/100%.txt",
2246
+ "symbols/logo@2x+dark.png",
2247
+ UNICODE_KEY
2248
+ ];
2249
+ /** How far a listed modification time may be from its put. */
2250
+ const CLOCK_SKEW_MS = 9e5;
2251
+ const encode = (text) => new TextEncoder().encode(text);
2252
+ const messageOf = (error) => error instanceof Error ? error.message : String(error);
2253
+ /** Deterministic bytes, so a reordered or dropped chunk shows up. */
2254
+ const patternBytes = (length, seed) => {
2255
+ const bytes = new Uint8Array(length);
2256
+ let state = seed;
2257
+ for (let index = 0; index < length; index += 1) {
2258
+ state ^= state << 13;
2259
+ state ^= state >>> 17;
2260
+ state ^= state << 5;
2261
+ bytes[index] = state;
2262
+ }
2263
+ return bytes;
2264
+ };
2265
+ /**
2266
+ * A one-shot body that hands out a chunk only when its reader asks for one,
2267
+ * so `read()` tells whether the adapter read all of it.
2268
+ */
2269
+ const bodyOf = (bytes, chunkSize = 16384) => {
2270
+ let offset = 0;
2271
+ return {
2272
+ stream: new ReadableStream({ pull(controller) {
2273
+ if (offset >= bytes.byteLength) {
2274
+ controller.close();
2275
+ return;
2276
+ }
2277
+ const end = Math.min(offset + chunkSize, bytes.byteLength);
2278
+ controller.enqueue(bytes.slice(offset, end));
2279
+ offset = end;
2280
+ } }, { highWaterMark: 0 }),
2281
+ read: () => offset === bytes.byteLength
2282
+ };
2283
+ };
2284
+ /** A body that fails after its first chunk, like a file removed mid-upload. */
2285
+ const failingBody = (chunk) => {
2286
+ let sent = false;
2287
+ return new ReadableStream({ pull(controller) {
2288
+ if (sent) {
2289
+ controller.error(/* @__PURE__ */ new Error("The upload source failed."));
2290
+ return;
2291
+ }
2292
+ sent = true;
2293
+ controller.enqueue(chunk);
2294
+ } }, { highWaterMark: 0 });
2295
+ };
2296
+ /** The bucket and base path of a URI `put` returned for `key`. */
2297
+ const locate = (storage, storageUri, key, basePath) => {
2298
+ let parsed;
2299
+ try {
2300
+ parsed = parseStorageUri(storageUri, storage.protocol);
2301
+ } catch (error) {
2302
+ throw new Error(`put("${key}") returned "${storageUri}", which is not a canonical ${storage.protocol} storage URI: ${messageOf(error)}`);
2303
+ }
2304
+ if (basePath !== void 0) {
2305
+ const expected = createStorageKeyBuilder(basePath)(key);
2306
+ expect(parsed.key, `put("${key}") returned "${storageUri}"; below base path "${basePath}" its key is "${expected}"`).toBe(expected);
2307
+ }
2308
+ if (parsed.key === key) return {
2309
+ bucket: parsed.bucket,
2310
+ prefix: ""
2311
+ };
2312
+ if (parsed.key.endsWith(`/${key}`)) return {
2313
+ bucket: parsed.bucket,
2314
+ prefix: parsed.key.slice(0, -key.length - 1)
2315
+ };
2316
+ throw new Error(`put("${key}") returned "${storageUri}", whose key does not end with "${key}". Deploy derives the URIs of other objects from it.`);
2317
+ };
2318
+ /**
2319
+ * Puts objects the way deploy does and checks each URI `put` returns: a
2320
+ * canonical URI of the key, in the same bucket and below the same base path
2321
+ * as every other object.
2322
+ */
2323
+ const objectsOf = (storage, basePath) => {
2324
+ let root;
2325
+ return {
2326
+ async put(key, bytes, { knownLength = true, chunkSize } = {}) {
2327
+ const body = bodyOf(bytes, chunkSize);
2328
+ const { storageUri } = await storage.put({
2329
+ key,
2330
+ body: body.stream,
2331
+ ...knownLength ? { contentLength: bytes.byteLength } : {},
2332
+ contentType: "application/octet-stream"
2333
+ });
2334
+ expect(body.read(), `put("${key}") resolved before it read the whole body`).toBe(true);
2335
+ const location = locate(storage, storageUri, key, basePath);
2336
+ root ??= location;
2337
+ expect(location, `put("${key}") returned "${storageUri}", outside the bucket or base path of the objects before it`).toEqual(root);
2338
+ return storageUri;
2339
+ },
2340
+ /** The URI `put` returns for `key`, which the case has not put. */
2341
+ uriOf(key) {
2342
+ if (root === void 0) throw new Error("Put an object before deriving another one's URI.");
2343
+ return createStorageUri({
2344
+ protocol: storage.protocol,
2345
+ bucket: root.bucket,
2346
+ key: createStorageKeyBuilder(root.prefix)(key)
2347
+ });
2348
+ }
2349
+ };
2350
+ };
2351
+ /** The object's bytes through `get`, or null when `get` finds none. */
2352
+ const readObject = async (storage, storageUri) => {
2353
+ const { response } = await storage.get({ storageUri });
2354
+ if (response === null) return null;
2355
+ expect(response, `get("${storageUri}") returned no Web Response`).toBeInstanceOf(Response);
2356
+ let bytes;
2357
+ try {
2358
+ bytes = new Uint8Array(await response.arrayBuffer());
2359
+ } catch (error) {
2360
+ throw new Error(`get("${storageUri}") returned a response whose body could not be read: ${messageOf(error)}`, { cause: error });
2361
+ }
2362
+ const contentLength = response.headers.get("content-length");
2363
+ if (contentLength !== null) expect(Number(contentLength), `get("${storageUri}") sent a content-length other than the object's size`).toBe(bytes.byteLength);
2364
+ return bytes;
2365
+ };
2366
+ const expectBytes = (actual, expected, label) => {
2367
+ expect(actual, `${label} found no object`).not.toBeNull();
2368
+ expect(actual.byteLength, `${label} returned a body of the wrong size`).toBe(expected.byteLength);
2369
+ expect(actual.findIndex((byte, index) => byte !== expected[index]), `${label} returned other bytes; first difference at index`).toBe(-1);
2370
+ };
2371
+ const expectObject = async (storage, storageUri, bytes) => {
2372
+ expectBytes(await readObject(storage, storageUri), bytes, `get("${storageUri}")`);
2373
+ if (storage.exists) expect(await storage.exists({ storageUri }), `exists("${storageUri}")`).toEqual({ exists: true });
2374
+ };
2375
+ const expectMissing = async (storage, storageUri) => {
2376
+ expect((await storage.get({ storageUri })).response, `get("${storageUri}") of a missing object`).toBeNull();
2377
+ if (storage.exists) expect((await storage.exists({ storageUri })).exists, `exists("${storageUri}") of a missing object`).toBe(false);
2378
+ };
2379
+ /** Expects a rejected promise: not a result, and not a synchronous throw. */
2380
+ const expectRejection = async (label, operation) => {
2381
+ let pending;
2382
+ try {
2383
+ pending = operation();
2384
+ } catch (error) {
2385
+ throw new Error(`${label} threw instead of returning a rejected promise: ${messageOf(error)}`);
2386
+ }
2387
+ const outcome = await Promise.resolve(pending).then(() => "resolved", () => "rejected");
2388
+ expect(outcome, label).toBe("rejected");
2389
+ };
2390
+ /** Every operation the adapter has that takes a storage URI rejects it. */
2391
+ const expectRejectedUri = async (storage, storageUri, owner) => {
2392
+ const calls = {
2393
+ get: storage.get && (() => storage.get({ storageUri })),
2394
+ exists: storage.exists && (() => storage.exists({ storageUri })),
2395
+ delete: storage.delete && (() => storage.delete({ storageUri })),
2396
+ getDownloadUrl: storage.getDownloadUrl && (() => storage.getDownloadUrl({ storageUri }))
2397
+ };
2398
+ for (const [operation, call] of Object.entries(calls)) {
2399
+ if (call === void 0) continue;
2400
+ await expectRejection(`${operation}("${storageUri}") of ${owner}`, call);
2401
+ }
2402
+ };
2403
+ const byKey = (left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0;
2404
+ const expectListing = (label, listed, expected, putWindow) => {
2405
+ expect(listed.map(({ key, storageUri, size }) => ({
2406
+ key,
2407
+ storageUri,
2408
+ size
2409
+ })).sort(byKey), label).toEqual([...expected].sort(byKey));
2410
+ for (const { key, lastModifiedAt } of listed) {
2411
+ if (lastModifiedAt === void 0) continue;
2412
+ expect(lastModifiedAt, `${label}: lastModifiedAt of "${key}"`).toBeInstanceOf(Date);
2413
+ const time = lastModifiedAt.getTime();
2414
+ expect(time >= putWindow.from - CLOCK_SKEW_MS && time <= putWindow.to + CLOCK_SKEW_MS, `${label}: "${key}" has lastModifiedAt ${String(lastModifiedAt)}, not the time it was put`).toBe(true);
2415
+ }
2416
+ };
2417
+ const OPERATIONS = [
2418
+ "put",
2419
+ "get",
2420
+ "getDownloadUrl",
2421
+ "exists",
2422
+ "delete",
2423
+ "listObjects",
2424
+ "deleteObjects"
2425
+ ];
2426
+ const describeArgument = (value) => {
2427
+ if (Array.isArray(value)) return `[${value.map((item) => JSON.stringify(item)).join(", ")}]`;
2428
+ if (typeof value === "object" && value !== null) {
2429
+ const { key, storageUri } = value;
2430
+ return JSON.stringify(storageUri ?? key);
2431
+ }
2432
+ return value === void 0 ? "" : JSON.stringify(value);
2433
+ };
2434
+ /**
2435
+ * The adapter with each rejection naming its call, such as
2436
+ * `delete("s3://bucket/key") rejected: ...`. A synchronous throw stays one.
2437
+ */
2438
+ const namingCalls = (storage) => {
2439
+ const named = {
2440
+ name: storage.name,
2441
+ protocol: storage.protocol
2442
+ };
2443
+ for (const operation of OPERATIONS) {
2444
+ const call = storage[operation];
2445
+ if (typeof call !== "function") continue;
2446
+ named[operation] = (...args) => Promise.resolve(call.apply(storage, args)).catch((error) => {
2447
+ throw new Error(`${operation}(${args.map(describeArgument).join(", ")}) rejected: ${messageOf(error)}`, { cause: error });
2448
+ });
2449
+ }
2450
+ return named;
2451
+ };
2452
+ const storageCase = (title, operations, run) => ({
2453
+ title,
2454
+ operations,
2455
+ run: async ({ storage, basePath }) => {
2456
+ assertStorageOperations(storage, [
2457
+ "put",
2458
+ "get",
2459
+ ...operations
2460
+ ]);
2461
+ await run({
2462
+ storage: namingCalls(storage),
2463
+ basePath
2464
+ });
2465
+ }
2466
+ });
2467
+ /**
2468
+ * The storage adapter contract, from what its consumers rely on: deploy and
2469
+ * patch (`put`, `get`, `exists`, `delete`), the Console, the server's update
2470
+ * responses and signed downloads (`get`, `getDownloadUrl`), and
2471
+ * `storage prune` (`listObjects`, `deleteObjects`).
2472
+ */
2473
+ const storageAdapterTestCases = [
2474
+ storageCase("names the adapter and its URI protocol", [], async ({ storage }) => {
2475
+ expect(typeof storage.name, "name").toBe("string");
2476
+ expect(storage.name.length, "name length").toBeGreaterThan(0);
2477
+ expect(storage.protocol, "protocol").toMatch(/^[a-z][a-z\d+.-]*$/);
2478
+ }),
2479
+ storageCase("stores a streamed body and reads it back", [], async ({ storage, basePath }) => {
2480
+ const objects = objectsOf(storage, basePath);
2481
+ const bytes = patternBytes(100003, 1);
2482
+ const storageUri = await objects.put("round-trip/known-length.bin", bytes, { chunkSize: 4096 });
2483
+ expectBytes(await readObject(storage, storageUri), bytes, `get("${storageUri}")`);
2484
+ expectBytes(await readObject(storage, storageUri), bytes, `a second get("${storageUri}")`);
2485
+ }),
2486
+ storageCase("stores a streamed body of unknown length", [], async ({ storage, basePath }) => {
2487
+ const objects = objectsOf(storage, basePath);
2488
+ const bytes = patternBytes(20011, 2);
2489
+ const storageUri = await objects.put("round-trip/unknown-length.bin", bytes, {
2490
+ chunkSize: 1e3,
2491
+ knownLength: false
2492
+ });
2493
+ expectBytes(await readObject(storage, storageUri), bytes, `get("${storageUri}")`);
2494
+ }),
2495
+ storageCase("stores an empty object", [], async ({ storage, basePath }) => {
2496
+ const storageUri = await objectsOf(storage, basePath).put("round-trip/empty.txt", /* @__PURE__ */ new Uint8Array());
2497
+ expectBytes(await readObject(storage, storageUri), /* @__PURE__ */ new Uint8Array(), `get("${storageUri}")`);
2498
+ }),
2499
+ storageCase("keeps concurrent puts of different keys apart", [], async ({ storage, basePath }) => {
2500
+ const objects = objectsOf(storage, basePath);
2501
+ const entries = Array.from({ length: 8 }, (_, index) => ({
2502
+ key: `concurrent/${index}.bin`,
2503
+ bytes: patternBytes(4e3 + index, 10 + index)
2504
+ }));
2505
+ const storageUris = await Promise.all(entries.map(({ key, bytes }) => objects.put(key, bytes, { chunkSize: 512 })));
2506
+ for (const [index, { bytes }] of entries.entries()) expectBytes(await readObject(storage, storageUris[index]), bytes, `get("${storageUris[index]}")`);
2507
+ }),
2508
+ storageCase("returns the canonical URI of each key below the base path", [], async ({ storage, basePath }) => {
2509
+ const objects = objectsOf(storage, basePath);
2510
+ for (const key of [
2511
+ MANIFEST_KEY,
2512
+ ASSET_KEY,
2513
+ "nested/one/two/three/deep.txt",
2514
+ ...SPECIAL_KEYS
2515
+ ]) {
2516
+ const bytes = encode(`object at ${key}`);
2517
+ const storageUri = await objects.put(key, bytes);
2518
+ expectBytes(await readObject(storage, storageUri), bytes, `get("${storageUri}")`);
2519
+ }
2520
+ }),
2521
+ storageCase("reads objects at the URIs deploy derives from a manifest's URI", [], async ({ storage, basePath }) => {
2522
+ const objects = objectsOf(storage, basePath);
2523
+ const archive = patternBytes(3001, 3);
2524
+ const asset = patternBytes(1234, 4);
2525
+ const manifestUri = await objects.put(MANIFEST_KEY, encode(JSON.stringify({
2526
+ bundleId: BUNDLE_ID,
2527
+ assets: {}
2528
+ })));
2529
+ const archiveUri = await objects.put(ARCHIVE_KEY, archive);
2530
+ const assetUri = await objects.put(ASSET_KEY, asset);
2531
+ const derivedArchiveUri = getBundleArchiveStorageUri({
2532
+ manifestStorageUri: manifestUri,
2533
+ bundleId: BUNDLE_ID
2534
+ });
2535
+ const derivedAssetUri = createStorageUriWithRelativePath({
2536
+ baseStorageUri: createStorageRootUriWithPath(manifestUri, BUNDLE_ID, "assets"),
2537
+ relativePath: ASSET_PATH
2538
+ });
2539
+ expect(derivedArchiveUri, "the archive URI derived from the manifest's").toBe(archiveUri);
2540
+ expect(derivedAssetUri, "the asset URI derived from the manifest's").toBe(assetUri);
2541
+ await expectObject(storage, derivedArchiveUri, archive);
2542
+ await expectObject(storage, derivedAssetUri, asset);
2543
+ }),
2544
+ storageCase("replaces an object put again under the same key", [], async ({ storage, basePath }) => {
2545
+ const objects = objectsOf(storage, basePath);
2546
+ const key = "overwrite/object.txt";
2547
+ const storageUri = await objects.put(key, encode("first"));
2548
+ expect(await objects.put(key, encode("second")), "a second put").toBe(storageUri);
2549
+ expectBytes(await readObject(storage, storageUri), encode("second"), `get("${storageUri}")`);
2550
+ expect(await Promise.all([objects.put(key, encode("shared")), objects.put(key, encode("shared"))]), "concurrent puts of one key").toEqual([storageUri, storageUri]);
2551
+ expectBytes(await readObject(storage, storageUri), encode("shared"), `get("${storageUri}")`);
2552
+ }),
2553
+ storageCase("rejects a put whose body fails and keeps no object", [], async ({ storage, basePath }) => {
2554
+ const objects = objectsOf(storage, basePath);
2555
+ await objects.put("failed-put/other.txt", encode("other"));
2556
+ const key = "failed-put/object.bin";
2557
+ await expectRejection(`put("${key}") of a body that fails`, () => storage.put({
2558
+ key,
2559
+ body: failingBody(patternBytes(4e3, 5)),
2560
+ contentLength: 1e4,
2561
+ contentType: "application/octet-stream"
2562
+ }));
2563
+ await expectMissing(storage, objects.uriOf(key));
2564
+ }),
2565
+ storageCase("returns a null response for a missing object", [], async ({ storage, basePath }) => {
2566
+ const objects = objectsOf(storage, basePath);
2567
+ await objects.put("missing/object.txt", encode("present"));
2568
+ for (const key of [
2569
+ "missing/absent.txt",
2570
+ "missing/object.tx",
2571
+ "missing/object.txt.bak",
2572
+ "missing"
2573
+ ]) {
2574
+ const storageUri = objects.uriOf(key);
2575
+ expect(await storage.get({ storageUri }), `get("${storageUri}") of a missing object`).toEqual({ response: null });
2576
+ }
2577
+ }),
2578
+ storageCase("tells whether an object exists", ["exists"], async ({ storage, basePath }) => {
2579
+ const objects = objectsOf(storage, basePath);
2580
+ const storageUri = await objects.put("exists/object.txt", encode("present"));
2581
+ expect(await storage.exists({ storageUri }), `exists("${storageUri}")`).toEqual({ exists: true });
2582
+ for (const key of [
2583
+ "exists/absent.txt",
2584
+ "exists/object.tx",
2585
+ "exists"
2586
+ ]) {
2587
+ const missingUri = objects.uriOf(key);
2588
+ expect(await storage.exists({ storageUri: missingUri }), `exists("${missingUri}")`).toEqual({ exists: false });
2589
+ }
2590
+ }),
2591
+ storageCase("deletes exactly one object, and deleting it again succeeds", ["delete"], async ({ storage, basePath }) => {
2592
+ const objects = objectsOf(storage, basePath);
2593
+ const storageUri = await objects.put("delete/object.txt", encode("gone"));
2594
+ const sibling = encode("kept");
2595
+ const siblingUri = await objects.put("delete/object.txt.bak", sibling);
2596
+ const child = encode("kept child");
2597
+ const childUri = await objects.put("delete/object/child.txt", child);
2598
+ expect(await storage.delete({ storageUri }), `delete("${storageUri}")`).toEqual({ deleted: true });
2599
+ await expectMissing(storage, storageUri);
2600
+ expect(await storage.delete({ storageUri }), `a second delete("${storageUri}")`).toEqual({ deleted: true });
2601
+ for (const key of ["delete/never-stored.txt", "delete/object"]) {
2602
+ const missingUri = objects.uriOf(key);
2603
+ expect(await storage.delete({ storageUri: missingUri }), `delete("${missingUri}")`).toEqual({ deleted: true });
2604
+ }
2605
+ await expectObject(storage, siblingUri, sibling);
2606
+ await expectObject(storage, childUri, child);
2607
+ }),
2608
+ storageCase("rejects the URI of an object in another bucket", [], async ({ storage, basePath }) => {
2609
+ const objects = objectsOf(storage, basePath);
2610
+ const bytes = encode("kept");
2611
+ const storageUri = await objects.put("foreign/object.txt", bytes);
2612
+ const { bucket, key } = parseStorageUri(storageUri, storage.protocol);
2613
+ await expectRejectedUri(storage, createStorageUri({
2614
+ protocol: storage.protocol,
2615
+ bucket: `${bucket}-other`,
2616
+ key
2617
+ }), "another bucket");
2618
+ await expectObject(storage, storageUri, bytes);
2619
+ }),
2620
+ storageCase("rejects the URI of another protocol", [], async ({ storage, basePath }) => {
2621
+ const objects = objectsOf(storage, basePath);
2622
+ const bytes = encode("kept");
2623
+ const storageUri = await objects.put("foreign/object.txt", bytes);
2624
+ const { bucket, key } = parseStorageUri(storageUri, storage.protocol);
2625
+ await expectRejectedUri(storage, createStorageUri({
2626
+ protocol: `not-${storage.protocol}`,
2627
+ bucket,
2628
+ key
2629
+ }), "another protocol");
2630
+ await expectObject(storage, storageUri, bytes);
2631
+ }),
2632
+ storageCase("resolves a download URL for a stored object", ["getDownloadUrl"], async ({ storage, basePath }) => {
2633
+ const objects = objectsOf(storage, basePath);
2634
+ for (const key of ["download/object.txt", ...SPECIAL_KEYS]) {
2635
+ const storageUri = await objects.put(key, encode(key));
2636
+ const { url } = await storage.getDownloadUrl({ storageUri });
2637
+ const label = `getDownloadUrl("${storageUri}") returned "${url}"`;
2638
+ expect(typeof url, label).toBe("string");
2639
+ if (/^[a-z][a-z\d+.-]*:/i.test(url)) {
2640
+ expect(["http:", "https:"], `${label}; a URL must use http(s)`).toContain(new URL(url).protocol);
2641
+ continue;
2642
+ }
2643
+ expect(parseStorageDownloadPath(url)?.storageUri, `${label}; a path must be the /storage/ path of the same URI`).toBe(storageUri);
2644
+ expect((await storage.getDownloadUrl({ storageUri })).url, `${label}, then another path`).toBe(url);
2645
+ }
2646
+ }),
2647
+ storageCase("lists every object with its key relative to the base path", ["listObjects"], async ({ storage, basePath }) => {
2648
+ const objects = objectsOf(storage, basePath);
2649
+ const entries = [
2650
+ [MANIFEST_KEY, encode("{}")],
2651
+ [ARCHIVE_KEY, patternBytes(3001, 6)],
2652
+ [ASSET_KEY, patternBytes(1234, 7)],
2653
+ ["bundles-other/object.txt", encode("next to bundles/")],
2654
+ ...SPECIAL_KEYS.map((key) => [key, encode(key)])
2655
+ ];
2656
+ const from = Date.now();
2657
+ const stored = [];
2658
+ for (const [key, bytes] of entries) stored.push({
2659
+ key,
2660
+ storageUri: await objects.put(key, bytes),
2661
+ size: bytes.byteLength
2662
+ });
2663
+ const putWindow = {
2664
+ from,
2665
+ to: Date.now()
2666
+ };
2667
+ const below = (folder) => stored.filter(({ key }) => key.startsWith(`${folder}/`));
2668
+ expectListing("listObjects()", await storage.listObjects(), stored, putWindow);
2669
+ expectListing("listObjects(\"bundles\")", await storage.listObjects("bundles"), below("bundles"), putWindow);
2670
+ expectListing(`listObjects("${UNICODE_FOLDER}")`, await storage.listObjects(UNICODE_FOLDER), below(UNICODE_FOLDER), putWindow);
2671
+ }),
2672
+ storageCase("deletes exactly the keys it receives", ["deleteObjects"], async ({ storage, basePath }) => {
2673
+ const objects = objectsOf(storage, basePath);
2674
+ const put = async (key) => {
2675
+ const bytes = encode(key);
2676
+ return {
2677
+ key,
2678
+ bytes,
2679
+ storageUri: await objects.put(key, bytes)
2680
+ };
2681
+ };
2682
+ const removed = [await put("delete-objects/object.txt"), await put(`delete-objects/${UNICODE_KEY}`)];
2683
+ const kept = [await put("delete-objects/object.txt.bak"), await put("delete-objects/folder/child.txt")];
2684
+ await storage.deleteObjects([]);
2685
+ for (const object of [...removed, ...kept]) await expectObject(storage, object.storageUri, object.bytes);
2686
+ await storage.deleteObjects([
2687
+ ...removed.map(({ key }) => key),
2688
+ "delete-objects/never-stored.txt",
2689
+ "delete-objects/folder"
2690
+ ]);
2691
+ for (const object of removed) await expectMissing(storage, object.storageUri);
2692
+ for (const object of kept) await expectObject(storage, object.storageUri, object.bytes);
2693
+ if (storage.listObjects) expect((await storage.listObjects()).map(({ key }) => key).sort(), "listObjects() after deleteObjects").toEqual(kept.map(({ key }) => key).sort());
2694
+ })
2695
+ ];
2696
+ //#endregion
2697
+ //#region src/setupStorageAdapterTestSuite.ts
2698
+ /**
2699
+ * The storage adapter contract that deploy, patch, the Console, the server,
2700
+ * and `storage prune` rely on. Every official storage adapter runs it.
2701
+ */
2702
+ const setupStorageAdapterTestSuite = (options) => {
2703
+ const declared = options.operations;
2704
+ const required = [.../* @__PURE__ */ new Set([
2705
+ "put",
2706
+ "get",
2707
+ ...declared ?? []
2708
+ ])];
2709
+ describe(`${options.name} storage adapter`, () => {
2710
+ it(`implements ${required.join(", ")}`, async () => {
2711
+ const { storage, cleanup } = await options.createStorage();
2712
+ try {
2713
+ assertStorageOperations(storage, required);
2714
+ } finally {
2715
+ await cleanup?.();
2716
+ }
2717
+ });
2718
+ for (const testCase of storageAdapterTestCases) {
2719
+ const undeclared = declared === void 0 ? [] : testCase.operations.filter((operation) => !declared.includes(operation));
2720
+ it.skipIf(undeclared.length > 0)(testCase.title, async (context) => {
2721
+ const { storage, basePath, cleanup } = await options.createStorage();
2722
+ try {
2723
+ const missing = testCase.operations.filter((operation) => typeof storage[operation] !== "function");
2724
+ if (declared === void 0 && missing.length > 0) context.skip(`${storage.name} lacks ${missing.join(", ")}`);
2725
+ await testCase.run({
2726
+ storage,
2727
+ basePath
2728
+ });
2729
+ } finally {
2730
+ await cleanup?.();
2731
+ }
2732
+ });
2733
+ }
2734
+ });
2735
+ };
2736
+ //#endregion
2737
+ //#region src/createMeasuredDatabase.ts
2738
+ const storageOf = (storage) => ({
2739
+ name: "measuredStorage",
2740
+ protocol: "storage",
2741
+ get: async ({ storageUri }) => {
2742
+ const text = await storage.readStorageText?.(storageUri) ?? null;
2743
+ return { response: text === null ? null : new Response(text) };
2744
+ },
2745
+ getDownloadUrl: async ({ storageUri }) => ({ url: await storage.resolveFileUrl(storageUri) })
2746
+ });
2747
+ /**
2748
+ * Core and the plugins over `adapter` as `createHotUpdater` runs them: what
2749
+ * read-budget suites measure. The database is `meterReads` over
2750
+ * `verifyAdapter(adapter)`, so every adapter call is checked against the
2751
+ * contract and `measureReads` reports each call's reads; the adapter runs
2752
+ * as given, without the schema fence. It loads `@hot-updater/server`, an
2753
+ * optional peer, when called.
2754
+ */
2755
+ const createMeasuredDatabase = async (adapter, plugins, options = {}) => {
2756
+ const { createHotUpdater } = await import("@hot-updater/server");
2757
+ let clientAuth;
2758
+ const observed = plugins.map((plugin) => Object.defineProperties({}, {
2759
+ ...Object.getOwnPropertyDescriptors(plugin),
2760
+ init: {
2761
+ configurable: true,
2762
+ enumerable: true,
2763
+ writable: true,
2764
+ value: (context) => {
2765
+ const instance = plugin.init(options.now === void 0 ? context : {
2766
+ ...context,
2767
+ now: options.now
2768
+ });
2769
+ if (instance.clientAuth !== void 0) clientAuth = instance.clientAuth;
2770
+ return instance;
2771
+ }
2772
+ }
2773
+ }));
2774
+ const database = meterReads({
2775
+ name: "measured",
2776
+ adapter: verifyAdapter(adapter)
2777
+ });
2778
+ const hotUpdater = createHotUpdater({
2779
+ database,
2780
+ storage: options.storage === void 0 ? [] : [storageOf(options.storage)],
2781
+ plugins: observed,
2782
+ ...plugins.some((plugin) => plugin.provides?.clientAuth === true) ? {} : { clientAccess: "public" }
2783
+ });
2784
+ return {
2785
+ core: hotUpdater.core,
2786
+ api: hotUpdater.api,
2787
+ ...clientAuth === void 0 ? {} : { clientAuth },
2788
+ measureReads: database.measureReads
2789
+ };
2790
+ };
2791
+ //#endregion
2186
2792
  //#region src/setupReadBudgetTestSuite.ts
2187
2793
  const DAY = 864e5;
2188
2794
  const HOUR = 36e5;
@@ -2286,7 +2892,7 @@ const storage = {
2286
2892
  bundleId: createBundleFixture("106").id,
2287
2893
  assets: { "index.ios.bundle": { fileHash: "hbc-hash-106" } }
2288
2894
  }) : null,
2289
- resolveFileUrl: async (storageUri) => storageUri === null ? null : `https://storage.example.com/${encodeURIComponent(storageUri)}`
2895
+ resolveFileUrl: async (storageUri) => `https://storage.example.com/${encodeURIComponent(storageUri)}`
2290
2896
  };
2291
2897
  /**
2292
2898
  * Core's bundles, releases, channels, and a client API key, and 25 Insights
@@ -2294,8 +2900,8 @@ const storage = {
2294
2900
  * days later; and the day of history before T0, as a production database
2295
2901
  * holds, so a read that scans beyond its window examines more rows.
2296
2902
  */
2297
- const seed = async (database, server) => {
2298
- const { core } = database;
2903
+ const seed = async (database) => {
2904
+ const core = database.core;
2299
2905
  const api = database.api;
2300
2906
  for (const name of [
2301
2907
  "staging",
@@ -2354,7 +2960,7 @@ const seed = async (database, server) => {
2354
2960
  productionId,
2355
2961
  nightlyId: (await channel("nightly")).id,
2356
2962
  /** The auto-patch base key of a new production iOS 1.0.0 bundle. */
2357
- candidateKey: server.targetBaseCandidateKey({
2963
+ candidateKey: targetBaseCandidateKey({
2358
2964
  channel: "production",
2359
2965
  platform: "ios",
2360
2966
  fingerprintHash: null,
@@ -2784,11 +3390,12 @@ const READ_BUDGETS = [
2784
3390
  })
2785
3391
  ];
2786
3392
  /**
2787
- * The read-budget suite (PRD S4) on one backend: core and the built-in
2788
- * plugins in verify mode over the backend's adapter, native pages capped at
2789
- * two rows. Every API in the read-budget list reads exactly its budget at the
2790
- * adapter and at the engine; with `examined`, the database examines no more
2791
- * rows than the adapter read, within the backend's native multipliers.
3393
+ * The read-budget suite (PRD S4) on one backend: core and the server's
3394
+ * built-in plugins on `createMeasuredDatabase` over the backend's adapter,
3395
+ * native pages capped at two rows. Every API in the read-budget list reads
3396
+ * exactly its budget at the adapter and at the engine; with `examined`, the
3397
+ * database examines no more rows than the adapter read, within the backend's
3398
+ * native multipliers. It loads `@hot-updater/server`, an optional peer.
2792
3399
  */
2793
3400
  const setupReadBudgetTestSuite = (options) => {
2794
3401
  describe(`${options.name} read budgets`, () => {
@@ -2797,22 +3404,24 @@ const setupReadBudgetTestSuite = (options) => {
2797
3404
  let seeded;
2798
3405
  let writes = 0;
2799
3406
  beforeAll(async () => {
3407
+ const [{ insights }, { apiKeys }] = await Promise.all([import("@hot-updater/server/plugins/insights"), import("@hot-updater/server/plugins/api-keys")]);
3408
+ const plugins = [insights(), apiKeys()];
2800
3409
  created = await options.createAdapter({
2801
- tables: options.server.toolingTargetOf(options.server.plugins).schema.tables,
3410
+ tables: [...toolingTargetOf(plugins).schema.tables, SETTINGS_TABLE],
2802
3411
  nativePageSize: NATIVE_PAGE_SIZE
2803
3412
  });
2804
3413
  const { adapter } = created;
2805
- database = options.server.createMeasuredDatabase({
3414
+ database = await createMeasuredDatabase({
2806
3415
  ...adapter,
2807
3416
  write: (ops) => {
2808
3417
  writes += 1;
2809
3418
  return adapter.write(ops);
2810
3419
  }
2811
- }, options.server.plugins, {
3420
+ }, plugins, {
2812
3421
  now: () => T0 + 10 * DAY,
2813
3422
  storage
2814
3423
  });
2815
- seeded = await seed(database, options.server);
3424
+ seeded = await seed(database);
2816
3425
  }, 3e5);
2817
3426
  afterAll(async () => {
2818
3427
  await created?.cleanup?.();
@@ -2901,9 +3510,14 @@ const identity = (name) => ({ [name]: {
2901
3510
  type: "string",
2902
3511
  maxLength: 16
2903
3512
  } });
2904
- /** A module shaped like `defineTable` and `defineAggregate` build them. */
2905
- const module = {
3513
+ /**
3514
+ * A plugin's tables, shaped like `defineTable` and `defineAggregate` build
3515
+ * them, under their own names.
3516
+ */
3517
+ const plugin = {
2906
3518
  id: "batching",
3519
+ schemaVersion: "1",
3520
+ namespace: false,
2907
3521
  schema: {
2908
3522
  installs: {
2909
3523
  kind: "table",
@@ -3019,31 +3633,32 @@ const snapshot = async (db) => {
3019
3633
  * writes with each transaction and a compaction merges once, and a memory
3020
3634
  * buffer, read back exactly as transactional writes read.
3021
3635
  */
3022
- const setupAggregateBatchingTestSuite = ({ name, engine, createAdapter, oversizedRows }) => {
3023
- const schema = engine.resolveSchema([module, engine.aggregateBatchingModule]);
3636
+ const setupAggregateBatchingTestSuite = ({ name, createAdapter, oversizedRows }) => {
3637
+ const tables = [...toolingTargetOf([plugin]).schema.tables, ...aggregateBatchingTables];
3024
3638
  const cleanups = [];
3025
3639
  const store = async () => {
3026
3640
  const { adapter, cleanup } = await createAdapter();
3027
3641
  if (cleanup) cleanups.push(cleanup);
3028
- await adapter.migrations?.apply(schema.tables);
3642
+ await adapter.migrations?.apply(tables);
3029
3643
  return adapter;
3030
3644
  };
3645
+ /** A server's engine on `adapter`, batching as `batching` says. */
3031
3646
  const server = (adapter, batching) => {
3032
- const created = engine.createDatabaseEngine({
3647
+ const engine = createEngine({
3648
+ name,
3033
3649
  adapter,
3034
- schema,
3035
- ...batching === void 0 ? {} : { batching },
3650
+ ...batching === void 0 ? {} : { aggregateBatching: batching },
3036
3651
  retry: { attempts: 64 }
3037
- });
3652
+ }, { plugins: [plugin] });
3038
3653
  return {
3039
- db: created.database(module),
3040
- flush: created.flush
3654
+ db: engine.database(plugin),
3655
+ flush: engine.flush
3041
3656
  };
3042
3657
  };
3043
3658
  /** The reference: transactional writes on a memory adapter. */
3044
3659
  const expected = async (steps) => {
3045
3660
  const memory = createMemoryAdapter();
3046
- await memory.migrations?.apply(schema.tables);
3661
+ await memory.migrations?.apply(tables);
3047
3662
  const { db } = server(memory);
3048
3663
  for (let step = 0; step < steps; step += 1) await open(db, step);
3049
3664
  return snapshot(db);
@@ -3224,41 +3839,1056 @@ const mysqlRowsExamined = (query) => {
3224
3839
  //#endregion
3225
3840
  //#region src/createPluginTestHarness.ts
3226
3841
  /**
3227
- * Runs one plugin the way `createHotUpdater` does, on a memory adapter in
3228
- * verify mode, so tests can call its API and measure what each call reads.
3842
+ * Runs one plugin on `createEngine`, the storage engine `createHotUpdater`
3843
+ * runs its plugins on, so tests can call its API and measure what each call
3844
+ * reads. Every adapter call is checked against the adapter contract
3845
+ * (`verifyAdapter`), and `ctx.core` is empty: core's reads need a server.
3846
+ * The engine gets the adapter without `prune`, as a store that expires rows
3847
+ * itself would give it, so no retention pass adds to what a call reads and
3848
+ * writes; rows stay past their retention.
3229
3849
  */
3230
- const createPluginTestHarness = async (plugin, options) => {
3850
+ const createPluginTestHarness = async (plugin, options = {}) => {
3231
3851
  const adapter = options.adapter ?? createMemoryAdapter();
3232
- const module = {
3233
- id: plugin.id,
3234
- schema: plugin.schema,
3235
- ...plugin.namespace === false ? {} : { namespace: plugin.id }
3236
- };
3237
- const schema = options.engine.resolveSchema([module]);
3238
- await adapter.migrations?.apply(schema.tables);
3239
- const engine = options.engine.createDatabaseEngine({
3240
- adapter,
3241
- schema,
3242
- verify: true
3243
- });
3852
+ const core = new Set(coreSchema.tables.map(({ name }) => name));
3853
+ const tables = toolingTargetOf([plugin]).schema.tables.filter(({ name }) => !core.has(name));
3854
+ await adapter.migrations?.apply(tables);
3244
3855
  let clock = options.now ?? Date.now;
3245
- const db = engine.database(module);
3856
+ const now = () => clock();
3857
+ const database = meterReads({
3858
+ name: "plugin test harness",
3859
+ adapter: verifyAdapter(Object.assign(Object.create(adapter), { prune: void 0 }))
3860
+ });
3861
+ const db = createEngine(database, {
3862
+ plugins: [plugin],
3863
+ now
3864
+ }).database(plugin);
3246
3865
  const instance = plugin.init({
3247
3866
  db,
3248
3867
  core: {},
3249
- now: () => clock()
3868
+ now
3250
3869
  });
3251
3870
  return {
3252
3871
  api: instance.api,
3253
3872
  instance,
3254
- /** The plugin's own database handle; cast it to its `HotUpdaterDatabase` type. */
3873
+ /** The plugin's own database handle. */
3255
3874
  db,
3256
3875
  adapter,
3257
- tables: schema.tables,
3258
- /** Reads at both boundaries while `read` runs. */
3259
- measureReads: (read) => engine.measureReads(read),
3260
- setNow: (now) => {
3261
- clock = now;
3876
+ /** The plugin's physical tables. */
3877
+ tables,
3878
+ /** Reads at the adapter and at the engine while `read` runs. */
3879
+ measureReads: database.measureReads,
3880
+ setNow: (next) => {
3881
+ clock = next;
3882
+ }
3883
+ };
3884
+ };
3885
+ //#endregion
3886
+ //#region src/storeBundles.ts
3887
+ /**
3888
+ * Stores bundles and their patches with no release, as a database holds an
3889
+ * artifact that no release uses. `deploy` always adds a release, and
3890
+ * deleting a bundle's last release deletes the bundle, so tests store such
3891
+ * bundles through the engine. A patch's base comes before it in `bundles`.
3892
+ */
3893
+ const storeBundles = async (database, bundles) => {
3894
+ const engine = createEngine(database);
3895
+ try {
3896
+ await engine.core.transaction(async (tx) => {
3897
+ for (const bundle of bundles) {
3898
+ tx.create("bundles", bundleToRow(bundle));
3899
+ for (const platformKey of [bundle.platform, "*"]) tx.aggregate("bundle_totals", { platform_key: platformKey }, { bundles: 1 });
3900
+ for (const row of bundleToPatchRows(bundle)) tx.create("bundle_patches", row);
3901
+ }
3902
+ });
3903
+ } finally {
3904
+ await engine.dispose();
3905
+ }
3906
+ };
3907
+ //#endregion
3908
+ //#region src/insights/expectInsightsIndex.ts
3909
+ /** Allow committed secondary indexes to catch up; query failures still fail. */
3910
+ const expectInsightsIndex = async (query, expected) => {
3911
+ const deadline = Date.now() + 5e3;
3912
+ for (;;) {
3913
+ const actual = await query();
3914
+ try {
3915
+ expect(actual).toEqual(expected);
3916
+ return;
3917
+ } catch (error) {
3918
+ if (Date.now() >= deadline) throw error;
3919
+ }
3920
+ await new Promise((resolve) => setTimeout(resolve, 50));
3921
+ }
3922
+ };
3923
+ //#endregion
3924
+ //#region src/insights/fixtures.ts
3925
+ const fixtureId = (suffix) => `00000000-0000-7000-8000-${suffix.padStart(12, "0")}`;
3926
+ /** An applied update from install `suffix`, received at `receivedAtMs`. */
3927
+ const createBundleEventRowFixture = (suffix, receivedAtMs) => ({
3928
+ id: fixtureId(suffix),
3929
+ type: "UPDATE_APPLIED",
3930
+ install_id: `install-${suffix}`,
3931
+ user_id: null,
3932
+ metadata: {
3933
+ cohort: "0",
3934
+ update_strategy: "appVersion",
3935
+ fingerprint_hash: null,
3936
+ sdk_version: null
3937
+ },
3938
+ from_bundle_id: fixtureId(`${Number(suffix) + 1e3}`),
3939
+ from_release_id: null,
3940
+ to_bundle_id: fixtureId(`${Number(suffix) + 2e3}`),
3941
+ to_release_id: null,
3942
+ platform: "ios",
3943
+ app_version: "1.0.0",
3944
+ channel: "production",
3945
+ received_at_ms: receivedAtMs
3946
+ });
3947
+ //#endregion
3948
+ //#region src/insights/insightsModelTests.ts
3949
+ const record = (model, event) => model.recordEvent({ event });
3950
+ const createMovementEvent = (suffix, receivedAtMs, type, installId) => {
3951
+ const row = createBundleEventRowFixture(suffix, receivedAtMs);
3952
+ return {
3953
+ ...row,
3954
+ type,
3955
+ install_id: installId,
3956
+ from_bundle_id: row.to_bundle_id,
3957
+ metadata: {
3958
+ ...row.metadata,
3959
+ update_strategy: "appVersion"
3960
+ }
3961
+ };
3962
+ };
3963
+ /**
3964
+ * The Insights report contract on `InsightsModel`: what the Insights plugin
3965
+ * records and reads, through `createInsightsModel`, on one database.
3966
+ */
3967
+ const registerInsightsModelTests = (state) => {
3968
+ describe("Insights report contract", () => {
3969
+ it("preserves ancillary JSON through event history and latest-event reads", async () => {
3970
+ const model = state.getDatabase();
3971
+ const base = createBundleEventRowFixture("979", 100);
3972
+ const event = {
3973
+ ...base,
3974
+ metadata: {
3975
+ ...base.metadata,
3976
+ device: {
3977
+ locale: "ko-KR",
3978
+ tags: [
3979
+ "minor",
3980
+ null,
3981
+ true,
3982
+ 2
3983
+ ]
3984
+ },
3985
+ diagnostic: null
3986
+ }
3987
+ };
3988
+ await record(model, event);
3989
+ await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([event]);
3990
+ await expectInsightsIndex(() => model.listEvents({
3991
+ filter: { kind: "all" },
3992
+ beforeReceivedAtMs: 101,
3993
+ limit: 10
3994
+ }), [event]);
3995
+ });
3996
+ it("selects the latest complete event across download, replacement, apply, and delayed reports", async () => {
3997
+ const model = state.getDatabase();
3998
+ const download = {
3999
+ ...createBundleEventRowFixture("980", 100),
4000
+ type: "UPDATE_DOWNLOADED",
4001
+ from_bundle_id: "00000000-0000-7000-8000-000000001980",
4002
+ metadata: {
4003
+ ...createBundleEventRowFixture("980", 100).metadata,
4004
+ update_strategy: "appVersion"
4005
+ },
4006
+ to_release_id: "00000000-0000-7000-8000-000000004980"
4007
+ };
4008
+ await record(model, download);
4009
+ await record(model, download);
4010
+ await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([download]);
4011
+ await expectInsightsIndex(() => model.listEvents({
4012
+ filter: {
4013
+ kind: "bundle",
4014
+ type: "UPDATE_DOWNLOADED",
4015
+ toBundleId: download.to_bundle_id,
4016
+ platform: "ios",
4017
+ channel: "production"
4018
+ },
4019
+ beforeReceivedAtMs: 200,
4020
+ limit: 10
4021
+ }), [download]);
4022
+ const replacement = {
4023
+ ...download,
4024
+ id: createBundleEventRowFixture("981", 110).id,
4025
+ received_at_ms: 110,
4026
+ to_bundle_id: "00000000-0000-7000-8000-000000002981"
4027
+ };
4028
+ await record(model, replacement);
4029
+ await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([replacement]);
4030
+ const applied = {
4031
+ ...replacement,
4032
+ type: "UPDATE_APPLIED",
4033
+ id: createBundleEventRowFixture("982", 120).id,
4034
+ received_at_ms: 120
4035
+ };
4036
+ await record(model, applied);
4037
+ await record(model, {
4038
+ ...download,
4039
+ id: createBundleEventRowFixture("983", 105).id,
4040
+ received_at_ms: 105
4041
+ });
4042
+ await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([applied]);
4043
+ await expectInsightsIndex(() => model.listEvents({
4044
+ filter: {
4045
+ kind: "installationMovement",
4046
+ installId: download.install_id
4047
+ },
4048
+ beforeReceivedAtMs: 200,
4049
+ limit: 10
4050
+ }), [
4051
+ applied,
4052
+ replacement,
4053
+ {
4054
+ ...download,
4055
+ id: createBundleEventRowFixture("983", 105).id,
4056
+ received_at_ms: 105
4057
+ },
4058
+ download
4059
+ ]);
4060
+ });
4061
+ it("treats duplicate IDs as complete no-ops, including changed retry payloads", async () => {
4062
+ const model = state.getDatabase();
4063
+ const event = createBundleEventRowFixture("901", 100);
4064
+ await Promise.all([
4065
+ record(model, event),
4066
+ record(model, event),
4067
+ record(model, event)
4068
+ ]);
4069
+ await record(model, event);
4070
+ const changed = {
4071
+ ...event,
4072
+ received_at_ms: 300,
4073
+ user_id: "changed-user"
4074
+ };
4075
+ await record(model, changed);
4076
+ const otherInstallation = {
4077
+ ...changed,
4078
+ install_id: "different-install"
4079
+ };
4080
+ await record(model, otherInstallation);
4081
+ await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([event]);
4082
+ await expect(model.findLatestEvents({ installId: otherInstallation.install_id })).resolves.toEqual([]);
4083
+ await expectInsightsIndex(() => model.listEvents({
4084
+ filter: { kind: "all" },
4085
+ beforeReceivedAtMs: 400,
4086
+ limit: 10
4087
+ }), [event]);
4088
+ });
4089
+ it("keeps all concurrent events and the greatest timestamp/ID state, including logout", async () => {
4090
+ const model = state.getDatabase();
4091
+ const older = {
4092
+ ...createBundleEventRowFixture("912", 100),
4093
+ install_id: "concurrent",
4094
+ user_id: "previous"
4095
+ };
4096
+ const tied = {
4097
+ ...createBundleEventRowFixture("910", 200),
4098
+ install_id: "concurrent",
4099
+ user_id: "previous"
4100
+ };
4101
+ const newest = {
4102
+ ...createBundleEventRowFixture("911", 200),
4103
+ install_id: "concurrent",
4104
+ user_id: null
4105
+ };
4106
+ await Promise.all([
4107
+ record(model, newest),
4108
+ record(model, older),
4109
+ record(model, tied)
4110
+ ]);
4111
+ await record(model, older);
4112
+ await expect(model.findLatestEvents({ installId: "concurrent" })).resolves.toEqual([newest]);
4113
+ await expect(model.findLatestEvents({
4114
+ userId: "previous",
4115
+ limit: 10
4116
+ })).resolves.toEqual([]);
4117
+ await expectInsightsIndex(() => model.listEvents({
4118
+ filter: { kind: "all" },
4119
+ beforeReceivedAtMs: 201,
4120
+ limit: 10
4121
+ }), [
4122
+ newest,
4123
+ tied,
4124
+ older
4125
+ ]);
4126
+ });
4127
+ it("rejects invalid event metadata before persisting a report", async () => {
4128
+ const model = state.getDatabase();
4129
+ const event = createBundleEventRowFixture("920", 100);
4130
+ await expect(model.recordEvent({ event: {
4131
+ ...event,
4132
+ metadata: {
4133
+ ...event.metadata,
4134
+ cohort: 123
4135
+ }
4136
+ } })).rejects.toMatchObject({ code: "invalid-data" });
4137
+ await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([]);
4138
+ await expect(model.listEvents({
4139
+ filter: { kind: "all" },
4140
+ beforeReceivedAtMs: 200,
4141
+ limit: 10
4142
+ })).resolves.toEqual([]);
4143
+ });
4144
+ it("shares scoped list/count predicates, recovery attribution, and half-open time bounds", async () => {
4145
+ const model = state.getDatabase();
4146
+ const bundleA = createBundleEventRowFixture("1", 0).to_bundle_id;
4147
+ const bundleB = createBundleEventRowFixture("2", 0).to_bundle_id;
4148
+ const bundleC = createBundleEventRowFixture("3", 0).to_bundle_id;
4149
+ const applied = {
4150
+ ...createBundleEventRowFixture("930", 100),
4151
+ install_id: "target",
4152
+ to_bundle_id: bundleB
4153
+ };
4154
+ const recovered = {
4155
+ ...createBundleEventRowFixture("931", 120),
4156
+ install_id: "target",
4157
+ type: "RECOVERED",
4158
+ from_bundle_id: bundleB,
4159
+ to_bundle_id: bundleA,
4160
+ metadata: {
4161
+ ...createBundleEventRowFixture("931", 120).metadata,
4162
+ update_strategy: "appVersion"
4163
+ }
4164
+ };
4165
+ const unchanged = {
4166
+ ...createBundleEventRowFixture("932", 130),
4167
+ type: "UNCHANGED",
4168
+ from_bundle_id: null,
4169
+ to_bundle_id: bundleB,
4170
+ metadata: {
4171
+ ...createBundleEventRowFixture("932", 130).metadata,
4172
+ update_strategy: null
4173
+ }
4174
+ };
4175
+ const excluded = [
4176
+ {
4177
+ ...applied,
4178
+ id: createBundleEventRowFixture("933", 99).id,
4179
+ install_id: "before",
4180
+ received_at_ms: 99
4181
+ },
4182
+ {
4183
+ ...applied,
4184
+ id: createBundleEventRowFixture("934", 200).id,
4185
+ install_id: "after",
4186
+ received_at_ms: 200
4187
+ },
4188
+ {
4189
+ ...applied,
4190
+ id: createBundleEventRowFixture("935", 110).id,
4191
+ install_id: "android",
4192
+ platform: "android"
4193
+ },
4194
+ {
4195
+ ...applied,
4196
+ id: createBundleEventRowFixture("936", 110).id,
4197
+ install_id: "preview",
4198
+ channel: "preview"
4199
+ },
4200
+ {
4201
+ ...recovered,
4202
+ id: createBundleEventRowFixture("937", 125).id,
4203
+ install_id: "other-source",
4204
+ from_bundle_id: bundleC,
4205
+ to_bundle_id: bundleB
4206
+ }
4207
+ ];
4208
+ for (const row of [
4209
+ applied,
4210
+ recovered,
4211
+ unchanged,
4212
+ ...excluded
4213
+ ]) await record(model, row);
4214
+ await expect(async () => record(model, {
4215
+ ...applied,
4216
+ type: "RELEASE_ADOPTED"
4217
+ })).rejects.toThrow();
4218
+ await expect(async () => record(model, {
4219
+ ...applied,
4220
+ type: "UNCHANGED",
4221
+ from_bundle_id: bundleB,
4222
+ metadata: {
4223
+ ...applied.metadata,
4224
+ update_strategy: "appVersion"
4225
+ }
4226
+ })).rejects.toThrow();
4227
+ await expect(model.findLatestEvents({ installId: "target" })).resolves.toEqual([recovered]);
4228
+ const cases = [[{
4229
+ platform: "ios",
4230
+ channel: "production",
4231
+ type: "UPDATE_APPLIED",
4232
+ toBundleId: bundleB
4233
+ }, applied], [{
4234
+ platform: "ios",
4235
+ channel: "production",
4236
+ type: "RECOVERED",
4237
+ fromBundleId: bundleB
4238
+ }, recovered]];
4239
+ for (const [filter, expected] of cases) {
4240
+ await expectInsightsIndex(() => model.countEvents({
4241
+ filter,
4242
+ sinceMs: 100,
4243
+ beforeReceivedAtMs: 200
4244
+ }), 1);
4245
+ await expectInsightsIndex(() => model.listEvents({
4246
+ filter: {
4247
+ kind: "bundle",
4248
+ ...filter
4249
+ },
4250
+ sinceMs: 100,
4251
+ beforeReceivedAtMs: 200,
4252
+ limit: 1
4253
+ }), [expected]);
4254
+ }
4255
+ await expect(model.findLatestEvents({ installId: unchanged.install_id })).resolves.toEqual([unchanged]);
4256
+ await expectInsightsIndex(() => model.listEvents({
4257
+ filter: { kind: "all" },
4258
+ sinceMs: 100,
4259
+ beforeReceivedAtMs: 200,
4260
+ limit: 10
4261
+ }), [
4262
+ excluded[4],
4263
+ recovered,
4264
+ excluded[3],
4265
+ excluded[2],
4266
+ applied
4267
+ ]);
4268
+ await expect(model.countEvents({
4269
+ filter: {
4270
+ platform: "ios",
4271
+ channel: "production",
4272
+ type: "UNCHANGED",
4273
+ toBundleId: bundleB
4274
+ },
4275
+ sinceMs: 100,
4276
+ beforeReceivedAtMs: 200
4277
+ })).rejects.toThrow();
4278
+ await expectInsightsIndex(() => model.countLatestEvents({
4279
+ platform: "ios",
4280
+ channel: "production",
4281
+ sinceMs: 0,
4282
+ bundle: [{
4283
+ field: "to_bundle_id",
4284
+ value: bundleA,
4285
+ types: [
4286
+ "UNCHANGED",
4287
+ "UPDATE_APPLIED",
4288
+ "RECOVERED"
4289
+ ]
4290
+ }]
4291
+ }), 1);
4292
+ await expectInsightsIndex(() => model.countLatestEvents({
4293
+ platform: "ios",
4294
+ channel: "production",
4295
+ sinceMs: 0
4296
+ }), 5);
4297
+ await expect(model.countLatestEvents({
4298
+ platform: "ios",
4299
+ channel: "production",
4300
+ sinceMs: 100
4301
+ })).rejects.toThrow();
4302
+ });
4303
+ it("uses exact identity and UTF-8 cursor order for user installations", async () => {
4304
+ const model = state.getDatabase();
4305
+ const ids = [
4306
+ "Install-a",
4307
+ "é",
4308
+ "install-a",
4309
+ "install-a ",
4310
+ "é",
4311
+ "",
4312
+ "😀"
4313
+ ];
4314
+ const events = ids.map((install_id, index) => ({
4315
+ ...createBundleEventRowFixture(String(950 + index), 100),
4316
+ install_id,
4317
+ user_id: "User-é"
4318
+ }));
4319
+ for (const event of events.toReversed()) await record(model, event);
4320
+ const spacedUser = {
4321
+ ...createBundleEventRowFixture("960", 100),
4322
+ install_id: "spaced-user-install",
4323
+ user_id: "User-é "
4324
+ };
4325
+ await record(model, spacedUser);
4326
+ await expectInsightsIndex(async () => {
4327
+ const found = [];
4328
+ let afterInstallId;
4329
+ for (let pageIndex = 0; pageIndex <= ids.length; pageIndex++) {
4330
+ const page = await model.findLatestEvents({
4331
+ userId: "User-é",
4332
+ afterInstallId,
4333
+ limit: 2
4334
+ });
4335
+ found.push(...page.map((row) => row.install_id));
4336
+ if (page.length < 2) return found;
4337
+ const nextCursor = page[page.length - 1].install_id;
4338
+ expect(nextCursor, "Insights cursor must advance").not.toBe(afterInstallId);
4339
+ afterInstallId = nextCursor;
4340
+ }
4341
+ throw new Error("Insights pagination did not terminate");
4342
+ }, ids);
4343
+ await expectInsightsIndex(() => model.findLatestEvents({
4344
+ userId: "User-é ",
4345
+ limit: 10
4346
+ }), [spacedUser]);
4347
+ await expect(model.findLatestEvents({
4348
+ userId: "user-é",
4349
+ limit: 10
4350
+ })).resolves.toEqual([]);
4351
+ await expect(model.findLatestEvents({ installId: "INSTALL-a" })).resolves.toEqual([]);
4352
+ await expect(model.findLatestEvents({ installId: "Install-a" })).resolves.toEqual([events[0]]);
4353
+ await expect(model.findLatestEvents({ installId: "install-a " })).resolves.toEqual([events[3]]);
4354
+ await expect(model.findLatestEvents({ installId: "install-a" })).resolves.toEqual([events[2]]);
4355
+ });
4356
+ it("counts overlapping bundle predicates once per latest installation", async () => {
4357
+ const model = state.getDatabase();
4358
+ const event = createBundleEventRowFixture("9700", 100);
4359
+ await record(model, event);
4360
+ const predicate = {
4361
+ field: "to_bundle_id",
4362
+ value: event.to_bundle_id,
4363
+ types: [event.type]
4364
+ };
4365
+ await expectInsightsIndex(() => model.countLatestEvents({
4366
+ platform: event.platform,
4367
+ channel: event.channel,
4368
+ sinceMs: 0,
4369
+ bundle: [predicate, predicate]
4370
+ }), 1);
4371
+ });
4372
+ it("counts a latest installation once when a from and a to predicate both match it", async () => {
4373
+ const model = state.getDatabase();
4374
+ const moved = createBundleEventRowFixture("9701", 100);
4375
+ await record(model, moved);
4376
+ await expectInsightsIndex(() => model.countLatestEvents({
4377
+ platform: moved.platform,
4378
+ channel: moved.channel,
4379
+ sinceMs: 0,
4380
+ bundle: [{
4381
+ field: "from_bundle_id",
4382
+ value: moved.from_bundle_id,
4383
+ types: [moved.type]
4384
+ }, {
4385
+ field: "to_bundle_id",
4386
+ value: moved.to_bundle_id,
4387
+ types: [moved.type]
4388
+ }]
4389
+ }), 1);
4390
+ });
4391
+ it("returns zero for successful empty scalar queries", async () => {
4392
+ const model = state.getDatabase();
4393
+ await expect(model.countLatestEvents({
4394
+ platform: "ios",
4395
+ channel: "production",
4396
+ sinceMs: 0
4397
+ })).resolves.toBe(0);
4398
+ await expect(model.countEvents({
4399
+ filter: {
4400
+ platform: "ios",
4401
+ channel: "production",
4402
+ type: "RECOVERED",
4403
+ fromBundleId: createBundleEventRowFixture("1", 0).to_bundle_id
4404
+ },
4405
+ sinceMs: 0,
4406
+ beforeReceivedAtMs: 100
4407
+ })).resolves.toBe(0);
4408
+ });
4409
+ it("maintains idempotent release, scope, usage, and latest-distribution summaries", async () => {
4410
+ const model = state.getDatabase();
4411
+ const releaseA = "00000000-0000-7000-8000-000000008001";
4412
+ const releaseB = "00000000-0000-7000-8000-000000008002";
4413
+ const download = {
4414
+ ...createBundleEventRowFixture("9801", 1e3),
4415
+ type: "UPDATE_DOWNLOADED",
4416
+ install_id: "summary-install-a",
4417
+ from_release_id: releaseA,
4418
+ to_release_id: releaseB
4419
+ };
4420
+ const appliedA = {
4421
+ ...download,
4422
+ id: createBundleEventRowFixture("9802", 2e3).id,
4423
+ type: "UPDATE_APPLIED",
4424
+ received_at_ms: 2e3
4425
+ };
4426
+ const appliedB = {
4427
+ ...appliedA,
4428
+ id: createBundleEventRowFixture("9803", 2100).id,
4429
+ install_id: "summary-install-b",
4430
+ received_at_ms: 2100
4431
+ };
4432
+ const recovered = {
4433
+ ...appliedA,
4434
+ id: createBundleEventRowFixture("9804", 3e3).id,
4435
+ type: "RECOVERED",
4436
+ from_release_id: releaseB,
4437
+ to_release_id: releaseA,
4438
+ received_at_ms: 3e3
4439
+ };
4440
+ for (const event of [
4441
+ download,
4442
+ appliedA,
4443
+ appliedB,
4444
+ appliedB,
4445
+ recovered
4446
+ ]) await record(model, event);
4447
+ const releases = [{
4448
+ releaseId: releaseA,
4449
+ platform: "ios",
4450
+ channel: "production"
4451
+ }, {
4452
+ releaseId: releaseB,
4453
+ platform: "ios",
4454
+ channel: "production"
4455
+ }];
4456
+ const lifetime = await model.getReleaseActivity({ releases });
4457
+ expect(lifetime.data.map(({ metrics }) => metrics)).toEqual([{
4458
+ downloads: 0,
4459
+ launches: 1,
4460
+ failedLaunches: 0
4461
+ }, {
4462
+ downloads: 1,
4463
+ launches: 2,
4464
+ failedLaunches: 1
4465
+ }]);
4466
+ const scope = await model.getReleaseActivity({
4467
+ scope: {
4468
+ platform: "ios",
4469
+ channel: "production"
4470
+ },
4471
+ timeRange: {
4472
+ start: 0,
4473
+ end: 36e5
4474
+ }
4475
+ });
4476
+ expect(scope.data[0]?.metrics).toMatchObject({
4477
+ downloads: 1,
4478
+ launches: 3,
4479
+ failedLaunches: 1,
4480
+ uniqueUsers: 2
4481
+ });
4482
+ const usage = await model.getAppUsage({
4483
+ channel: "production",
4484
+ platform: "all",
4485
+ timeRange: {
4486
+ start: 0,
4487
+ end: 36e5
4488
+ },
4489
+ intervalMs: 36e5
4490
+ });
4491
+ expect(usage.activeInstallations).toBe(2);
4492
+ expect(usage.points).toEqual([{
4493
+ startMs: 0,
4494
+ installations: 2
4495
+ }]);
4496
+ expect(usage.bundleDistribution).toEqual(expect.arrayContaining([{
4497
+ appVersion: "1.0.0",
4498
+ platform: "ios",
4499
+ releaseId: releaseA,
4500
+ installations: 1
4501
+ }, {
4502
+ appVersion: "1.0.0",
4503
+ platform: "ios",
4504
+ releaseId: releaseB,
4505
+ installations: 1
4506
+ }]));
4507
+ });
4508
+ it("pages insights events newest first with a stable cursor", async () => {
4509
+ const model = state.getDatabase();
4510
+ const first = createBundleEventRowFixture("701", 100);
4511
+ const second = createBundleEventRowFixture("702", 100);
4512
+ const third = createBundleEventRowFixture("703", 200);
4513
+ await model.recordEvent({ event: third });
4514
+ await model.recordEvent({ event: second });
4515
+ await model.recordEvent({ event: first });
4516
+ await expectInsightsIndex(() => model.listEvents({
4517
+ filter: { kind: "all" },
4518
+ beforeReceivedAtMs: 201,
4519
+ limit: 2
4520
+ }), [third, second]);
4521
+ await expectInsightsIndex(() => model.listEvents({
4522
+ filter: { kind: "all" },
4523
+ after: {
4524
+ receivedAtMs: second.received_at_ms,
4525
+ id: second.id
4526
+ },
4527
+ beforeReceivedAtMs: 201,
4528
+ limit: 2
4529
+ }), [first]);
4530
+ await expectInsightsIndex(() => model.listEvents({
4531
+ filter: { kind: "all" },
4532
+ beforeReceivedAtMs: 200,
4533
+ limit: 10
4534
+ }), [second, first]);
4535
+ });
4536
+ it("filters installation movements before applying the page limit", async () => {
4537
+ const model = state.getDatabase();
4538
+ const unchanged = {
4539
+ ...createBundleEventRowFixture("711", 300),
4540
+ type: "UNCHANGED",
4541
+ install_id: "install-target",
4542
+ from_bundle_id: null,
4543
+ metadata: {
4544
+ ...createBundleEventRowFixture("711", 300).metadata,
4545
+ update_strategy: null
4546
+ }
4547
+ };
4548
+ const unrelated = createMovementEvent("712", 250, "UPDATE_APPLIED", "install-other");
4549
+ const applied = createMovementEvent("713", 200, "UPDATE_APPLIED", "install-target");
4550
+ const recovered = createMovementEvent("714", 100, "RECOVERED", "install-target");
4551
+ for (const row of [
4552
+ unchanged,
4553
+ unrelated,
4554
+ applied,
4555
+ recovered
4556
+ ]) await model.recordEvent({ event: row });
4557
+ await expectInsightsIndex(() => model.listEvents({
4558
+ filter: {
4559
+ kind: "installationMovement",
4560
+ installId: "install-target"
4561
+ },
4562
+ beforeReceivedAtMs: 301,
4563
+ limit: 1
4564
+ }), [applied]);
4565
+ await expectInsightsIndex(() => model.listEvents({
4566
+ filter: {
4567
+ kind: "installationMovement",
4568
+ installId: "install-target"
4569
+ },
4570
+ after: {
4571
+ receivedAtMs: applied.received_at_ms,
4572
+ id: applied.id
4573
+ },
4574
+ beforeReceivedAtMs: 301,
4575
+ limit: 1
4576
+ }), [recovered]);
4577
+ });
4578
+ it("lists an update failure where its target's list and installation history read it, and a failed check in no bundle list, moving no latest event", async () => {
4579
+ const model = state.getDatabase();
4580
+ const applied = createMovementEvent("721", 100, "UPDATE_APPLIED", "install-failing");
4581
+ const failure = (suffix, receivedAtMs, failed) => ({
4582
+ ...applied,
4583
+ id: createBundleEventRowFixture(suffix, 0).id,
4584
+ type: "UPDATE_FAILED",
4585
+ from_bundle_id: applied.to_bundle_id,
4586
+ to_bundle_id: failed.stage === "check" ? applied.to_bundle_id : createBundleEventRowFixture("729", 0).to_bundle_id,
4587
+ metadata: {
4588
+ ...applied.metadata,
4589
+ failure: failed
4590
+ },
4591
+ received_at_ms: receivedAtMs
4592
+ });
4593
+ const download = failure("722", 200, {
4594
+ stage: "download",
4595
+ reason: "http",
4596
+ http_status: 403,
4597
+ origin_code: "AccessDenied"
4598
+ });
4599
+ const check = failure("723", 300, {
4600
+ stage: "check",
4601
+ reason: "http"
4602
+ });
4603
+ for (const event of [
4604
+ applied,
4605
+ download,
4606
+ check
4607
+ ]) await record(model, event);
4608
+ await expect(model.findLatestEvents({ installId: "install-failing" })).resolves.toEqual([applied]);
4609
+ await expectInsightsIndex(() => model.listEvents({
4610
+ filter: {
4611
+ kind: "installationMovement",
4612
+ installId: "install-failing"
4613
+ },
4614
+ beforeReceivedAtMs: 301,
4615
+ limit: 10
4616
+ }), [
4617
+ check,
4618
+ download,
4619
+ applied
4620
+ ]);
4621
+ await expectInsightsIndex(() => model.listEvents({
4622
+ filter: { kind: "all" },
4623
+ beforeReceivedAtMs: 301,
4624
+ limit: 10
4625
+ }), [
4626
+ check,
4627
+ download,
4628
+ applied
4629
+ ]);
4630
+ const scope = {
4631
+ platform: "ios",
4632
+ channel: "production"
4633
+ };
4634
+ for (const [toBundleId, listed] of [[download.to_bundle_id, [download]], [check.to_bundle_id, []]]) {
4635
+ const filter = {
4636
+ ...scope,
4637
+ type: "UPDATE_FAILED",
4638
+ toBundleId
4639
+ };
4640
+ await expectInsightsIndex(() => model.listEvents({
4641
+ filter: {
4642
+ kind: "bundle",
4643
+ ...filter
4644
+ },
4645
+ beforeReceivedAtMs: 301,
4646
+ limit: 10
4647
+ }), listed);
4648
+ await expect(model.countEvents({
4649
+ filter,
4650
+ sinceMs: 0,
4651
+ beforeReceivedAtMs: 301
4652
+ })).resolves.toBe(listed.length);
4653
+ }
4654
+ });
4655
+ });
4656
+ };
4657
+ //#endregion
4658
+ //#region src/insights/setupInsightsHttpTestSuite.ts
4659
+ const jsonRequest$1 = (method, body) => ({
4660
+ method,
4661
+ headers: { "content-type": "application/json" },
4662
+ body: JSON.stringify(body)
4663
+ });
4664
+ /**
4665
+ * The Insights plugin's routes on a database: a client reports an event, and
4666
+ * the admin routes read it back. The server must run `insights()`.
4667
+ */
4668
+ const setupInsightsHttpTestSuite = (options) => {
4669
+ describe("Insights HTTP routes", () => {
4670
+ it("records a reported event and reads it back", async () => {
4671
+ const client = options.getClient();
4672
+ const installId = `install-${crypto.randomUUID()}`;
4673
+ const userId = `user-${crypto.randomUUID()}`;
4674
+ const channel = `insights-${crypto.randomUUID()}`;
4675
+ const event = {
4676
+ appVersion: "1.0.0",
4677
+ channel,
4678
+ cohort: "default",
4679
+ fingerprintHash: null,
4680
+ fromBundleId: null,
4681
+ fromReleaseId: null,
4682
+ installId,
4683
+ platform: "ios",
4684
+ toBundleId: "00000000-0000-7000-8000-000000000001",
4685
+ toReleaseId: null,
4686
+ type: "UNCHANGED",
4687
+ updateStrategy: null,
4688
+ userId,
4689
+ sdkVersion: "2.0.0"
4690
+ };
4691
+ const reported = await client.client("/events", jsonRequest$1("POST", event));
4692
+ expect(reported.status).toBe(204);
4693
+ await reported.text();
4694
+ const installation = await client.admin(`/installations/${encodeURIComponent(installId)}`);
4695
+ expect(installation.status).toBe(200);
4696
+ expect(await installation.json()).toMatchObject({
4697
+ installId,
4698
+ latestStatus: "UNCHANGED",
4699
+ lastKnownBundleId: event.toBundleId,
4700
+ channel
4701
+ });
4702
+ await expectInsightsIndex(async () => {
4703
+ return (await (await client.admin(`/installations?userId=${encodeURIComponent(userId)}`)).json()).data.map(({ installId: id }) => id);
4704
+ }, [installId]);
4705
+ await expectInsightsIndex(async () => {
4706
+ return (await (await client.admin(`/overview?platform=ios&channel=${encodeURIComponent(channel)}&window=24h`)).json()).reportingInstallations.count;
4707
+ }, 1);
4708
+ });
4709
+ it("records an update failure in installation history and the failures read", async () => {
4710
+ const client = options.getClient();
4711
+ const installId = `install-${crypto.randomUUID()}`;
4712
+ const channel = `insights-${crypto.randomUUID()}`;
4713
+ const releaseId = "00000000-0000-7000-8000-0000000000b2";
4714
+ const reported = await client.client("/events", jsonRequest$1("POST", {
4715
+ appVersion: "1.0.0",
4716
+ channel,
4717
+ cohort: "default",
4718
+ fingerprintHash: null,
4719
+ fromBundleId: "00000000-0000-7000-8000-000000000001",
4720
+ fromReleaseId: null,
4721
+ installId,
4722
+ platform: "ios",
4723
+ toBundleId: "00000000-0000-7000-8000-000000000002",
4724
+ toReleaseId: releaseId,
4725
+ type: "UPDATE_FAILED",
4726
+ updateStrategy: "appVersion",
4727
+ metadata: { failure: {
4728
+ stage: "download",
4729
+ reason: "http",
4730
+ httpStatus: 403
4731
+ } }
4732
+ }));
4733
+ expect(reported.status).toBe(204);
4734
+ await reported.text();
4735
+ await expectInsightsIndex(async () => {
4736
+ return (await (await client.admin(`/installations/${encodeURIComponent(installId)}/events?limit=10`)).json()).data.map(({ type, failure }) => ({
4737
+ type,
4738
+ failure
4739
+ }));
4740
+ }, [{
4741
+ type: "UPDATE_FAILED",
4742
+ failure: {
4743
+ stage: "download",
4744
+ reason: "http",
4745
+ httpStatus: 403
4746
+ }
4747
+ }]);
4748
+ const now = Date.now();
4749
+ await expectInsightsIndex(async () => {
4750
+ const body = await (await client.admin(`/failures?platform=ios&channel=${encodeURIComponent(channel)}&releaseId=${releaseId}&start=${now - 864e5}&end=${now + 36e5}`)).json();
4751
+ return [body.failedUpdates, body.failedInstallations];
4752
+ }, [1, 1]);
4753
+ });
4754
+ it("refuses a malformed event", async () => {
4755
+ const response = await options.getClient().client("/events", jsonRequest$1("POST", { type: "UNCHANGED" }));
4756
+ expect(response.status).toBe(400);
4757
+ await response.text();
4758
+ });
4759
+ });
4760
+ };
4761
+ //#endregion
4762
+ //#region src/insights/setupInsightsModelTestSuite.ts
4763
+ /**
4764
+ * The Insights report contract on one database: pass the Insights plugin's
4765
+ * model over it, `createInsightsModel(createHotUpdater({ database, plugins:
4766
+ * [insights()], clientAccess: "public" }).api.insights)`, as `createDatabase`.
4767
+ */
4768
+ const setupInsightsModelTestSuite = (lifecycle) => {
4769
+ describe(lifecycle.name, () => {
4770
+ let model;
4771
+ const getDatabase = () => {
4772
+ if (model === void 0) throw new Error("The model is unavailable outside the test lifecycle");
4773
+ return model;
4774
+ };
4775
+ beforeAll(async () => {
4776
+ await lifecycle.migrate();
4777
+ model = await lifecycle.createDatabase();
4778
+ });
4779
+ beforeEach(async () => {
4780
+ await lifecycle.reset(getDatabase());
4781
+ });
4782
+ afterAll(async () => {
4783
+ await lifecycle.dispose(getDatabase());
4784
+ model = void 0;
4785
+ });
4786
+ registerInsightsModelTests({ getDatabase });
4787
+ });
4788
+ };
4789
+ //#endregion
4790
+ //#region src/insights/index.ts
4791
+ /**
4792
+ * The Insights plugin's tests, for `setupDatabaseTestSuite`'s `plugins`: its
4793
+ * HTTP routes on the provider's server, which must run `insights()`, and
4794
+ * with `createModel` its report contract on the provider's database, such as
4795
+ * `(database) => createInsightsModel(createHotUpdater({ database, plugins:
4796
+ * [insights()], clientAccess: "public" }).api.insights)`.
4797
+ */
4798
+ const insightsTestSuite = (options = {}) => ({
4799
+ name: "Insights plugin",
4800
+ register: (context) => {
4801
+ setupInsightsHttpTestSuite({ getClient: context.getClient });
4802
+ const { createModel } = options;
4803
+ if (createModel !== void 0) describe("Insights model", () => {
4804
+ registerInsightsModelTests({ getDatabase: () => createModel(context.getDatabase()) });
4805
+ });
4806
+ }
4807
+ });
4808
+ //#endregion
4809
+ //#region src/kvTestStore.ts
4810
+ /**
4811
+ * An in-memory key-value store that behaves like DynamoDB where the adapter
4812
+ * can tell: a write checks every condition against the state before it,
4813
+ * refuses two ops on one item, and applies all ops or none.
4814
+ */
4815
+ const createMemoryKeyValueStore = (options = {}) => {
4816
+ let partitions = /* @__PURE__ */ new Map();
4817
+ let expiries = /* @__PURE__ */ new Map();
4818
+ const read = (key) => partitions.get(key.pk)?.get(key.sk) ?? null;
4819
+ const holds = (key, condition) => {
4820
+ if (condition === void 0) return true;
4821
+ const item = read(key);
4822
+ return "v" in condition ? item !== null && item[DATABASE_VERSION_COLUMN] === condition.v : item !== null === condition.exists;
4823
+ };
4824
+ return {
4825
+ id: "memory-kv",
4826
+ limits: options.limits ?? {
4827
+ items: 100,
4828
+ bytes: 4e6
4829
+ },
4830
+ items: () => [...partitions.values()].reduce((sum, items) => sum + items.size, 0),
4831
+ expiries: () => expiries,
4832
+ async get(keys) {
4833
+ return keys.map((key) => structuredClone(read(key)));
4834
+ },
4835
+ async query({ pk, gte, lt, order, limit, after }) {
4836
+ const direction = order === "asc" ? 1 : -1;
4837
+ const matching = [...partitions.get(pk) ?? /* @__PURE__ */ new Map()].filter(([sk]) => (gte === void 0 || compareUtf8(sk, gte) >= 0) && (lt === void 0 || compareUtf8(sk, lt) < 0) && (after === void 0 || direction * compareUtf8(sk, after) > 0)).sort(([left], [right]) => direction * compareUtf8(left, right));
4838
+ const size = Math.min(limit, options.nativePageSize ?? limit);
4839
+ return {
4840
+ items: matching.slice(0, size).map(([sk, value]) => ({
4841
+ sk,
4842
+ value: structuredClone(value)
4843
+ })),
4844
+ more: matching.length > size
4845
+ };
4846
+ },
4847
+ async write(ops) {
4848
+ if (new Set(ops.map(({ key }) => JSON.stringify(key))).size !== ops.length) throw new Error("A write may touch each item once.");
4849
+ const failed = ops.findIndex((op) => !holds(op.key, op.condition));
4850
+ if (failed !== -1) return {
4851
+ ok: false,
4852
+ failedOp: failed
4853
+ };
4854
+ const next = new Map(partitions);
4855
+ const stamped = new Map(expiries);
4856
+ for (const op of ops) {
4857
+ if (op.type === "check") continue;
4858
+ const items = new Map(next.get(op.key.pk));
4859
+ next.set(op.key.pk, items);
4860
+ const id = JSON.stringify([op.key.pk, op.key.sk]);
4861
+ if (op.type === "delete") {
4862
+ items.delete(op.key.sk);
4863
+ stamped.delete(id);
4864
+ } else if (op.type === "put") {
4865
+ items.set(op.key.sk, structuredClone(op.value));
4866
+ if (op.expiresAt === void 0) stamped.delete(id);
4867
+ else stamped.set(id, op.expiresAt);
4868
+ } else {
4869
+ if (!items.has(op.key.sk) && op.expiresAt !== void 0) stamped.set(id, op.expiresAt);
4870
+ const item = structuredClone(items.get(op.key.sk) ?? op.init ?? {});
4871
+ for (const [name, delta] of Object.entries(op.by)) item[name] = Number(item[name] ?? 0) + delta;
4872
+ items.set(op.key.sk, item);
4873
+ }
4874
+ }
4875
+ partitions = next;
4876
+ expiries = stamped;
4877
+ return { ok: true };
4878
+ },
4879
+ async deleteConsumed(keys) {
4880
+ const next = new Map(partitions);
4881
+ for (const { pk, sk } of keys) {
4882
+ const items = new Map(next.get(pk));
4883
+ items.delete(sk);
4884
+ next.set(pk, items);
4885
+ expiries.delete(JSON.stringify([pk, sk]));
4886
+ }
4887
+ partitions = next;
4888
+ },
4889
+ async dispose() {
4890
+ partitions = /* @__PURE__ */ new Map();
4891
+ expiries = /* @__PURE__ */ new Map();
3262
4892
  }
3263
4893
  };
3264
4894
  };
@@ -3332,4 +4962,4 @@ async function startHttpTestServer(handlers) {
3332
4962
  };
3333
4963
  }
3334
4964
  //#endregion
3335
- export { RELEASE_CATALOG_DOWNLOAD_HASH, RELEASE_CATALOG_MANIFEST_URIS, RELEASE_CATALOG_STORAGE_FIXTURES, conformanceCounters, conformanceExpiring, conformanceItems, createAdminApiTestClient, createHandlerHttpTestClient, createHttpTestClient, createPluginTestHarness, createReleaseCatalogTestStorage, jsonRequest, mysqlRowsExamined, postgresRowsExamined, releaseCatalogArtifact, releaseCatalogDownloadUrl, runContentionHarness, setupAggregateBatchingTestSuite, setupBundleMethodsTestSuite, setupCoreAdminTestSuite, setupDatabaseAdapterConformanceSuite, setupDatabaseTestSuite, setupReadBudgetTestSuite, setupReleaseCatalogTestSuite, setupSemverSatisfiesTestSuite, startHttpTestServer, toDeployBundle, withAdapterLatency };
4965
+ export { RELEASE_CATALOG_DOWNLOAD_HASH, RELEASE_CATALOG_MANIFEST_URIS, RELEASE_CATALOG_STORAGE_FIXTURES, conformanceCounters, conformanceExpiring, conformanceItems, createAdminApiTestClient, createBundleEventRowFixture, createBundleFixture, createBundlePatchRowFixture, createBundleRowFixture, createChannelRowFixture, createHandlerHttpTestClient, createHttpTestClient, createMeasuredDatabase, createMemoryKeyValueStore, createPluginTestHarness, createReleaseCatalogTestStorage, createReleaseRowFixture, expectInsightsIndex, insightsTestSuite, jsonRequest, mysqlRowsExamined, postgresRowsExamined, releaseCatalogArtifact, releaseCatalogDownloadUrl, runContentionHarness, setupAggregateBatchingTestSuite, setupBundleMethodsTestSuite, setupCoreAdminTestSuite, setupDatabaseAdapterConformanceSuite, setupDatabaseTestSuite, setupInsightsHttpTestSuite, setupInsightsModelTestSuite, setupReadBudgetTestSuite, setupReleaseCatalogTestSuite, setupSemverSatisfiesTestSuite, setupStorageAdapterTestSuite, startHttpTestServer, storeBundles, toDeployBundle, withAdapterLatency };