@hot-updater/standalone 1.0.0-rc.2 → 1.0.0-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,226 +1,4 @@
1
- import { bundleToPatchRows, bundleToRow, createStoragePlugin, parseStorageUri } from "@hot-updater/plugin-core";
2
- //#region src/standaloneBundleRows.ts
3
- async function loadRows(remote, model) {
4
- return model === "bundles" ? remote.loadBundleRows() : (await remote.loadBundles()).flatMap(bundleToPatchRows);
5
- }
6
- //#endregion
7
- //#region src/standaloneDatabaseQuery.ts
8
- const compare$1 = (left, right) => {
9
- if (typeof left === "number" && typeof right === "number") return left - right;
10
- return String(left).localeCompare(String(right));
11
- };
12
- const compareString = (actual, expected, mode, predicate) => {
13
- if (typeof actual !== "string") return false;
14
- return mode === "insensitive" ? predicate(actual.toLocaleLowerCase(), expected.toLocaleLowerCase()) : predicate(actual, expected);
15
- };
16
- const matchesCondition = (row, condition) => {
17
- const actual = Reflect.get(row, condition.field);
18
- const expected = Reflect.get(condition, "value");
19
- switch (Reflect.get(condition, "operator") ?? "eq") {
20
- case "eq":
21
- if (typeof expected === "string") return compareString(actual, expected, Reflect.get(condition, "mode"), (value, query) => value === query);
22
- return actual === expected;
23
- case "ne":
24
- if (actual === null || actual === void 0) return false;
25
- if (typeof expected === "string") return !compareString(actual, expected, Reflect.get(condition, "mode"), (value, query) => value === query);
26
- return actual !== expected;
27
- case "gt":
28
- if (actual === null || actual === void 0) return false;
29
- return compare$1(actual, expected) > 0;
30
- case "gte":
31
- if (actual === null || actual === void 0) return false;
32
- return compare$1(actual, expected) >= 0;
33
- case "lt":
34
- if (actual === null || actual === void 0) return false;
35
- return compare$1(actual, expected) < 0;
36
- case "lte":
37
- if (actual === null || actual === void 0) return false;
38
- return compare$1(actual, expected) <= 0;
39
- case "in": return Array.isArray(expected) && expected.some((candidate) => candidate === actual);
40
- case "not_in": return Array.isArray(expected) && (expected.length === 0 || actual !== null && actual !== void 0 && expected.every((candidate) => candidate !== actual));
41
- case "contains": return typeof expected === "string" ? compareString(actual, expected, Reflect.get(condition, "mode"), (value, query) => value.includes(query)) : false;
42
- case "starts_with": return typeof expected === "string" ? compareString(actual, expected, Reflect.get(condition, "mode"), (value, query) => value.startsWith(query)) : false;
43
- case "ends_with": return typeof expected === "string" ? compareString(actual, expected, Reflect.get(condition, "mode"), (value, query) => value.endsWith(query)) : false;
44
- default: return false;
45
- }
46
- };
47
- const matchesStandaloneWhere = (row, where) => {
48
- if (!where || where.length === 0) return true;
49
- let result = matchesCondition(row, where[0]);
50
- for (const condition of where.slice(1)) {
51
- const current = matchesCondition(row, condition);
52
- result = condition.connector === "OR" ? result || current : result && current;
53
- }
54
- return result;
55
- };
56
- const queryStandaloneRows = (rows, input) => {
57
- const filtered = rows.filter((row) => matchesStandaloneWhere(row, input.where));
58
- if (input.orderBy) {
59
- const direction = input.orderBy.direction === "asc" ? 1 : -1;
60
- filtered.sort((left, right) => compare$1(Reflect.get(left, input.orderBy?.field ?? "id"), Reflect.get(right, input.orderBy?.field ?? "id")) * direction);
61
- }
62
- const offset = input.offset ?? 0;
63
- return filtered.slice(offset, offset + (input.limit ?? filtered.length));
64
- };
65
- //#endregion
66
- //#region src/standaloneBundleReads.ts
67
- const compare = (left, right) => {
68
- if (typeof left === "number" && typeof right === "number") return left - right;
69
- return String(left).localeCompare(String(right));
70
- };
71
- const queryBundleRows = (rows, input) => {
72
- const filtered = queryStandaloneRows(rows, { where: input.where });
73
- const orderBy = input.orderBy;
74
- if (orderBy !== void 0) filtered.sort((left, right) => {
75
- for (const clause of orderBy) {
76
- const leftValue = Reflect.get(left, clause.field);
77
- const rightValue = Reflect.get(right, clause.field);
78
- if (leftValue === rightValue) continue;
79
- if (leftValue === null || rightValue === null) {
80
- const nulls = clause.nulls ?? (clause.direction === "asc" ? "last" : "first");
81
- return leftValue === null ? nulls === "first" ? -1 : 1 : nulls === "first" ? 1 : -1;
82
- }
83
- const result = compare(leftValue, rightValue);
84
- if (result !== 0) return clause.direction === "asc" ? result : -result;
85
- }
86
- return 0;
87
- });
88
- return (input.distinctOn === void 0 ? filtered : filtered.filter((row, index, ordered) => ordered.findIndex((candidate) => JSON.stringify(input.distinctOn?.fields.map((field) => Reflect.get(candidate, field))) === JSON.stringify(input.distinctOn?.fields.map((field) => Reflect.get(row, field)))) === index)).slice(input.offset, input.offset + input.limit);
89
- };
90
- const countDistinctRows = (rows, fields) => fields === void 0 ? rows.length : new Set(rows.map((row) => JSON.stringify(fields.map((field) => Reflect.get(row, field))))).size;
91
- const createBundleReads = (remote) => ({
92
- async count(input) {
93
- switch (input.model) {
94
- case "bundles": {
95
- const where = input.where;
96
- if (input.distinct === void 0) {
97
- const remoteWindow = await remote.loadBundleWindow({
98
- where,
99
- limit: 1,
100
- offset: 0
101
- });
102
- if (remoteWindow) return remoteWindow.total;
103
- }
104
- const rows = queryStandaloneRows(await loadRows(remote, "bundles"), { where });
105
- return countDistinctRows(rows, input.distinct);
106
- }
107
- case "bundle_patches": {
108
- const rows = queryStandaloneRows(await loadRows(remote, "bundle_patches"), { where: input.where });
109
- return countDistinctRows(rows, input.distinct);
110
- }
111
- }
112
- },
113
- async findOne(input) {
114
- if (input.model === "bundles") {
115
- const idSelector = input.where?.length === 1 ? input.where[0] : void 0;
116
- if (idSelector?.field === "id" && (idSelector.operator === void 0 || idSelector.operator === "eq") && typeof idSelector.value === "string") {
117
- const row = await remote.loadBundleRow(idSelector.value);
118
- return row && matchesStandaloneWhere(row, input.where) ? row : null;
119
- }
120
- return queryStandaloneRows(await loadRows(remote, "bundles"), {
121
- where: input.where,
122
- limit: 1
123
- })[0] ?? null;
124
- }
125
- return queryStandaloneRows(await loadRows(remote, "bundle_patches"), {
126
- where: input.where,
127
- limit: 1
128
- })[0] ?? null;
129
- },
130
- async findMany(input) {
131
- switch (input.model) {
132
- case "bundles": {
133
- const where = input.where;
134
- const orderBy = input.orderBy;
135
- const remoteSort = orderBy?.length === 1 && orderBy[0]?.field === "id" ? orderBy[0] : void 0;
136
- if (input.distinctOn === void 0 && (orderBy === void 0 || remoteSort)) {
137
- const remoteWindow = await remote.loadBundleWindow({
138
- where,
139
- limit: input.limit,
140
- offset: input.offset,
141
- orderBy: remoteSort
142
- });
143
- if (remoteWindow) return remoteWindow.rows;
144
- }
145
- return queryBundleRows(await loadRows(remote, "bundles"), {
146
- distinctOn: input.distinctOn,
147
- where,
148
- limit: input.limit,
149
- offset: input.offset,
150
- orderBy: input.orderBy
151
- });
152
- }
153
- case "bundle_patches": return queryBundleRows(await loadRows(remote, "bundle_patches"), {
154
- distinctOn: input.distinctOn,
155
- where: input.where,
156
- limit: input.limit,
157
- offset: input.offset,
158
- orderBy: input.orderBy
159
- });
160
- }
161
- }
162
- });
163
- //#endregion
164
- //#region src/standaloneBundleReader.ts
165
- const createStandaloneBundleReader = (remote) => createBundleReads(remote);
166
- //#endregion
167
- //#region src/standaloneBundleWhere.ts
168
- const appendBundleWhere = (url, where) => {
169
- const usedParameters = /* @__PURE__ */ new Set();
170
- const setParameter = (parameter, value) => {
171
- if (usedParameters.has(parameter)) return false;
172
- usedParameters.add(parameter);
173
- url.searchParams.set(parameter, value);
174
- return true;
175
- };
176
- const appendParameter = (parameter, values) => {
177
- if (values.length === 0 || usedParameters.has(parameter)) return false;
178
- usedParameters.add(parameter);
179
- for (const value of values) url.searchParams.append(parameter, value);
180
- return true;
181
- };
182
- for (const [index, condition] of (where ?? []).entries()) {
183
- if (index > 0 && condition.connector === "OR") return false;
184
- const operator = condition.operator ?? "eq";
185
- switch (condition.field) {
186
- case "platform":
187
- if (operator !== "eq" || typeof condition.value !== "string") return false;
188
- if (!setParameter("platform", condition.value)) return false;
189
- break;
190
- case "id": {
191
- let parameter;
192
- switch (operator) {
193
- case "eq":
194
- parameter = "idEq";
195
- break;
196
- case "gt":
197
- parameter = "idGt";
198
- break;
199
- case "gte":
200
- parameter = "idGte";
201
- break;
202
- case "lt":
203
- parameter = "idLt";
204
- break;
205
- case "lte": parameter = "idLte";
206
- }
207
- if (parameter && typeof condition.value === "string") {
208
- if (!setParameter(parameter, condition.value)) return false;
209
- break;
210
- }
211
- if (operator === "in" && Array.isArray(condition.value)) {
212
- if (!condition.value.every((value) => typeof value === "string")) return false;
213
- if (!appendParameter("idIn", condition.value)) return false;
214
- break;
215
- }
216
- return false;
217
- }
218
- default: return false;
219
- }
220
- }
221
- return true;
222
- };
223
- //#endregion
1
+ import { DatabaseBundleNotFoundError, ReleaseCatalogMutationError, ReleaseManagementError, createStorageAdapter, parseStorageUri } from "@hot-updater/plugin-core";
224
2
  //#region src/standaloneHttp.ts
