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

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,575 @@ 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, ...context }) => {
2456
+ assertStorageOperations(storage, [
2457
+ "put",
2458
+ "get",
2459
+ ...operations
2460
+ ]);
2461
+ await run({
2462
+ ...context,
2463
+ storage: namingCalls(storage)
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, fetchDownloadUrls }) => {
2633
+ const objects = objectsOf(storage, basePath);
2634
+ for (const key of ["download/object.txt", ...SPECIAL_KEYS]) {
2635
+ const bytes = encode(key);
2636
+ const storageUri = await objects.put(key, bytes);
2637
+ const { url } = await storage.getDownloadUrl({ storageUri });
2638
+ const label = `getDownloadUrl("${storageUri}") returned "${url}"`;
2639
+ expect(typeof url, label).toBe("string");
2640
+ if (/^[a-z][a-z\d+.-]*:/i.test(url)) {
2641
+ expect(["http:", "https:"], `${label}; a URL must use http(s)`).toContain(new URL(url).protocol);
2642
+ if (fetchDownloadUrls) {
2643
+ const response = await fetch(url);
2644
+ expect(response.status, `${label}, which answered`).toBe(200);
2645
+ expectBytes(new Uint8Array(await response.arrayBuffer()), bytes, `${label}, whose download`);
2646
+ }
2647
+ continue;
2648
+ }
2649
+ expect(parseStorageDownloadPath(url)?.storageUri, `${label}; a path must be the /storage/ path of the same URI`).toBe(storageUri);
2650
+ expect((await storage.getDownloadUrl({ storageUri })).url, `${label}, then another path`).toBe(url);
2651
+ }
2652
+ }),
2653
+ storageCase("lists every object with its key relative to the base path", ["listObjects"], async ({ storage, basePath }) => {
2654
+ const objects = objectsOf(storage, basePath);
2655
+ const entries = [
2656
+ [MANIFEST_KEY, encode("{}")],
2657
+ [ARCHIVE_KEY, patternBytes(3001, 6)],
2658
+ [ASSET_KEY, patternBytes(1234, 7)],
2659
+ ["bundles-other/object.txt", encode("next to bundles/")],
2660
+ ...SPECIAL_KEYS.map((key) => [key, encode(key)])
2661
+ ];
2662
+ const from = Date.now();
2663
+ const stored = [];
2664
+ for (const [key, bytes] of entries) stored.push({
2665
+ key,
2666
+ storageUri: await objects.put(key, bytes),
2667
+ size: bytes.byteLength
2668
+ });
2669
+ const putWindow = {
2670
+ from,
2671
+ to: Date.now()
2672
+ };
2673
+ const below = (folder) => stored.filter(({ key }) => key.startsWith(`${folder}/`));
2674
+ expectListing("listObjects()", await storage.listObjects(), stored, putWindow);
2675
+ expectListing("listObjects(\"bundles\")", await storage.listObjects("bundles"), below("bundles"), putWindow);
2676
+ expectListing(`listObjects("${UNICODE_FOLDER}")`, await storage.listObjects(UNICODE_FOLDER), below(UNICODE_FOLDER), putWindow);
2677
+ }),
2678
+ storageCase("deletes exactly the keys it receives", ["deleteObjects"], async ({ storage, basePath }) => {
2679
+ const objects = objectsOf(storage, basePath);
2680
+ const put = async (key) => {
2681
+ const bytes = encode(key);
2682
+ return {
2683
+ key,
2684
+ bytes,
2685
+ storageUri: await objects.put(key, bytes)
2686
+ };
2687
+ };
2688
+ const removed = [await put("delete-objects/object.txt"), await put(`delete-objects/${UNICODE_KEY}`)];
2689
+ const kept = [await put("delete-objects/object.txt.bak"), await put("delete-objects/folder/child.txt")];
2690
+ await storage.deleteObjects([]);
2691
+ for (const object of [...removed, ...kept]) await expectObject(storage, object.storageUri, object.bytes);
2692
+ await storage.deleteObjects([
2693
+ ...removed.map(({ key }) => key),
2694
+ "delete-objects/never-stored.txt",
2695
+ "delete-objects/folder"
2696
+ ]);
2697
+ for (const object of removed) await expectMissing(storage, object.storageUri);
2698
+ for (const object of kept) await expectObject(storage, object.storageUri, object.bytes);
2699
+ if (storage.listObjects) expect((await storage.listObjects()).map(({ key }) => key).sort(), "listObjects() after deleteObjects").toEqual(kept.map(({ key }) => key).sort());
2700
+ })
2701
+ ];
2702
+ //#endregion
2703
+ //#region src/setupStorageAdapterTestSuite.ts
2704
+ /**
2705
+ * The storage adapter contract that deploy, patch, the Console, the server,
2706
+ * and `storage prune` rely on. Every official storage adapter runs it.
2707
+ */
2708
+ const setupStorageAdapterTestSuite = (options) => {
2709
+ const declared = options.operations;
2710
+ const required = [.../* @__PURE__ */ new Set([
2711
+ "put",
2712
+ "get",
2713
+ ...declared ?? []
2714
+ ])];
2715
+ describe(`${options.name} storage adapter`, () => {
2716
+ it(`implements ${required.join(", ")}`, async () => {
2717
+ const { storage, cleanup } = await options.createStorage();
2718
+ try {
2719
+ assertStorageOperations(storage, required);
2720
+ } finally {
2721
+ await cleanup?.();
2722
+ }
2723
+ });
2724
+ for (const testCase of storageAdapterTestCases) {
2725
+ const undeclared = declared === void 0 ? [] : testCase.operations.filter((operation) => !declared.includes(operation));
2726
+ it.skipIf(undeclared.length > 0)(testCase.title, async (context) => {
2727
+ const { storage, basePath, cleanup } = await options.createStorage();
2728
+ try {
2729
+ const missing = testCase.operations.filter((operation) => typeof storage[operation] !== "function");
2730
+ if (declared === void 0 && missing.length > 0) context.skip(`${storage.name} lacks ${missing.join(", ")}`);
2731
+ await testCase.run({
2732
+ storage,
2733
+ basePath,
2734
+ fetchDownloadUrls: options.fetchDownloadUrls
2735
+ });
2736
+ } finally {
2737
+ await cleanup?.();
2738
+ }
2739
+ });
2740
+ }
2741
+ });
2742
+ };
2743
+ //#endregion
2744
+ //#region src/createMeasuredDatabase.ts
2745
+ const storageOf = (storage) => ({
2746
+ name: "measuredStorage",
2747
+ protocol: "storage",
2748
+ get: async ({ storageUri }) => {
2749
+ const text = await storage.readStorageText?.(storageUri) ?? null;
2750
+ return { response: text === null ? null : new Response(text) };
2751
+ },
2752
+ getDownloadUrl: async ({ storageUri }) => ({ url: await storage.resolveFileUrl(storageUri) })
2753
+ });
2754
+ /**
2755
+ * Core and the plugins over `adapter` as `createHotUpdater` runs them: what
2756
+ * read-budget suites measure. The database is `meterReads` over
2757
+ * `verifyAdapter(adapter)`, so every adapter call is checked against the
2758
+ * contract and `measureReads` reports each call's reads; the adapter runs
2759
+ * as given, without the schema fence. It loads `@hot-updater/server`, an
2760
+ * optional peer, when called.
2761
+ */
2762
+ const createMeasuredDatabase = async (adapter, plugins, options = {}) => {
2763
+ const { createHotUpdater } = await import("@hot-updater/server");
2764
+ let clientAuth;
2765
+ const observed = plugins.map((plugin) => Object.defineProperties({}, {
2766
+ ...Object.getOwnPropertyDescriptors(plugin),
2767
+ init: {
2768
+ configurable: true,
2769
+ enumerable: true,
2770
+ writable: true,
2771
+ value: (context) => {
2772
+ const instance = plugin.init(options.now === void 0 ? context : {
2773
+ ...context,
2774
+ now: options.now
2775
+ });
2776
+ if (instance.clientAuth !== void 0) clientAuth = instance.clientAuth;
2777
+ return instance;
2778
+ }
2779
+ }
2780
+ }));
2781
+ const database = meterReads({
2782
+ name: "measured",
2783
+ adapter: verifyAdapter(adapter)
2784
+ });
2785
+ const hotUpdater = createHotUpdater({
2786
+ database,
2787
+ storage: options.storage === void 0 ? [] : [storageOf(options.storage)],
2788
+ plugins: observed,
2789
+ ...plugins.some((plugin) => plugin.provides?.clientAuth === true) ? {} : { clientAccess: "public" }
2790
+ });
2791
+ return {
2792
+ core: hotUpdater.core,
2793
+ api: hotUpdater.api,
2794
+ ...clientAuth === void 0 ? {} : { clientAuth },
2795
+ measureReads: database.measureReads
2796
+ };
2797
+ };
2798
+ //#endregion
2186
2799
  //#region src/setupReadBudgetTestSuite.ts
