@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/README.md +93 -14
- package/dist/index.d.mts +176 -145
- package/dist/index.mjs +1690 -60
- package/dist/node.cjs +210 -0
- package/dist/node.d.cts +49 -4
- package/dist/node.d.mts +49 -4
- package/dist/node.mjs +203 -1
- package/dist/react-native.cjs +177 -0
- package/dist/react-native.d.cts +104 -0
- package/dist/react-native.d.mts +104 -0
- package/dist/react-native.mjs +174 -0
- package/package.json +44 -6
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/
|
|
3
|
-
import { DatabaseAdapterContractError, addDistinct, countDistinct, createMemoryAdapter, findPhysicalIndex,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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) =>
|
|
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
|
|
2298
|
-
const
|
|
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:
|
|
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
|
|
2788
|
-
* plugins
|
|
2789
|
-
* two rows. Every API in the read-budget list reads
|
|
2790
|
-
* adapter and at the engine; with `examined`, the
|
|
2791
|
-
* rows than the adapter read, within the backend's
|
|
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:
|
|
3410
|
+
tables: [...toolingTargetOf(plugins).schema.tables, SETTINGS_TABLE],
|
|
2802
3411
|
nativePageSize: NATIVE_PAGE_SIZE
|
|
2803
3412
|
});
|
|
2804
3413
|
const { adapter } = created;
|
|
2805
|
-
database =
|
|
3414
|
+
database = await createMeasuredDatabase({
|
|
2806
3415
|
...adapter,
|
|
2807
3416
|
write: (ops) => {
|
|
2808
3417
|
writes += 1;
|
|
2809
3418
|
return adapter.write(ops);
|
|
2810
3419
|
}
|
|
2811
|
-
},
|
|
3420
|
+
}, plugins, {
|
|
2812
3421
|
now: () => T0 + 10 * DAY,
|
|
2813
3422
|
storage
|
|
2814
3423
|
});
|
|
2815
|
-
seeded = await seed(database
|
|
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
|
-
/**
|
|
2905
|
-
|
|
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,
|
|
3023
|
-
const
|
|
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(
|
|
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
|
|
3647
|
+
const engine = createEngine({
|
|
3648
|
+
name,
|
|
3033
3649
|
adapter,
|
|
3034
|
-
|
|
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:
|
|
3040
|
-
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(
|
|
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
|
|
3228
|
-
*
|
|
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
|
|
3233
|
-
|
|
3234
|
-
|
|
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
|
|
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
|
|
3868
|
+
now
|
|
3250
3869
|
});
|
|
3251
3870
|
return {
|
|
3252
3871
|
api: instance.api,
|
|
3253
3872
|
instance,
|
|
3254
|
-
/** The plugin's own database handle
|
|
3873
|
+
/** The plugin's own database handle. */
|
|
3255
3874
|
db,
|
|
3256
3875
|
adapter,
|
|
3257
|
-
tables
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
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 };
|