225
3
  var StandaloneDatabaseError = class extends Error {
226
4
  code;
@@ -232,7 +10,7 @@ var StandaloneDatabaseError = class extends Error {
232
10
  this.status = status;
233
11
  }
234
12
  };
235
- const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
13
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
236
14
  const createStandaloneHttp = (config) => {
237
15
  const baseUrl = config.baseUrl.replace(/\/+$/, "");
238
16
  const buildUrl = (path) => `${baseUrl}${path}`;
@@ -245,8 +23,8 @@ const createStandaloneHttp = (config) => {
245
23
  let message = `Database request failed with status ${response.status}.`;
246
24
  try {
247
25
  const body = await response.json();
248
- if (isRecord$2(body) && typeof body.message === "string") message = body.message;
249
- else if (isRecord$2(body) && typeof body.error === "string") message = body.error;
26
+ if (isRecord$1(body) && typeof body.message === "string") message = body.message;
27
+ else if (isRecord$1(body) && typeof body.error === "string") message = body.error;
250
28
  } catch (error) {
251
29
  if (!(error instanceof SyntaxError)) throw error;
252
30
  }
@@ -260,409 +38,206 @@ const createStandaloneHttp = (config) => {
260
38
  throw new StandaloneDatabaseError("invalid-response", "Database response must contain JSON.", response.status);
261
39
  }
262
40
  };
263
- const load = async (route, searchParams, isResult, invalidMessage, signal) => {
264
- const url = new URL(buildUrl(route.path));
265
- for (const [key, value] of Object.entries(searchParams)) url.searchParams.set(key, value);
266
- const response = await fetch(url, {
267
- method: "GET",
268
- headers: headers(route.headers),
269
- signal
270
- });
271
- const value = await parseJson(response);
272
- if (!isResult(value)) throw new StandaloneDatabaseError("invalid-response", invalidMessage, response.status);
273
- return value;
274
- };
275
41
  return {
276
42
  buildUrl,
277
43
  headers,
278
- load,
279
- parseJson,
280
- requestFailed
44
+ parseJson
281
45
  };
282
46
  };