2187
2800
  const DAY = 864e5;
2188
2801
  const HOUR = 36e5;
@@ -2286,7 +2899,7 @@ const storage = {
2286
2899
  bundleId: createBundleFixture("106").id,
2287
2900
  assets: { "index.ios.bundle": { fileHash: "hbc-hash-106" } }
2288
2901
  }) : null,
2289
- resolveFileUrl: async (storageUri) => storageUri === null ? null : `https://storage.example.com/${encodeURIComponent(storageUri)}`
2902
+ resolveFileUrl: async (storageUri) => `https://storage.example.com/${encodeURIComponent(storageUri)}`
2290
2903
  };
2291
2904
  /**
2292
2905
  * Core's bundles, releases, channels, and a client API key, and 25 Insights
@@ -2294,8 +2907,8 @@ const storage = {
2294
2907
  * days later; and the day of history before T0, as a production database
2295
2908
  * holds, so a read that scans beyond its window examines more rows.
2296
2909
  */
2297
- const seed = async (database, server) => {
2298
- const { core } = database;
2910
+ const seed = async (database) => {
2911
+ const core = database.core;
2299
2912
  const api = database.api;
2300
2913
  for (const name of [
2301
2914
  "staging",
@@ -2354,7 +2967,7 @@ const seed = async (database, server) => {
2354
2967
  productionId,
2355
2968
  nightlyId: (await channel("nightly")).id,
2356
2969
  /** The auto-patch base key of a new production iOS 1.0.0 bundle. */
2357
- candidateKey: server.targetBaseCandidateKey({
2970
+ candidateKey: targetBaseCandidateKey({
2358
2971
  channel: "production",
2359
2972
  platform: "ios",
2360
2973
  fingerprintHash: null,
@@ -2784,11 +3397,12 @@ const READ_BUDGETS = [
2784
3397
  })
2785
3398
  ];
2786
3399
  /**
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.
3400
+ * The read-budget suite (PRD S4) on one backend: core and the server's
3401
+ * built-in plugins on `createMeasuredDatabase` over the backend's adapter,
3402
+ * native pages capped at two rows. Every API in the read-budget list reads
3403
+ * exactly its budget at the adapter and at the engine; with `examined`, the
3404
+ * database examines no more rows than the adapter read, within the backend's
3405
+ * native multipliers. It loads `@hot-updater/server`, an optional peer.
2792
3406
  */
2793
3407
  const setupReadBudgetTestSuite = (options) => {
2794
3408
  describe(`${options.name} read budgets`, () => {
@@ -2797,22 +3411,24 @@ const setupReadBudgetTestSuite = (options) => {
2797
3411
  let seeded;
2798
3412
  let writes = 0;
2799
3413
  beforeAll(async () => {
3414
+ const [{ insights }, { apiKeys }] = await Promise.all([import("@hot-updater/server/plugins/insights"), import("@hot-updater/server/plugins/api-keys")]);
3415
+ const plugins = [insights(), apiKeys()];
2800
3416
  created = await options.createAdapter({
2801
- tables: options.server.toolingTargetOf(options.server.plugins).schema.tables,
3417
+ tables: [...toolingTargetOf(plugins).schema.tables, SETTINGS_TABLE],
2802
3418
  nativePageSize: NATIVE_PAGE_SIZE
2803
3419
  });
2804
3420
  const { adapter } = created;
2805
- database = options.server.createMeasuredDatabase({
3421
+ database = await createMeasuredDatabase({
2806
3422
  ...adapter,
2807
3423
  write: (ops) => {
2808
3424
  writes += 1;
2809
3425
  return adapter.write(ops);
2810
3426
  }
2811
- }, options.server.plugins, {
3427
+ }, plugins, {
2812
3428
  now: () => T0 + 10 * DAY,
2813
3429
  storage
2814
3430
  });
2815
- seeded = await seed(database, options.server);
3431
+ seeded = await seed(database);
2816
3432
  }, 3e5);
2817
3433
  afterAll(async () => {
2818
3434
  await created?.cleanup?.();
@@ -2901,9 +3517,14 @@ const identity = (name) => ({ [name]: {
2901
3517
  type: "string",
2902
3518
  maxLength: 16
2903
3519
  } });
2904
- /** A module shaped like `defineTable` and `defineAggregate` build them. */
2905
- const module = {
3520
+ /**
3521
+ * A plugin's tables, shaped like `defineTable` and `defineAggregate` build
3522
+ * them, under their own names.
3523
+ */
3524
+ const plugin = {
2906
3525
  id: "batching",
3526
+ schemaVersion: "1",
3527
+ namespace: false,
2907
3528
  schema: {
2908
3529
  installs: {
2909
3530
  kind: "table",
@@ -3019,31 +3640,32 @@ const snapshot = async (db) => {
3019
3640
  * writes with each transaction and a compaction merges once, and a memory
3020
3641
  * buffer, read back exactly as transactional writes read.
3021
3642
  */
3022
- const setupAggregateBatchingTestSuite = ({ name, engine, createAdapter, oversizedRows }) => {
3023
- const schema = engine.resolveSchema([module, engine.aggregateBatchingModule]);
3643
+ const setupAggregateBatchingTestSuite = ({ name, createAdapter, oversizedRows }) => {
3644
+ const tables = [...toolingTargetOf([plugin]).schema.tables, ...aggregateBatchingTables];
3024
3645
  const cleanups = [];
3025
3646
  const store = async () => {
3026
3647
  const { adapter, cleanup } = await createAdapter();
3027
3648
  if (cleanup) cleanups.push(cleanup);
3028
- await adapter.migrations?.apply(schema.tables);
3649
+ await adapter.migrations?.apply(tables);
3029
3650
  return adapter;
3030
3651
  };
3652
+ /** A server's engine on `adapter`, batching as `batching` says. */
3031
3653
  const server = (adapter, batching) => {
3032
- const created = engine.createDatabaseEngine({
3654
+ const engine = createEngine({
3655
+ name,
3033
3656
  adapter,
3034
- schema,
3035
- ...batching === void 0 ? {} : { batching },
3657
+ ...batching === void 0 ? {} : { aggregateBatching: batching },
3036
3658
  retry: { attempts: 64 }
3037
- });
3659
+ }, { plugins: [plugin] });
3038
3660
  return {
3039
- db: created.database(module),
3040
- flush: created.flush
3661
+ db: engine.database(plugin),
3662
+ flush: engine.flush
3041
3663
  };
3042
3664
  };
3043
3665
  /** The reference: transactional writes on a memory adapter. */
3044
3666
  const expected = async (steps) => {
3045
3667
  const memory = createMemoryAdapter();
3046
- await memory.migrations?.apply(schema.tables);
3668
+ await memory.migrations?.apply(tables);
3047
3669
  const { db } = server(memory);
3048
3670
  for (let step = 0; step < steps; step += 1) await open(db, step);
3049
3671
  return snapshot(db);
@@ -3224,41 +3846,1056 @@ const mysqlRowsExamined = (query) => {
3224
3846
  //#endregion
3225
3847
  //#region src/createPluginTestHarness.ts
3226
3848
  /**
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.
3849
+ * Runs one plugin on `createEngine`, the storage engine `createHotUpdater`
3850
+ * runs its plugins on, so tests can call its API and measure what each call
3851
+ * reads. Every adapter call is checked against the adapter contract
3852
+ * (`verifyAdapter`), and `ctx.core` is empty: core's reads need a server.
3853
+ * The engine gets the adapter without `prune`, as a store that expires rows
3854
+ * itself would give it, so no retention pass adds to what a call reads and
3855
+ * writes; rows stay past their retention.
3229
3856
  */
3230
- const createPluginTestHarness = async (plugin, options) => {
3857
+ const createPluginTestHarness = async (plugin, options = {}) => {
3231
3858
  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
- });
3859
+ const core = new Set(coreSchema.tables.map(({ name }) => name));
3860
+ const tables = toolingTargetOf([plugin]).schema.tables.filter(({ name }) => !core.has(name));
3861
+ await adapter.migrations?.apply(tables);
3244
3862
  let clock = options.now ?? Date.now;
3245
- const db = engine.database(module);
3863
+ const now = () => clock();
3864
+ const database = meterReads({
3865
+ name: "plugin test harness",
3866
+ adapter: verifyAdapter(Object.assign(Object.create(adapter), { prune: void 0 }))
3867
+ });
3868
+ const db = createEngine(database, {
3869
+ plugins: [plugin],
3870
+ now
3871
+ }).database(plugin);
3246
3872
  const instance = plugin.init({
3247
3873
  db,
3248
3874
  core: {},
3249
- now: () => clock()
3875
+ now
3250
3876
  });
3251
3877
  return {
3252
3878
  api: instance.api,
3253
3879
  instance,
3254
- /** The plugin's own database handle; cast it to its `HotUpdaterDatabase` type. */
3880
+ /** The plugin's own database handle. */
3255
3881
  db,
3256
3882
  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;
3883
+ /** The plugin's physical tables. */
3884
+ tables,
3885
+ /** Reads at the adapter and at the engine while `read` runs. */
3886
+ measureReads: database.measureReads,
3887
+ setNow: (next) => {
3888
+ clock = next;
3889
+ }
3890
+ };
3891
+ };
3892
+ //#endregion
3893
+ //#region src/storeBundles.ts
3894
+ /**
3895
+ * Stores bundles and their patches with no release, as a database holds an
3896
+ * artifact that no release uses. `deploy` always adds a release, and
3897
+ * deleting a bundle's last release deletes the bundle, so tests store such
3898
+ * bundles through the engine. A patch's base comes before it in `bundles`.
3899
+ */
3900
+ const storeBundles = async (database, bundles) => {
3901
+ const engine = createEngine(database);
3902
+ try {
3903
+ await engine.core.transaction(async (tx) => {
3904
+ for (const bundle of bundles) {
3905
+ tx.create("bundles", bundleToRow(bundle));
3906
+ for (const platformKey of [bundle.platform, "*"]) tx.aggregate("bundle_totals", { platform_key: platformKey }, { bundles: 1 });
3907
+ for (const row of bundleToPatchRows(bundle)) tx.create("bundle_patches", row);
3908
+ }
3909
+ });
3910
+ } finally {
3911
+ await engine.dispose();
3912
+ }
3913
+ };
3914
+ //#endregion
3915
+ //#region src/insights/expectInsightsIndex.ts
3916
+ /** Allow committed secondary indexes to catch up; query failures still fail. */
3917
+ const expectInsightsIndex = async (query, expected) => {
3918
+ const deadline = Date.now() + 5e3;
3919
+ for (;;) {
3920
+ const actual = await query();
3921
+ try {
3922
+ expect(actual).toEqual(expected);
3923
+ return;
3924
+ } catch (error) {
3925
+ if (Date.now() >= deadline) throw error;
3926
+ }
3927
+ await new Promise((resolve) => setTimeout(resolve, 50));
3928
+ }
3929
+ };
3930
+ //#endregion
3931
+ //#region src/insights/fixtures.ts
3932
+ const fixtureId = (suffix) => `00000000-0000-7000-8000-${suffix.padStart(12, "0")}`;
3933
+ /** An applied update from install `suffix`, received at `receivedAtMs`. */
3934
+ const createBundleEventRowFixture = (suffix, receivedAtMs) => ({
3935
+ id: fixtureId(suffix),
3936
+ type: "UPDATE_APPLIED",
3937
+ install_id: `install-${suffix}`,
3938
+ user_id: null,
3939
+ metadata: {
3940
+ cohort: "0",
3941
+ update_strategy: "appVersion",
3942
+ fingerprint_hash: null,
3943
+ sdk_version: null
3944
+ },
3945
+ from_bundle_id: fixtureId(`${Number(suffix) + 1e3}`),
3946
+ from_release_id: null,
3947
+ to_bundle_id: fixtureId(`${Number(suffix) + 2e3}`),
3948
+ to_release_id: null,
3949
+ platform: "ios",
3950
+ app_version: "1.0.0",
3951
+ channel: "production",
3952
+ received_at_ms: receivedAtMs
3953
+ });
3954
+ //#endregion
3955
+ //#region src/insights/insightsModelTests.ts
3956
+ const record = (model, event) => model.recordEvent({ event });
3957
+ const createMovementEvent = (suffix, receivedAtMs, type, installId) => {
3958
+ const row = createBundleEventRowFixture(suffix, receivedAtMs);
3959
+ return {
3960
+ ...row,
3961
+ type,
3962
+ install_id: installId,
3963
+ from_bundle_id: row.to_bundle_id,
3964
+ metadata: {
3965
+ ...row.metadata,
3966
+ update_strategy: "appVersion"
3967
+ }
3968
+ };
3969
+ };
3970
+ /**
3971
+ * The Insights report contract on `InsightsModel`: what the Insights plugin
3972
+ * records and reads, through `createInsightsModel`, on one database.
3973
+ */
3974
+ const registerInsightsModelTests = (state) => {
3975
+ describe("Insights report contract", () => {
3976
+ it("preserves ancillary JSON through event history and latest-event reads", async () => {
3977
+ const model = state.getDatabase();
3978
+ const base = createBundleEventRowFixture("979", 100);
3979
+ const event = {
3980
+ ...base,
3981
+ metadata: {
3982
+ ...base.metadata,
3983
+ device: {
3984
+ locale: "ko-KR",
3985
+ tags: [
3986
+ "minor",
3987
+ null,
3988
+ true,
3989
+ 2
3990
+ ]
3991
+ },
3992
+ diagnostic: null
3993
+ }
3994
+ };
3995
+ await record(model, event);
3996
+ await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([event]);
3997
+ await expectInsightsIndex(() => model.listEvents({
3998
+ filter: { kind: "all" },
3999
+ beforeReceivedAtMs: 101,
4000
+ limit: 10
4001
+ }), [event]);
4002
+ });
4003
+ it("selects the latest complete event across download, replacement, apply, and delayed reports", async () => {
4004
+ const model = state.getDatabase();
4005
+ const download = {
4006
+ ...createBundleEventRowFixture("980", 100),
4007
+ type: "UPDATE_DOWNLOADED",
4008
+ from_bundle_id: "00000000-0000-7000-8000-000000001980",
4009
+ metadata: {
4010
+ ...createBundleEventRowFixture("980", 100).metadata,
4011
+ update_strategy: "appVersion"
4012
+ },
4013
+ to_release_id: "00000000-0000-7000-8000-000000004980"
4014
+ };
4015
+ await record(model, download);
4016
+ await record(model, download);
4017
+ await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([download]);
4018
+ await expectInsightsIndex(() => model.listEvents({
4019
+ filter: {
4020
+ kind: "bundle",
4021
+ type: "UPDATE_DOWNLOADED",
4022
+ toBundleId: download.to_bundle_id,
4023
+ platform: "ios",
4024
+ channel: "production"
4025
+ },
4026
+ beforeReceivedAtMs: 200,
4027
+ limit: 10
4028
+ }), [download]);
4029
+ const replacement = {
4030
+ ...download,
4031
+ id: createBundleEventRowFixture("981", 110).id,
4032
+ received_at_ms: 110,
4033
+ to_bundle_id: "00000000-0000-7000-8000-000000002981"
4034
+ };
4035
+ await record(model, replacement);
4036
+ await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([replacement]);
4037
+ const applied = {
4038
+ ...replacement,
4039
+ type: "UPDATE_APPLIED",
4040
+ id: createBundleEventRowFixture("982", 120).id,
4041
+ received_at_ms: 120
4042
+ };
4043
+ await record(model, applied);
4044
+ await record(model, {
4045
+ ...download,
4046
+ id: createBundleEventRowFixture("983", 105).id,
4047
+ received_at_ms: 105
4048
+ });
4049
+ await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([applied]);
4050
+ await expectInsightsIndex(() => model.listEvents({
4051
+ filter: {
4052
+ kind: "installationMovement",
4053
+ installId: download.install_id
4054
+ },
4055
+ beforeReceivedAtMs: 200,
4056
+ limit: 10
4057
+ }), [
4058
+ applied,
4059
+ replacement,
4060
+ {
4061
+ ...download,
4062
+ id: createBundleEventRowFixture("983", 105).id,
4063
+ received_at_ms: 105
4064
+ },
4065
+ download
4066
+ ]);
4067
+ });
4068
+ it("treats duplicate IDs as complete no-ops, including changed retry payloads", async () => {
4069
+ const model = state.getDatabase();
4070
+ const event = createBundleEventRowFixture("901", 100);
4071
+ await Promise.all([
4072
+ record(model, event),
4073
+ record(model, event),
4074
+ record(model, event)
4075
+ ]);
4076
+ await record(model, event);
4077
+ const changed = {
4078
+ ...event,
4079
+ received_at_ms: 300,
4080
+ user_id: "changed-user"
4081
+ };
4082
+ await record(model, changed);
4083
+ const otherInstallation = {
4084
+ ...changed,
4085
+ install_id: "different-install"
4086
+ };
4087
+ await record(model, otherInstallation);
4088
+ await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([event]);
4089
+ await expect(model.findLatestEvents({ installId: otherInstallation.install_id })).resolves.toEqual([]);
4090
+ await expectInsightsIndex(() => model.listEvents({
4091
+ filter: { kind: "all" },
4092
+ beforeReceivedAtMs: 400,
4093
+ limit: 10
4094
+ }), [event]);
4095
+ });
4096
+ it("keeps all concurrent events and the greatest timestamp/ID state, including logout", async () => {
4097
+ const model = state.getDatabase();
4098
+ const older = {
4099
+ ...createBundleEventRowFixture("912", 100),
4100
+ install_id: "concurrent",
4101
+ user_id: "previous"
4102
+ };
4103
+ const tied = {
4104
+ ...createBundleEventRowFixture("910", 200),
4105
+ install_id: "concurrent",
4106
+ user_id: "previous"
4107
+ };
4108
+ const newest = {
4109
+ ...createBundleEventRowFixture("911", 200),
4110
+ install_id: "concurrent",
4111
+ user_id: null
4112
+ };
4113
+ await Promise.all([
4114
+ record(model, newest),
4115
+ record(model, older),
4116
+ record(model, tied)
4117
+ ]);
4118
+ await record(model, older);
4119
+ await expect(model.findLatestEvents({ installId: "concurrent" })).resolves.toEqual([newest]);
4120
+ await expect(model.findLatestEvents({
4121
+ userId: "previous",
4122
+ limit: 10
4123
+ })).resolves.toEqual([]);
4124
+ await expectInsightsIndex(() => model.listEvents({
4125
+ filter: { kind: "all" },
4126
+ beforeReceivedAtMs: 201,
4127
+ limit: 10
4128
+ }), [
4129
+ newest,
4130
+ tied,
4131
+ older
4132
+ ]);
4133
+ });
4134
+ it("rejects invalid event metadata before persisting a report", async () => {
4135
+ const model = state.getDatabase();
4136
+ const event = createBundleEventRowFixture("920", 100);
4137
+ await expect(model.recordEvent({ event: {
4138
+ ...event,
4139
+ metadata: {
4140
+ ...event.metadata,
4141
+ cohort: 123
4142
+ }
4143
+ } })).rejects.toMatchObject({ code: "invalid-data" });
4144
+ await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([]);
4145
+ await expect(model.listEvents({
4146
+ filter: { kind: "all" },
4147
+ beforeReceivedAtMs: 200,
4148
+ limit: 10
4149
+ })).resolves.toEqual([]);
4150
+ });
4151
+ it("shares scoped list/count predicates, recovery attribution, and half-open time bounds", async () => {
4152
+ const model = state.getDatabase();
4153
+ const bundleA = createBundleEventRowFixture("1", 0).to_bundle_id;
4154
+ const bundleB = createBundleEventRowFixture("2", 0).to_bundle_id;
4155
+ const bundleC = createBundleEventRowFixture("3", 0).to_bundle_id;
4156
+ const applied = {
4157
+ ...createBundleEventRowFixture("930", 100),
4158
+ install_id: "target",
4159
+ to_bundle_id: bundleB
4160
+ };
4161
+ const recovered = {
4162
+ ...createBundleEventRowFixture("931", 120),
4163
+ install_id: "target",
4164
+ type: "RECOVERED",
4165
+ from_bundle_id: bundleB,
4166
+ to_bundle_id: bundleA,
4167
+ metadata: {
4168
+ ...createBundleEventRowFixture("931", 120).metadata,
4169
+ update_strategy: "appVersion"
4170
+ }
4171
+ };
4172
+ const unchanged = {
4173
+ ...createBundleEventRowFixture("932", 130),
4174
+ type: "UNCHANGED",
4175
+ from_bundle_id: null,
4176
+ to_bundle_id: bundleB,
4177
+ metadata: {
4178
+ ...createBundleEventRowFixture("932", 130).metadata,
4179
+ update_strategy: null
4180
+ }
4181
+ };
4182
+ const excluded = [
4183
+ {
4184
+ ...applied,
4185
+ id: createBundleEventRowFixture("933", 99).id,
4186
+ install_id: "before",
4187
+ received_at_ms: 99
4188
+ },
4189
+ {
4190
+ ...applied,
4191
+ id: createBundleEventRowFixture("934", 200).id,
4192
+ install_id: "after",
4193
+ received_at_ms: 200
4194
+ },
4195
+ {
4196
+ ...applied,
4197
+ id: createBundleEventRowFixture("935", 110).id,
4198
+ install_id: "android",
4199
+ platform: "android"
4200
+ },
4201
+ {
4202
+ ...applied,
4203
+ id: createBundleEventRowFixture("936", 110).id,
4204
+ install_id: "preview",
4205
+ channel: "preview"
4206
+ },
4207
+ {
4208
+ ...recovered,
4209
+ id: createBundleEventRowFixture("937", 125).id,
4210
+ install_id: "other-source",
4211
+ from_bundle_id: bundleC,
4212
+ to_bundle_id: bundleB
4213
+ }
4214
+ ];
4215
+ for (const row of [
4216
+ applied,
4217
+ recovered,
4218
+ unchanged,
4219
+ ...excluded
4220
+ ]) await record(model, row);
4221
+ await expect(async () => record(model, {
4222
+ ...applied,
4223
+ type: "RELEASE_ADOPTED"
4224
+ })).rejects.toThrow();
4225
+ await expect(async () => record(model, {
4226
+ ...applied,
4227
+ type: "UNCHANGED",
4228
+ from_bundle_id: bundleB,
4229
+ metadata: {
4230
+ ...applied.metadata,
4231
+ update_strategy: "appVersion"
4232
+ }
4233
+ })).rejects.toThrow();
4234
+ await expect(model.findLatestEvents({ installId: "target" })).resolves.toEqual([recovered]);
4235
+ const cases = [[{
4236
+ platform: "ios",
4237
+ channel: "production",
4238
+ type: "UPDATE_APPLIED",
4239
+ toBundleId: bundleB
4240
+ }, applied], [{
4241
+ platform: "ios",
4242
+ channel: "production",
4243
+ type: "RECOVERED",
4244
+ fromBundleId: bundleB
4245
+ }, recovered]];
4246
+ for (const [filter, expected] of cases) {
4247
+ await expectInsightsIndex(() => model.countEvents({
4248
+ filter,
4249
+ sinceMs: 100,
4250
+ beforeReceivedAtMs: 200
4251
+ }), 1);
4252
+ await expectInsightsIndex(() => model.listEvents({
4253
+ filter: {
4254
+ kind: "bundle",
4255
+ ...filter
4256
+ },
4257
+ sinceMs: 100,
4258
+ beforeReceivedAtMs: 200,
4259
+ limit: 1
4260
+ }), [expected]);
4261
+ }
4262
+ await expect(model.findLatestEvents({ installId: unchanged.install_id })).resolves.toEqual([unchanged]);
4263
+ await expectInsightsIndex(() => model.listEvents({
4264
+ filter: { kind: "all" },
4265
+ sinceMs: 100,
4266
+ beforeReceivedAtMs: 200,
4267
+ limit: 10
4268
+ }), [
4269
+ excluded[4],
4270
+ recovered,
4271
+ excluded[3],
4272
+ excluded[2],
4273
+ applied
4274
+ ]);
4275
+ await expect(model.countEvents({
4276
+ filter: {
4277
+ platform: "ios",
4278
+ channel: "production",
4279
+ type: "UNCHANGED",
4280
+ toBundleId: bundleB
4281
+ },
4282
+ sinceMs: 100,
4283
+ beforeReceivedAtMs: 200
4284
+ })).rejects.toThrow();
4285
+ await expectInsightsIndex(() => model.countLatestEvents({
4286
+ platform: "ios",
4287
+ channel: "production",
4288
+ sinceMs: 0,
4289
+ bundle: [{
4290
+ field: "to_bundle_id",
4291
+ value: bundleA,
4292
+ types: [
4293
+ "UNCHANGED",
4294
+ "UPDATE_APPLIED",
4295
+ "RECOVERED"
4296
+ ]
4297
+ }]
4298
+ }), 1);
4299
+ await expectInsightsIndex(() => model.countLatestEvents({
4300
+ platform: "ios",
4301
+ channel: "production",
4302
+ sinceMs: 0
4303
+ }), 5);
4304
+ await expect(model.countLatestEvents({
4305
+ platform: "ios",
4306
+ channel: "production",
4307
+ sinceMs: 100
4308
+ })).rejects.toThrow();
4309
+ });
4310
+ it("uses exact identity and UTF-8 cursor order for user installations", async () => {
4311
+ const model = state.getDatabase();
4312
+ const ids = [
4313
+ "Install-a",
4314
+ "é",
4315
+ "install-a",
4316
+ "install-a ",
4317
+ "é",
4318
+ "",
4319
+ "😀"
4320
+ ];
4321
+ const events = ids.map((install_id, index) => ({
4322
+ ...createBundleEventRowFixture(String(950 + index), 100),
4323
+ install_id,
4324
+ user_id: "User-é"
4325
+ }));
4326
+ for (const event of events.toReversed()) await record(model, event);
4327
+ const spacedUser = {
4328
+ ...createBundleEventRowFixture("960", 100),
4329
+ install_id: "spaced-user-install",
4330
+ user_id: "User-é "
4331
+ };
4332
+ await record(model, spacedUser);
4333
+ await expectInsightsIndex(async () => {
4334
+ const found = [];
4335
+ let afterInstallId;
4336
+ for (let pageIndex = 0; pageIndex <= ids.length; pageIndex++) {
4337
+ const page = await model.findLatestEvents({
4338
+ userId: "User-é",
4339
+ afterInstallId,
4340
+ limit: 2
4341
+ });
4342
+ found.push(...page.map((row) => row.install_id));
4343
+ if (page.length < 2) return found;
4344
+ const nextCursor = page[page.length - 1].install_id;
4345
+ expect(nextCursor, "Insights cursor must advance").not.toBe(afterInstallId);
4346
+ afterInstallId = nextCursor;
4347
+ }
4348
+ throw new Error("Insights pagination did not terminate");
4349
+ }, ids);
4350
+ await expectInsightsIndex(() => model.findLatestEvents({
4351
+ userId: "User-é ",
4352
+ limit: 10
4353
+ }), [spacedUser]);
4354
+ await expect(model.findLatestEvents({
4355
+ userId: "user-é",
4356
+ limit: 10
4357
+ })).resolves.toEqual([]);
4358
+ await expect(model.findLatestEvents({ installId: "INSTALL-a" })).resolves.toEqual([]);
4359
+ await expect(model.findLatestEvents({ installId: "Install-a" })).resolves.toEqual([events[0]]);
4360
+ await expect(model.findLatestEvents({ installId: "install-a " })).resolves.toEqual([events[3]]);
4361
+ await expect(model.findLatestEvents({ installId: "install-a" })).resolves.toEqual([events[2]]);
4362
+ });
4363
+ it("counts overlapping bundle predicates once per latest installation", async () => {
4364
+ const model = state.getDatabase();
4365
+ const event = createBundleEventRowFixture("9700", 100);
4366
+ await record(model, event);
4367
+ const predicate = {
4368
+ field: "to_bundle_id",
4369
+ value: event.to_bundle_id,
4370
+ types: [event.type]
4371
+ };
4372
+ await expectInsightsIndex(() => model.countLatestEvents({
4373
+ platform: event.platform,
4374
+ channel: event.channel,
4375
+ sinceMs: 0,
4376
+ bundle: [predicate, predicate]
4377
+ }), 1);
4378
+ });
4379
+ it("counts a latest installation once when a from and a to predicate both match it", async () => {
4380
+ const model = state.getDatabase();
4381
+ const moved = createBundleEventRowFixture("9701", 100);
4382
+ await record(model, moved);
4383
+ await expectInsightsIndex(() => model.countLatestEvents({
4384
+ platform: moved.platform,
4385
+ channel: moved.channel,
4386
+ sinceMs: 0,
4387
+ bundle: [{
4388
+ field: "from_bundle_id",
4389
+ value: moved.from_bundle_id,
4390
+ types: [moved.type]
4391
+ }, {
4392
+ field: "to_bundle_id",
4393
+ value: moved.to_bundle_id,
4394
+ types: [moved.type]
4395
+ }]
4396
+ }), 1);
4397
+ });
4398
+ it("returns zero for successful empty scalar queries", async () => {
4399
+ const model = state.getDatabase();
4400
+ await expect(model.countLatestEvents({
4401
+ platform: "ios",
4402
+ channel: "production",
4403
+ sinceMs: 0
4404
+ })).resolves.toBe(0);
4405
+ await expect(model.countEvents({
4406
+ filter: {
4407
+ platform: "ios",
4408
+ channel: "production",
4409
+ type: "RECOVERED",
4410
+ fromBundleId: createBundleEventRowFixture("1", 0).to_bundle_id
4411
+ },
4412
+ sinceMs: 0,
4413
+ beforeReceivedAtMs: 100
4414
+ })).resolves.toBe(0);
4415
+ });
4416
+ it("maintains idempotent release, scope, usage, and latest-distribution summaries", async () => {
4417
+ const model = state.getDatabase();
4418
+ const releaseA = "00000000-0000-7000-8000-000000008001";
4419
+ const releaseB = "00000000-0000-7000-8000-000000008002";
4420
+ const download = {
4421
+ ...createBundleEventRowFixture("9801", 1e3),
4422
+ type: "UPDATE_DOWNLOADED",
4423
+ install_id: "summary-install-a",
4424
+ from_release_id: releaseA,
4425
+ to_release_id: releaseB
4426
+ };
4427
+ const appliedA = {
4428
+ ...download,
4429
+ id: createBundleEventRowFixture("9802", 2e3).id,
4430
+ type: "UPDATE_APPLIED",
4431
+ received_at_ms: 2e3
4432
+ };
4433
+ const appliedB = {
4434
+ ...appliedA,
4435
+ id: createBundleEventRowFixture("9803", 2100).id,
4436
+ install_id: "summary-install-b",
4437
+ received_at_ms: 2100
4438
+ };
4439
+ const recovered = {
4440
+ ...appliedA,
4441
+ id: createBundleEventRowFixture("9804", 3e3).id,
4442
+ type: "RECOVERED",
4443
+ from_release_id: releaseB,
4444
+ to_release_id: releaseA,
4445
+ received_at_ms: 3e3
4446
+ };
4447
+ for (const event of [
4448
+ download,
4449
+ appliedA,
4450
+ appliedB,
4451
+ appliedB,
4452
+ recovered
4453
+ ]) await record(model, event);
4454
+ const releases = [{
4455
+ releaseId: releaseA,
4456
+ platform: "ios",
4457
+ channel: "production"
4458
+ }, {
4459
+ releaseId: releaseB,
4460
+ platform: "ios",
4461
+ channel: "production"
4462
+ }];
4463
+ const lifetime = await model.getReleaseActivity({ releases });
4464
+ expect(lifetime.data.map(({ metrics }) => metrics)).toEqual([{
4465
+ downloads: 0,
4466
+ launches: 1,
4467
+ failedLaunches: 0
4468
+ }, {
4469
+ downloads: 1,
4470
+ launches: 2,
4471
+ failedLaunches: 1
4472
+ }]);
4473
+ const scope = await model.getReleaseActivity({
4474
+ scope: {
4475
+ platform: "ios",
4476
+ channel: "production"
4477
+ },
4478
+ timeRange: {
4479
+ start: 0,
4480
+ end: 36e5
4481
+ }
4482
+ });
4483
+ expect(scope.data[0]?.metrics).toMatchObject({
4484
+ downloads: 1,
4485
+ launches: 3,
4486
+ failedLaunches: 1,
4487
+ uniqueUsers: 2
4488
+ });
4489
+ const usage = await model.getAppUsage({
4490
+ channel: "production",
4491
+ platform: "all",
4492
+ timeRange: {
4493
+ start: 0,
4494
+ end: 36e5
4495
+ },
4496
+ intervalMs: 36e5
4497
+ });
4498
+ expect(usage.activeInstallations).toBe(2);
4499
+ expect(usage.points).toEqual([{
4500
+ startMs: 0,
4501
+ installations: 2
4502
+ }]);
4503
+ expect(usage.bundleDistribution).toEqual(expect.arrayContaining([{
4504
+ appVersion: "1.0.0",
4505
+ platform: "ios",
4506
+ releaseId: releaseA,
4507
+ installations: 1
4508
+ }, {
4509
+ appVersion: "1.0.0",
4510
+ platform: "ios",
4511
+ releaseId: releaseB,
4512
+ installations: 1
4513
+ }]));
4514
+ });
4515
+ it("pages insights events newest first with a stable cursor", async () => {
4516
+ const model = state.getDatabase();
4517
+ const first = createBundleEventRowFixture("701", 100);
4518
+ const second = createBundleEventRowFixture("702", 100);
4519
+ const third = createBundleEventRowFixture("703", 200);
4520
+ await model.recordEvent({ event: third });
4521
+ await model.recordEvent({ event: second });
4522
+ await model.recordEvent({ event: first });
4523
+ await expectInsightsIndex(() => model.listEvents({
4524
+ filter: { kind: "all" },
4525
+ beforeReceivedAtMs: 201,
4526
+ limit: 2
4527
+ }), [third, second]);
4528
+ await expectInsightsIndex(() => model.listEvents({
4529
+ filter: { kind: "all" },
4530
+ after: {
4531
+ receivedAtMs: second.received_at_ms,
4532
+ id: second.id
4533
+ },
4534
+ beforeReceivedAtMs: 201,
4535
+ limit: 2
4536
+ }), [first]);
4537
+ await expectInsightsIndex(() => model.listEvents({
4538
+ filter: { kind: "all" },
4539
+ beforeReceivedAtMs: 200,
4540
+ limit: 10
4541
+ }), [second, first]);
4542
+ });
4543
+ it("filters installation movements before applying the page limit", async () => {
4544
+ const model = state.getDatabase();
4545
+ const unchanged = {
4546
+ ...createBundleEventRowFixture("711", 300),
4547
+ type: "UNCHANGED",
4548
+ install_id: "install-target",
4549
+ from_bundle_id: null,
4550
+ metadata: {
4551
+ ...createBundleEventRowFixture("711", 300).metadata,
4552
+ update_strategy: null
4553
+ }
4554
+ };
4555
+ const unrelated = createMovementEvent("712", 250, "UPDATE_APPLIED", "install-other");
4556
+ const applied = createMovementEvent("713", 200, "UPDATE_APPLIED", "install-target");
4557
+ const recovered = createMovementEvent("714", 100, "RECOVERED", "install-target");
4558
+ for (const row of [
4559
+ unchanged,
4560
+ unrelated,
4561
+ applied,
4562
+ recovered
4563
+ ]) await model.recordEvent({ event: row });
4564
+ await expectInsightsIndex(() => model.listEvents({
4565
+ filter: {
4566
+ kind: "installationMovement",
4567
+ installId: "install-target"
4568
+ },
4569
+ beforeReceivedAtMs: 301,
4570
+ limit: 1
4571
+ }), [applied]);
4572
+ await expectInsightsIndex(() => model.listEvents({
4573
+ filter: {
4574
+ kind: "installationMovement",
4575
+ installId: "install-target"
4576
+ },
4577
+ after: {
4578
+ receivedAtMs: applied.received_at_ms,
4579
+ id: applied.id
4580
+ },
4581
+ beforeReceivedAtMs: 301,
4582
+ limit: 1
4583
+ }), [recovered]);
4584
+ });
4585
+ 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 () => {
4586
+ const model = state.getDatabase();
4587
+ const applied = createMovementEvent("721", 100, "UPDATE_APPLIED", "install-failing");
4588
+ const failure = (suffix, receivedAtMs, failed) => ({
4589
+ ...applied,
4590
+ id: createBundleEventRowFixture(suffix, 0).id,
4591
+ type: "UPDATE_FAILED",
4592
+ from_bundle_id: applied.to_bundle_id,
4593
+ to_bundle_id: failed.stage === "check" ? applied.to_bundle_id : createBundleEventRowFixture("729", 0).to_bundle_id,
4594
+ metadata: {
4595
+ ...applied.metadata,
4596
+ failure: failed
4597
+ },
4598
+ received_at_ms: receivedAtMs
4599
+ });
4600
+ const download = failure("722", 200, {
4601
+ stage: "download",
4602
+ reason: "http",
4603
+ http_status: 403,
4604
+ origin_code: "AccessDenied"
4605
+ });
4606
+ const check = failure("723", 300, {
4607
+ stage: "check",
4608
+ reason: "http"
4609
+ });
4610
+ for (const event of [
4611
+ applied,
4612
+ download,
4613
+ check
4614
+ ]) await record(model, event);
4615
+ await expect(model.findLatestEvents({ installId: "install-failing" })).resolves.toEqual([applied]);
4616
+ await expectInsightsIndex(() => model.listEvents({
4617
+ filter: {
4618
+ kind: "installationMovement",
4619
+ installId: "install-failing"
4620
+ },
4621
+ beforeReceivedAtMs: 301,
4622
+ limit: 10
4623
+ }), [
4624
+ check,
4625
+ download,
4626
+ applied
4627
+ ]);
4628
+ await expectInsightsIndex(() => model.listEvents({
4629
+ filter: { kind: "all" },
4630
+ beforeReceivedAtMs: 301,
4631
+ limit: 10
4632
+ }), [
4633
+ check,
4634
+ download,
4635
+ applied
4636
+ ]);
4637
+ const scope = {
4638
+ platform: "ios",
4639
+ channel: "production"
4640
+ };
4641
+ for (const [toBundleId, listed] of [[download.to_bundle_id, [download]], [check.to_bundle_id, []]]) {
4642
+ const filter = {
4643
+ ...scope,
4644
+ type: "UPDATE_FAILED",
4645
+ toBundleId
4646
+ };
4647
+ await expectInsightsIndex(() => model.listEvents({
4648
+ filter: {
4649
+ kind: "bundle",
4650
+ ...filter
4651
+ },
4652
+ beforeReceivedAtMs: 301,
4653
+ limit: 10
4654
+ }), listed);
4655
+ await expect(model.countEvents({
4656
+ filter,
4657
+ sinceMs: 0,
4658
+ beforeReceivedAtMs: 301
4659
+ })).resolves.toBe(listed.length);
4660
+ }
4661
+ });
4662
+ });
4663
+ };
4664
+ //#endregion
4665
+ //#region src/insights/setupInsightsHttpTestSuite.ts
4666
+ const jsonRequest$1 = (method, body) => ({
4667
+ method,
4668
+ headers: { "content-type": "application/json" },
4669
+ body: JSON.stringify(body)
4670
+ });
4671
+ /**
4672
+ * The Insights plugin's routes on a database: a client reports an event, and
4673
+ * the admin routes read it back. The server must run `insights()`.
4674
+ */
4675
+ const setupInsightsHttpTestSuite = (options) => {
4676
+ describe("Insights HTTP routes", () => {
4677
+ it("records a reported event and reads it back", async () => {
4678
+ const client = options.getClient();
4679
+ const installId = `install-${crypto.randomUUID()}`;
4680
+ const userId = `user-${crypto.randomUUID()}`;
4681
+ const channel = `insights-${crypto.randomUUID()}`;
4682
+ const event = {
4683
+ appVersion: "1.0.0",
4684
+ channel,
4685
+ cohort: "default",
4686
+ fingerprintHash: null,
4687
+ fromBundleId: null,
4688
+ fromReleaseId: null,
4689
+ installId,
4690
+ platform: "ios",
4691
+ toBundleId: "00000000-0000-7000-8000-000000000001",
4692
+ toReleaseId: null,
4693
+ type: "UNCHANGED",
4694
+ updateStrategy: null,
4695
+ userId,
4696
+ sdkVersion: "2.0.0"
4697
+ };
4698
+ const reported = await client.client("/events", jsonRequest$1("POST", event));
4699
+ expect(reported.status).toBe(204);
4700
+ await reported.text();
4701
+ const installation = await client.admin(`/installations/${encodeURIComponent(installId)}`);
4702
+ expect(installation.status).toBe(200);
4703
+ expect(await installation.json()).toMatchObject({
4704
+ installId,
4705
+ latestStatus: "UNCHANGED",
4706
+ lastKnownBundleId: event.toBundleId,
4707
+ channel
4708
+ });
4709
+ await expectInsightsIndex(async () => {
4710
+ return (await (await client.admin(`/installations?userId=${encodeURIComponent(userId)}`)).json()).data.map(({ installId: id }) => id);
4711
+ }, [installId]);
4712
+ await expectInsightsIndex(async () => {
4713
+ return (await (await client.admin(`/overview?platform=ios&channel=${encodeURIComponent(channel)}&window=24h`)).json()).reportingInstallations.count;
4714
+ }, 1);
4715
+ });
4716
+ it("records an update failure in installation history and the failures read", async () => {
4717
+ const client = options.getClient();
4718
+ const installId = `install-${crypto.randomUUID()}`;
4719
+ const channel = `insights-${crypto.randomUUID()}`;
4720
+ const releaseId = "00000000-0000-7000-8000-0000000000b2";
4721
+ const reported = await client.client("/events", jsonRequest$1("POST", {
4722
+ appVersion: "1.0.0",
4723
+ channel,
4724
+ cohort: "default",
4725
+ fingerprintHash: null,
4726
+ fromBundleId: "00000000-0000-7000-8000-000000000001",
4727
+ fromReleaseId: null,
4728
+ installId,
4729
+ platform: "ios",
4730
+ toBundleId: "00000000-0000-7000-8000-000000000002",
4731
+ toReleaseId: releaseId,
4732
+ type: "UPDATE_FAILED",
4733
+ updateStrategy: "appVersion",
4734
+ metadata: { failure: {
4735
+ stage: "download",
4736
+ reason: "http",
4737
+ httpStatus: 403
4738
+ } }
4739
+ }));
4740
+ expect(reported.status).toBe(204);
4741
+ await reported.text();
4742
+ await expectInsightsIndex(async () => {
4743
+ return (await (await client.admin(`/installations/${encodeURIComponent(installId)}/events?limit=10`)).json()).data.map(({ type, failure }) => ({
4744
+ type,
4745
+ failure
4746
+ }));
4747
+ }, [{
4748
+ type: "UPDATE_FAILED",
4749
+ failure: {
4750
+ stage: "download",
4751
+ reason: "http",
4752
+ httpStatus: 403
4753
+ }
4754
+ }]);
4755
+ const now = Date.now();
4756
+ await expectInsightsIndex(async () => {
4757
+ const body = await (await client.admin(`/failures?platform=ios&channel=${encodeURIComponent(channel)}&releaseId=${releaseId}&start=${now - 864e5}&end=${now + 36e5}`)).json();
4758
+ return [body.failedUpdates, body.failedInstallations];
4759
+ }, [1, 1]);
4760
+ });
4761
+ it("refuses a malformed event", async () => {
4762
+ const response = await options.getClient().client("/events", jsonRequest$1("POST", { type: "UNCHANGED" }));
4763
+ expect(response.status).toBe(400);
4764
+ await response.text();
4765
+ });
4766
+ });
4767
+ };
4768
+ //#endregion
4769
+ //#region src/insights/setupInsightsModelTestSuite.ts
4770
+ /**
4771
+ * The Insights report contract on one database: pass the Insights plugin's
4772
+ * model over it, `createInsightsModel(createHotUpdater({ database, plugins:
4773
+ * [insights()], clientAccess: "public" }).api.insights)`, as `createDatabase`.
4774
+ */
4775
+ const setupInsightsModelTestSuite = (lifecycle) => {
4776
+ describe(lifecycle.name, () => {
4777
+ let model;
4778
+ const getDatabase = () => {
4779
+ if (model === void 0) throw new Error("The model is unavailable outside the test lifecycle");
4780
+ return model;
4781
+ };
4782
+ beforeAll(async () => {
4783
+ await lifecycle.migrate();
4784
+ model = await lifecycle.createDatabase();
4785
+ });
4786
+ beforeEach(async () => {
4787
+ await lifecycle.reset(getDatabase());
4788
+ });
4789
+ afterAll(async () => {
4790
+ await lifecycle.dispose(getDatabase());
4791
+ model = void 0;
4792
+ });
4793
+ registerInsightsModelTests({ getDatabase });
4794
+ });
4795
+ };
4796
+ //#endregion
4797
+ //#region src/insights/index.ts
4798
+ /**
4799
+ * The Insights plugin's tests, for `setupDatabaseTestSuite`'s `plugins`: its
4800
+ * HTTP routes on the provider's server, which must run `insights()`, and
4801
+ * with `createModel` its report contract on the provider's database, such as
4802
+ * `(database) => createInsightsModel(createHotUpdater({ database, plugins:
4803
+ * [insights()], clientAccess: "public" }).api.insights)`.
4804
+ */
4805
+ const insightsTestSuite = (options = {}) => ({
4806
+ name: "Insights plugin",
4807
+ register: (context) => {
4808
+ setupInsightsHttpTestSuite({ getClient: context.getClient });
4809
+ const { createModel } = options;
4810
+ if (createModel !== void 0) describe("Insights model", () => {
4811
+ registerInsightsModelTests({ getDatabase: () => createModel(context.getDatabase()) });
4812
+ });
4813
+ }
4814
+ });
4815
+ //#endregion
4816
+ //#region src/kvTestStore.ts
4817
+ /**
4818
+ * An in-memory key-value store that behaves like DynamoDB where the adapter
4819
+ * can tell: a write checks every condition against the state before it,
4820
+ * refuses two ops on one item, and applies all ops or none.
4821
+ */
4822
+ const createMemoryKeyValueStore = (options = {}) => {
4823
+ let partitions = /* @__PURE__ */ new Map();
4824
+ let expiries = /* @__PURE__ */ new Map();
4825
+ const read = (key) => partitions.get(key.pk)?.get(key.sk) ?? null;
4826
+ const holds = (key, condition) => {
4827
+ if (condition === void 0) return true;
4828
+ const item = read(key);
4829
+ return "v" in condition ? item !== null && item[DATABASE_VERSION_COLUMN] === condition.v : item !== null === condition.exists;
4830
+ };
4831
+ return {
4832
+ id: "memory-kv",
4833
+ limits: options.limits ?? {
4834
+ items: 100,
4835
+ bytes: 4e6
4836
+ },
4837
+ items: () => [...partitions.values()].reduce((sum, items) => sum + items.size, 0),
4838
+ expiries: () => expiries,
4839
+ async get(keys) {
4840
+ return keys.map((key) => structuredClone(read(key)));
4841
+ },
4842
+ async query({ pk, gte, lt, order, limit, after }) {
4843
+ const direction = order === "asc" ? 1 : -1;
4844
+ 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));
4845
+ const size = Math.min(limit, options.nativePageSize ?? limit);
4846
+ return {
4847
+ items: matching.slice(0, size).map(([sk, value]) => ({
4848
+ sk,
4849
+ value: structuredClone(value)
4850
+ })),
4851
+ more: matching.length > size
4852
+ };
4853
+ },
4854
+ async write(ops) {
4855
+ if (new Set(ops.map(({ key }) => JSON.stringify(key))).size !== ops.length) throw new Error("A write may touch each item once.");
4856
+ const failed = ops.findIndex((op) => !holds(op.key, op.condition));
4857
+ if (failed !== -1) return {
4858
+ ok: false,
4859
+ failedOp: failed
4860
+ };
4861
+ const next = new Map(partitions);
4862
+ const stamped = new Map(expiries);
4863
+ for (const op of ops) {
4864
+ if (op.type === "check") continue;
4865
+ const items = new Map(next.get(op.key.pk));
4866
+ next.set(op.key.pk, items);
4867
+ const id = JSON.stringify([op.key.pk, op.key.sk]);
4868
+ if (op.type === "delete") {
4869
+ items.delete(op.key.sk);
4870
+ stamped.delete(id);
4871
+ } else if (op.type === "put") {
4872
+ items.set(op.key.sk, structuredClone(op.value));
4873
+ if (op.expiresAt === void 0) stamped.delete(id);
4874
+ else stamped.set(id, op.expiresAt);
4875
+ } else {
4876
+ if (!items.has(op.key.sk) && op.expiresAt !== void 0) stamped.set(id, op.expiresAt);
4877
+ const item = structuredClone(items.get(op.key.sk) ?? op.init ?? {});
4878
+ for (const [name, delta] of Object.entries(op.by)) item[name] = Number(item[name] ?? 0) + delta;
4879
+ items.set(op.key.sk, item);
4880
+ }
4881
+ }
4882
+ partitions = next;
4883
+ expiries = stamped;
4884
+ return { ok: true };
4885
+ },
4886
+ async deleteConsumed(keys) {
4887
+ const next = new Map(partitions);
4888
+ for (const { pk, sk } of keys) {
4889
+ const items = new Map(next.get(pk));
4890
+ items.delete(sk);
4891
+ next.set(pk, items);
4892
+ expiries.delete(JSON.stringify([pk, sk]));
4893
+ }
4894
+ partitions = next;
4895
+ },
4896
+ async dispose() {
4897
+ partitions = /* @__PURE__ */ new Map();
4898
+ expiries = /* @__PURE__ */ new Map();
3262
4899
  }
3263
4900
  };
3264
4901
  };
@@ -3332,4 +4969,4 @@ async function startHttpTestServer(handlers) {
3332
4969
  };
3333
4970
  }
3334
4971
  //#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 };
4972
+ 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 };