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