283
47
  //#endregion
284
- //#region src/standaloneResponseGuards.ts
285
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
286
- const isNonNegativeInteger = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
287
- const isByteSize = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
288
- const isBundlePatch = (value) => isRecord$1(value) && typeof value.baseBundleId === "string" && typeof value.baseFileHash === "string" && isByteSize(value.byteSize) && typeof value.patchFileHash === "string" && typeof value.patchStorageUri === "string";
289
- const isChannelText = (value) => {
290
- if (typeof value !== "string" || value.length === 0) return false;
291
- let codePoints = 0;
292
- for (const _codePoint of value) {
293
- codePoints += 1;
294
- if (codePoints > 255) return false;
48
+ //#region src/standaloneCore.ts
49
+ /** The admin API protocol this client speaks; the server's `/version` must report it or a later one. */
50
+ const STANDALONE_ADMIN_PROTOCOL = 2;
51
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
52
+ const MANAGEMENT_CODES = /* @__PURE__ */ new Set([
53
+ "ENABLED_RELEASE",
54
+ "RELEASE_NOT_FOUND",
55
+ "SCOPE_MOVE_UNSUPPORTED",
56
+ "TARGET_RELEASE_INVALID",
57
+ "VERSION_CONFLICT"
58
+ ]);
59
+ const CATALOG_CODES = /* @__PURE__ */ new Set([
60
+ "CATALOG_GENERATION_EXHAUSTED",
61
+ "CATALOG_IDENTITY_MISSING",
62
+ "INVALID_SCOPE",
63
+ "NON_MONOTONIC_RELEASE_ID"
64
+ ]);
65
+ /** A refusal the server named, as the error core throws in process; anything else as a request failure. */
66
+ const refusal = (status, value, fallback) => {
67
+ const code = isRecord(value) ? value.code : void 0;
68
+ const message = isRecord(value) && typeof value.error === "string" ? value.error : fallback;
69
+ if (typeof code === "string" && MANAGEMENT_CODES.has(code)) return new ReleaseManagementError(code, message);
70
+ if (typeof code === "string" && CATALOG_CODES.has(code)) return new ReleaseCatalogMutationError(code, message);
71
+ if (code === "BUNDLE_NOT_FOUND" && isRecord(value)) {
72
+ const id = /"([^"]+)"/u.exec(message)?.[1] ?? "";
73
+ return new DatabaseBundleNotFoundError(id);
295
74
  }
296
- return true;
297
- };
298
- const isChannelRow = (value) => isRecord$1(value) && isChannelText(value.id) && isChannelText(value.name);
299
- const isBundle = (value) => isRecord$1(value) && typeof value.id === "string" && (value.platform === "ios" || value.platform === "android") && typeof value.fileHash === "string" && typeof value.storageUri === "string" && isByteSize(value.archiveByteSize) && (value.patches === void 0 || value.patches === null || Array.isArray(value.patches) && value.patches.every(isBundlePatch));
300
- const isPaginationInfo = (value) => isRecord$1(value) && isNonNegativeInteger(value.total) && typeof value.hasNextPage === "boolean" && typeof value.hasPreviousPage === "boolean" && isNonNegativeInteger(value.currentPage) && value.currentPage >= 1 && isNonNegativeInteger(value.totalPages);
301
- const isPaginatedResult = (value) => isRecord$1(value) && Array.isArray(value.data) && value.data.every(isBundle) && isPaginationInfo(value.pagination);
302
- const hasChannels = (value) => isRecord$1(value) && isRecord$1(value.data) && Array.isArray(value.data.channels) && value.data.channels.every(isChannelRow);
303
- const hasChannelInsertResult = (value) => isRecord$1(value) && isRecord$1(value.data) && isChannelRow(value.data.row) && typeof value.data.inserted === "boolean";
304
- const hasChannelDeleteResult = (value) => isRecord$1(value) && isRecord$1(value.data) && (value.data.deleted === true || value.data.deleted === false && (value.data.reason === "not_empty" || value.data.reason === "not_found"));
305
- //#endregion
306
- //#region src/standaloneRoutes.ts
307
- function bundlePath(bundleId) {
308
- return `/bundles/${encodeURIComponent(bundleId)}`;
309
- }
310
- function channelPath(channelId) {
311
- return `/channels/${encodeURIComponent(channelId)}`;
312
- }
313
- const defaultRoutes$1 = {
314
- create: () => ({ path: "/bundles" }),
315
- update: (bundleId) => ({ path: bundlePath(bundleId) }),
316
- list: () => ({
317
- path: "/bundles",
318
- headers: { "Cache-Control": "no-cache" }
319
- }),
320
- channels: () => ({
321
- path: "/channels",
322
- headers: { "Cache-Control": "no-cache" }
323
- }),
324
- deleteChannel: (channelId) => ({
325
- path: channelPath(channelId),
326
- headers: {}
327
- }),
328
- retrieve: (bundleId) => ({
329
- path: bundlePath(bundleId),
330
- headers: { Accept: "application/json" }
331
- }),
332
- delete: (bundleId) => ({ path: bundlePath(bundleId) })
75
+ return new StandaloneDatabaseError("request-failed", message, status);
333
76
  };
334
- const createRoute$1 = (defaultRoute, customRoute) => ({
335
- path: customRoute?.path ?? defaultRoute.path,
336
- headers: {
337
- ...defaultRoute.headers,
338
- ...customRoute?.headers
339
- }
77
+ const keysetParams = ({ order, after, limit }) => ({
78
+ v: String(2),
79
+ limit: String(limit),
80
+ ...order === void 0 ? {} : { order },
81
+ ...after === void 0 ? {} : { cursor: after }
340
82
  });
341
- //#endregion
342
- //#region src/standaloneBundleRemote.ts
343
- const PAGE_SIZE = 100;
344
- const createStandaloneBundleRemote = (config) => {
345
- const routes = {
346
- list: () => createRoute$1(defaultRoutes$1.list(), config.routes?.list?.()),
347
- channels: defaultRoutes$1.channels,
348
- deleteChannel: defaultRoutes$1.deleteChannel,
349
- create: () => createRoute$1(defaultRoutes$1.create(), config.routes?.create?.()),
350
- update: (bundleId) => createRoute$1(defaultRoutes$1.update(bundleId), config.routes?.update?.(bundleId)),
351
- retrieve: (bundleId) => createRoute$1(defaultRoutes$1.retrieve(bundleId), config.routes?.retrieve?.(bundleId)),
352
- delete: (bundleId) => createRoute$1(defaultRoutes$1.delete(bundleId), config.routes?.delete?.(bundleId))
353
- };
354
- const http = createStandaloneHttp(config);
355
- const loadBundles = async () => {
356
- const bundles = [];
357
- for (let page = 1;; page += 1) {
358
- const route = routes.list();
359
- const url = new URL(http.buildUrl(route.path));
360
- url.searchParams.set("limit", String(PAGE_SIZE));
361
- url.searchParams.set("page", String(page));
362
- const response = await fetch(url, {
363
- method: "GET",
364
- headers: http.headers(route.headers)
365
- });
366
- const value = await http.parseJson(response);
367
- if (!isPaginatedResult(value)) throw new StandaloneDatabaseError("invalid-response", "Invalid bundle list response.", response.status);
368
- bundles.push(...value.data);
369
- if (value.data.length < PAGE_SIZE || value.pagination.hasNextPage === false || bundles.length >= value.pagination.total) return bundles;
370
- }
371
- };
372
- const loadChannels = async () => {
373
- const route = routes.channels();
374
- const response = await fetch(http.buildUrl(route.path), {
375
- method: "GET",
376
- headers: http.headers(route.headers)
377
- });
378
- const value = await http.parseJson(response);
379
- if (!hasChannels(value)) throw new StandaloneDatabaseError("invalid-response", "Invalid channels response.", response.status);
380
- return value.data.channels;
381
- };
382
- const bundlesToRows = async (bundles) => bundles.map((bundle) => bundleToRow(bundle));
383
- const loadBundleWindow = async (input) => {
384
- if (input.limit === 0) return {
385
- rows: [],
386
- total: 0
83
+ const filterParams = (filter) => {
84
+ switch (filter.kind) {
85
+ case "all": return {};
86
+ case "bundle": return { bundleId: filter.bundleId };
87
+ case "scope": return {
88
+ scopeKey: filter.scopeKey,
89
+ ...filter.enabled === void 0 ? {} : { enabled: String(filter.enabled) }
387
90
  };
388
- if (input.orderBy && input.orderBy.field !== "id") return null;
389
- const route = routes.list();
390
- const url = new URL(http.buildUrl(route.path));
391
- if (!appendBundleWhere(url, input.where)) return null;
392
- if (input.orderBy !== void 0) url.searchParams.set("orderDirection", input.orderBy.direction);
393
- const pageAligned = input.limit > 0 && input.offset % input.limit === 0;
394
- const remoteLimit = pageAligned ? input.limit : input.offset + input.limit;
395
- if (remoteLimit > PAGE_SIZE) return null;
396
- url.searchParams.set("limit", String(remoteLimit));
397
- url.searchParams.set("page", String(pageAligned ? input.offset / input.limit + 1 : 1));
398
- const response = await fetch(url, {
399
- method: "GET",
400
- headers: http.headers(route.headers)
401
- });
402
- const value = await http.parseJson(response);
403
- if (!isPaginatedResult(value)) throw new StandaloneDatabaseError("invalid-response", "Invalid bundle list response.", response.status);
404
- const bundles = pageAligned ? value.data : value.data.slice(input.offset, input.offset + input.limit);
405
- return {
406
- rows: await bundlesToRows(bundles),
407
- total: value.pagination.total
91
+ case "channelPlatform": return {
92
+ channelId: filter.channelId,
93
+ platform: filter.platform,
94
+ ...filter.enabled === void 0 ? {} : { enabled: String(filter.enabled) }
408
95
  };
96
+ }
97
+ };
98
+ /**
99
+ * Core's API over a self-hosted server's admin handler, protocol 2: the CLI's
100
+ * path to a database it cannot open itself. The first call checks that the
101
+ * server speaks protocol 2, so an older server fails fast.
102
+ */
103
+ const createStandaloneCoreApi = (config) => {
104
+ const http = createStandaloneHttp(config);
105
+ let checked;
106
+ const checkProtocol = async () => {
107
+ const response = await fetch(http.buildUrl("/version"), { headers: http.headers({ "Cache-Control": "no-cache" }) });
108
+ const body = response.status === 404 ? {} : await http.parseJson(response);
109
+ const protocol = isRecord(body) ? body.adminProtocol : void 0;
110
+ if (typeof protocol !== "number" || protocol < 2) throw new StandaloneDatabaseError("request-failed", `The server at ${config.baseUrl} speaks admin API protocol ${typeof protocol === "number" ? protocol : 1}, and this CLI needs 2. Upgrade @hot-updater/server on the server.`, response.status);
409
111
  };
410
- const insertChannel = async (input) => {
411
- const route = routes.channels();
412
- const response = await fetch(http.buildUrl(route.path), {
413
- method: "POST",
414
- headers: http.headers(route.headers),
415
- body: JSON.stringify(input)
112
+ /** One admin request's JSON body, or null for 204 and for a GET that finds nothing; `/version` is checked once before the first. */
113
+ const request = async (method, path, { query = {}, body } = {}) => {
114
+ checked ??= checkProtocol().catch((error) => {
115
+ checked = void 0;
116
+ throw error;
416
117
  });
417
- const value = await http.parseJson(response);
418
- if (!hasChannelInsertResult(value)) throw new StandaloneDatabaseError("invalid-response", "Invalid Channel insert response.", response.status);
419
- if (value.data.row.name !== input.row.name || value.data.inserted && value.data.row.id !== input.row.id) throw new StandaloneDatabaseError("invalid-response", "Invalid Channel insert response.", response.status);
420
- return value.data;
421
- };
422
- const deleteChannel = async (input) => {
423
- const route = routes.deleteChannel(input.id);
424
- const response = await fetch(http.buildUrl(route.path), {
425
- method: "DELETE",
426
- headers: http.headers(route.headers)
118
+ await checked;
119
+ const url = new URL(http.buildUrl(path));
120
+ for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value);
121
+ const response = await fetch(url, {
122
+ method,
123
+ headers: http.headers(method === "GET" ? { "Cache-Control": "no-cache" } : void 0),
124
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
427
125
  });
428
- if (response.status === 204) return { deleted: true };
429
- let value;
430
- try {
431
- value = await response.json();
432
- } catch {
433
- throw new StandaloneDatabaseError("invalid-response", "Database response must contain JSON.", response.status);
126
+ if (response.status === 204) return null;
127
+ if (method === "GET" && response.status === 404) return null;
128
+ if (response.status === 503) throw new StandaloneDatabaseError("request-failed", `The server at ${config.baseUrl} answered ${method} ${path} with 503: its database is not ready. If it needs migrations, run \`hot-updater db migrate\` where the server's database is configured.`, 503);
129
+ if (!response.ok) {
130
+ const failure = await response.json().catch(() => null);
131
+ throw refusal(response.status, failure, `${method} ${path} failed with status ${response.status}.`);
434
132
  }
435
- if (!hasChannelDeleteResult(value)) throw new StandaloneDatabaseError("invalid-response", "Invalid Channel delete response.", response.status);
436
- return value.data;
437
- };
438
- const loadBundle = async (bundleId) => {
439
- const route = routes.retrieve(bundleId);
440
- const response = await fetch(http.buildUrl(route.path), {
441
- method: "GET",
442
- headers: http.headers(route.headers)
443
- });
444
- if (response.status === 404) return null;
445
133
  const value = await http.parseJson(response);
446
- if (!isBundle(value)) throw new StandaloneDatabaseError("invalid-response", "Invalid bundle response.", response.status);
134
+ if (!isRecord(value)) throw new StandaloneDatabaseError("invalid-response", `The server answered ${method} ${path} with no JSON object.`, response.status);
447
135
  return value;
448
136
  };
449
- const loadBundleRow = async (bundleId) => {
450
- const bundle = await loadBundle(bundleId);
451
- return bundle ? (await bundlesToRows([bundle]))[0] ?? null : null;
452
- };
453
- const loadBundleRows = async () => bundlesToRows(await loadBundles());
454
- const updateBundle = async (bundle) => {
455
- const route = routes.update(bundle.id);
456
- const response = await fetch(http.buildUrl(route.path), {
457
- method: "PATCH",
458
- headers: http.headers(route.headers),
459
- body: JSON.stringify(bundle)
460
- });
461
- await http.parseJson(response);
462
- };
463
- const createBundles = async (bundles) => {
464
- const route = routes.create();
465
- const response = await fetch(http.buildUrl(route.path), {
466
- method: "POST",
467
- headers: http.headers(route.headers),
468
- body: JSON.stringify(bundles)
469
- });
470
- await http.parseJson(response);
471
- };
472
- const deleteBundle = async (bundleId) => {
473
- const route = routes.delete(bundleId);
474
- const response = await fetch(http.buildUrl(route.path), {
475
- method: "DELETE",
476
- headers: http.headers(route.headers)
477
- });
478
- await http.parseJson(response);
479
- };
480
- return {
481
- createBundle: (bundle) => createBundles([bundle]),
482
- createBundles,
483
- deleteBundle,
484
- deleteChannel,
485
- insertChannel,
486
- loadBundle,
487
- loadBundleRow,
488
- loadBundleRows,
489
- loadBundles,
490
- loadBundleWindow,
491
- loadChannels,
492
- updateBundle
137
+ /** A request's `data`, or null when it found nothing. */
138
+ const call = async (...args) => {
139
+ const value = await request(...args);
140
+ if (value === null) return null;
141
+ if (!("data" in value)) throw new StandaloneDatabaseError("invalid-response", `The server answered ${args[0]} ${args[1]} without data.`);
142
+ return value.data;
493
143
  };
494
- };
495
- //#endregion
496
- //#region src/standaloneReleaseRemote.ts
497
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
498
- const data = (value, message) => {
499
- if (!isRecord(value) || !("data" in value)) throw new StandaloneDatabaseError("invalid-response", message);
500
- return value.data;
501
- };
502
- const createStandaloneReleaseRemote = (config) => {
503
- const http = createStandaloneHttp(config);
504
- const headers = () => http.headers({ "Cache-Control": "no-cache" });
144
+ const list = async (path, query) => await call("GET", path, { query }) ?? [];
145
+ const v2 = { v: String(2) };
146
+ const encode = encodeURIComponent;
505
147
  return {
506
- async findReleaseById(id) {
507
- const response = await fetch(http.buildUrl(`/releases/${encodeURIComponent(id)}`), { headers: headers() });
508
- if (response.status === 404) return null;
509
- return data(await http.parseJson(response), "Invalid Release response.");
148
+ ready: async () => {
149
+ await list("/release-catalogs", {
150
+ ...v2,
151
+ limit: "1"
152
+ });
510
153
  },
511
- async findReleasesByScope(input) {
512
- const url = new URL(http.buildUrl("/releases"));
513
- url.searchParams.set("scopeKey", input.scopeKey);
514
- url.searchParams.set("limit", String(input.limit));
515
- if (input.afterReleaseId !== void 0) url.searchParams.set("afterReleaseId", input.afterReleaseId);
516
- const response = await fetch(url, { headers: headers() });
517
- const rows = data(await http.parseJson(response), "Invalid Release list response.");
518
- if (!Array.isArray(rows)) throw new StandaloneDatabaseError("invalid-response", "Invalid Release list response.", response.status);
519
- return rows;
154
+ getBundle: (id) => call("GET", `/bundles/${encode(id)}`, { query: v2 }),
155
+ listBundles: ({ platform, ...input }) => list("/bundles", {
156
+ ...keysetParams(input),
157
+ ...platform === void 0 ? {} : { platform }
158
+ }),
159
+ countBundles: async (platform) => {
160
+ const total = await call("GET", "/bundles/count", { query: {
161
+ ...v2,
162
+ ...platform === void 0 ? {} : { platform }
163
+ } });
164
+ if (typeof total !== "number") throw new StandaloneDatabaseError("invalid-response", "The server answered a bundle count without a number.");
165
+ return total;
520
166
  },
521
- async findReleases(input) {
522
- const url = new URL(http.buildUrl("/releases"));
523
- url.searchParams.set("limit", String(input.limit));
524
- for (const [key, value] of Object.entries(input)) if (key !== "limit" && value !== void 0) url.searchParams.set(key, String(value));
525
- const response = await fetch(url, { headers: headers() });
526
- const rows = data(await http.parseJson(response), "Invalid Release list response.");
527
- if (!Array.isArray(rows)) throw new StandaloneDatabaseError("invalid-response", "Invalid Release list response.", response.status);
528
- return rows;
167
+ countBundleChildren: async (baseBundleIds) => {
168
+ const ids = [...new Set(baseBundleIds)];
169
+ if (ids.length === 0) return {};
170
+ return await call("GET", "/bundles/child-counts", { query: {
171
+ ...v2,
172
+ ids: ids.join(",")
173
+ } }) ?? {};
529
174
  },
530
- async findCatalogByScopeKey(scopeKey) {
531
- const response = await fetch(http.buildUrl(`/release-catalogs/${encodeURIComponent(scopeKey)}`), { headers: headers() });
532
- if (response.status === 404) return null;
533
- return data(await http.parseJson(response), "Invalid Release catalog response.");
175
+ listPatchesFromBase: (baseBundleId, input) => list(`/bundles/${encode(baseBundleId)}/children`, keysetParams(input)),
176
+ findBaseBundleIds: (candidateKey, bundleId, limit) => list(`/base-candidates/${encode(candidateKey)}`, {
177
+ before: bundleId,
178
+ limit: String(limit)
179
+ }),
180
+ getRelease: (id) => call("GET", `/releases/${encode(id)}`),
181
+ listReleases: ({ filter, ...input }) => list("/releases", {
182
+ ...keysetParams(input),
183
+ ...filterParams(filter)
184
+ }),
185
+ getReleaseCatalogRow: (scopeKey) => call("GET", `/release-catalogs/${encode(scopeKey)}`),
186
+ listReleaseCatalogs: (input) => list("/release-catalogs", keysetParams(input)),
187
+ listChannels: () => list("/channels", v2),
188
+ findChannelByName: (name) => call("GET", "/channels", { query: {
189
+ ...v2,
190
+ name
191
+ } }),
192
+ deploy: async (deployments) => await call("POST", "/releases", { body: { deployments } }),
193
+ updateReleasePolicy: async ({ releaseId, ...input }) => await call("PATCH", `/releases/${encode(releaseId)}`, { body: input }),
194
+ preflightReleasePolicy: async ({ releaseId, ...input }) => await call("POST", `/releases/${encode(releaseId)}/preflight`, { body: input }),
195
+ deleteRelease: async ({ releaseId, expectedRevision }) => await call("DELETE", `/releases/${encode(releaseId)}`, { query: {
196
+ confirm: releaseId,
197
+ ...expectedRevision === void 0 ? {} : { expectedRevision: String(expectedRevision) }
198
+ } }),
199
+ promoteRelease: async ({ releaseId, ...input }) => await call("POST", `/releases/${encode(releaseId)}/promote`, { body: input }),
200
+ rebuildReleaseCatalog: async (scopeKey) => await call("POST", `/release-catalogs/${encode(scopeKey)}/rebuild`),
201
+ preflightReleaseCatalogRebuild: async (scopeKey) => await call("POST", `/release-catalogs/${encode(scopeKey)}/preflight`),
202
+ ensureChannel: async (name) => await call("POST", "/channels", {
203
+ query: v2,
204
+ body: { name }
205
+ }),
206
+ deleteChannel: async (id) => {
207
+ return await call("DELETE", `/channels/${encode(id)}`).catch((error) => {
208
+ if (error instanceof StandaloneDatabaseError && error.status === 409) return {
209
+ deleted: false,
210
+ reason: "not_empty"
211
+ };
212
+ if (error instanceof StandaloneDatabaseError && error.status === 404) return {
213
+ deleted: false,
214
+ reason: "not_found"
215
+ };
216
+ throw error;
217
+ }) ?? { deleted: true };
534
218
  },
535
- async findCatalogs(input) {
536
- const url = new URL(http.buildUrl("/release-catalogs"));
537
- url.searchParams.set("limit", String(input.limit));
538
- if (input.afterScopeKey !== void 0) url.searchParams.set("afterScopeKey", input.afterScopeKey);
539
- const response = await fetch(url, { headers: headers() });
540
- const rows = data(await http.parseJson(response), "Invalid Release catalog list response.");
541
- if (!Array.isArray(rows)) throw new StandaloneDatabaseError("invalid-response", "Invalid Release catalog list response.", response.status);
542
- return rows;
219
+ updateBundle: async (id, update) => {
220
+ await request("PATCH", `/bundles/${encode(id)}`, { body: update });
543
221
  },
544
- async commit(input) {
545
- const response = await fetch(http.buildUrl("/database/commit"), {
546
- body: JSON.stringify(input),
547
- headers: http.headers(),
548
- method: "POST"
549
- });
550
- return data(await http.parseJson(response), "Invalid database commit response.");
222
+ deleteBundles: async (ids) => {
223
+ await request("POST", "/bundles/delete", { body: { ids } });
551
224
  }
552
225
  };
553
226
  };
554
227
  //#endregion
555
228
  //#region src/standaloneRepository.ts
556
- const toBundleWhere = (where) => {
557
- if (!where) return [];
558
- const filters = [];
559
- if (where.platform !== void 0) filters.push({
560
- field: "platform",
561
- value: where.platform
562
- });
563
- if (where.id?.eq !== void 0) filters.push({
564
- field: "id",
565
- value: where.id.eq
566
- });
567
- if (where.id?.gt !== void 0) filters.push({
568
- field: "id",
569
- operator: "gt",
570
- value: where.id.gt
571
- });
572
- if (where.id?.gte !== void 0) filters.push({
573
- field: "id",
574
- operator: "gte",
575
- value: where.id.gte
576
- });
577
- if (where.id?.lt !== void 0) filters.push({
578
- field: "id",
579
- operator: "lt",
580
- value: where.id.lt
581
- });
582
- if (where.id?.lte !== void 0) filters.push({
583
- field: "id",
584
- operator: "lte",
585
- value: where.id.lte
586
- });
587
- if (where.id?.in !== void 0) filters.push({
588
- field: "id",
589
- operator: "in",
590
- value: where.id.in
591
- });
592
- return filters;
593
- };
594
229
  /**
595
- * Bundle-only HTTP repository used by the CLI for a self-hosted server.
596
- *
597
- * This is intentionally not a database plugin: insights and access-key
598
- * persistence belong to the server's database provider.
230
+ * A self-hosted server's database for the CLI and console: core's API over
231
+ * the server's admin API protocol 2, and GETs on its admin handler for the
232
+ * routes core does not cover, which its plugins serve. Plugins' tables belong
233
+ * to the server's own database.
599
234
  */
600
235
  const standaloneRepository = (config) => {
601
- const remote = createStandaloneBundleRemote(config);
602
- const releaseRemote = createStandaloneReleaseRemote(config);
603
- const bundleReader = createStandaloneBundleReader(remote);
236
+ const http = createStandaloneHttp(config);
604
237
  return Object.freeze({
605
238
  name: "standalone-repository",
606
- models: {
607
- bundles: {
608
- async findById(id) {
609
- return await bundleReader.findOne({
610
- model: "bundles",
611
- where: [{
612
- field: "id",
613
- value: id
614
- }]
615
- });
616
- },
617
- async findMany(query) {
618
- return await bundleReader.findMany({
619
- model: "bundles",
620
- where: toBundleWhere(query.where),
621
- limit: query.limit,
622
- offset: query.offset,
623
- orderBy: [query.orderBy]
624
- });
625
- },
626
- count: (where) => bundleReader.count({
627
- model: "bundles",
628
- where: toBundleWhere(where)
629
- })
630
- },
631
- bundlePatches: { async findByBundleIds(bundleIds) {
632
- if (bundleIds.length === 0) return [];
633
- return await bundleReader.findMany({
634
- model: "bundle_patches",
635
- where: [{
636
- field: "bundle_id",
637
- operator: "in",
638
- value: bundleIds
639
- }],
640
- orderBy: [{
641
- field: "id",
642
- direction: "asc"
643
- }],
644
- limit: Number.MAX_SAFE_INTEGER,
645
- offset: 0
646
- });
647
- } },
648
- releases: {
649
- findById: (id) => releaseRemote.findReleaseById(id),
650
- findMany: (input) => releaseRemote.findReleases(input),
651
- findManyByScope: (input) => releaseRemote.findReleasesByScope(input)
652
- },
653
- releaseCatalogs: {
654
- findByScopeKey: (scopeKey) => releaseRemote.findCatalogByScopeKey(scopeKey),
655
- findMany: (input) => releaseRemote.findCatalogs(input)
656
- },
657
- channels: {
658
- insert: (input) => remote.insertChannel(input),
659
- delete: (input) => remote.deleteChannel(input),
660
- async list() {
661
- return { channels: await remote.loadChannels() };
662
- }
663
- }
664
- },
665
- commit: (input) => releaseRemote.commit(input)
239
+ core: createStandaloneCoreApi(config),
240
+ fetchAdmin: (path) => fetch(http.buildUrl(path), { headers: http.headers({ "Cache-Control": "no-cache" }) })
666
241
  });
667
242
  };
668
243
  //#endregion
@@ -704,7 +279,7 @@ const standaloneStorage = (config) => {
704
279
  if (!response.ok && response.status !== 404) throw new Error(`Failed to ${routeName} storage object: ${response.statusText}`);
705
280
  return response;
706
281
  };
707
- return createStoragePlugin({
282
+ return createStorageAdapter({
708
283
  name: "standaloneStorage",
709
284
  protocol: config.protocol,
710
285
  async put({ key, body, contentType, contentLength }) {
@@ -745,4 +320,4 @@ const standaloneStorage = (config) => {
745
320
  });
746
321
  };
747
322
  //#endregion
748
- export { StandaloneDatabaseError, standaloneRepository, standaloneStorage };
323
+ export { STANDALONE_ADMIN_PROTOCOL, StandaloneDatabaseError, createStandaloneCoreApi, standaloneRepository, standaloneStorage };