@hot-updater/server 1.1.3 → 1.2.0-rc.0

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/core/api.cjs CHANGED
@@ -1,4 +1,5 @@
1
1
  const require_reads = require("./reads.cjs");
2
+ const require_nativeBuilds = require("./nativeBuilds.cjs");
2
3
  const require_operations = require("./operations.cjs");
3
4
  //#region src/core/api.ts
4
5
  /**
@@ -41,6 +42,7 @@ const createCoreApi = (db, storage, options = {}) => {
41
42
  return {
42
43
  ...reads,
43
44
  ...require_operations.createCoreOperations(purgingCachedRoutes(db, options.onCachedRoutesChange), options),
45
+ ...require_nativeBuilds.createNativeBuildApi(db, options),
44
46
  /** One read, which checks the schema settings first. */
45
47
  ready: async () => {
46
48
  await reads.listReleaseCatalogs({ limit: 1 });
@@ -9,6 +9,19 @@ interface CoreApiOptions {
9
9
  declare const createCoreApi: (db: CoreDatabase, storage: CoreStorage, options?: CoreApiOptions) => {
10
10
  /** One read, which checks the schema settings first. */
11
11
  ready: () => Promise<void>;
12
+ registerNativeBuild: (registration: import("@hot-updater/plugin-core").NativeBuildRegistration) => Promise<import("@hot-updater/plugin-core").NativeBuildRow>;
13
+ getNativeBuild: (id: string) => Promise<import("@hot-updater/plugin-core").NativeBuildRow | null>;
14
+ findNativeBuild: (scope: import("@hot-updater/plugin-core").NativeBuildScope, options?: {
15
+ readonly buildKey?: string;
16
+ }) => Promise<import("@hot-updater/plugin-core").NativeBuildRow | null>;
17
+ listNativeBuilds: (input: import("@hot-updater/plugin-core").KeysetInput & {
18
+ readonly channelId?: string;
19
+ }) => Promise<import("@hot-updater/plugin-core").NativeBuildRow[]>;
20
+ deleteNativeBuild: (id: string) => Promise<import("@hot-updater/plugin-core").NativeBuildDeleteResult>;
21
+ recordNativeBuildSubmission: (id: string, submission: {
22
+ readonly destination: import("@hot-updater/plugin-core").NativeBuildSubmissionDestination;
23
+ readonly version: string;
24
+ }) => Promise<import("@hot-updater/plugin-core").NativeBuildRow | null>;
12
25
  deploy: (deployments: readonly import("@hot-updater/plugin-core").Deployment[]) => Promise<import("@hot-updater/plugin-core").ReleaseCatalogMutationResult[]>;
13
26
  updateReleasePolicy: (input: import("@hot-updater/plugin-core").ReleaseTarget & {
14
27
  readonly patch: import("@hot-updater/plugin-core").ReleasePolicyPatch;
@@ -9,6 +9,19 @@ interface CoreApiOptions {
9
9
  declare const createCoreApi: (db: CoreDatabase, storage: CoreStorage, options?: CoreApiOptions) => {
10
10
  /** One read, which checks the schema settings first. */
11
11
  ready: () => Promise<void>;
12
+ registerNativeBuild: (registration: import("@hot-updater/plugin-core").NativeBuildRegistration) => Promise<import("@hot-updater/plugin-core").NativeBuildRow>;
13
+ getNativeBuild: (id: string) => Promise<import("@hot-updater/plugin-core").NativeBuildRow | null>;
14
+ findNativeBuild: (scope: import("@hot-updater/plugin-core").NativeBuildScope, options?: {
15
+ readonly buildKey?: string;
16
+ }) => Promise<import("@hot-updater/plugin-core").NativeBuildRow | null>;
17
+ listNativeBuilds: (input: import("@hot-updater/plugin-core").KeysetInput & {
18
+ readonly channelId?: string;
19
+ }) => Promise<import("@hot-updater/plugin-core").NativeBuildRow[]>;
20
+ deleteNativeBuild: (id: string) => Promise<import("@hot-updater/plugin-core").NativeBuildDeleteResult>;
21
+ recordNativeBuildSubmission: (id: string, submission: {
22
+ readonly destination: import("@hot-updater/plugin-core").NativeBuildSubmissionDestination;
23
+ readonly version: string;
24
+ }) => Promise<import("@hot-updater/plugin-core").NativeBuildRow | null>;
12
25
  deploy: (deployments: readonly import("@hot-updater/plugin-core").Deployment[]) => Promise<import("@hot-updater/plugin-core").ReleaseCatalogMutationResult[]>;
13
26
  updateReleasePolicy: (input: import("@hot-updater/plugin-core").ReleaseTarget & {
14
27
  readonly patch: import("@hot-updater/plugin-core").ReleasePolicyPatch;
package/dist/core/api.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createCoreReads } from "./reads.mjs";
2
+ import { createNativeBuildApi } from "./nativeBuilds.mjs";
2
3
  import { createCoreOperations } from "./operations.mjs";
3
4
  //#region src/core/api.ts
4
5
  /**
@@ -41,6 +42,7 @@ const createCoreApi = (db, storage, options = {}) => {
41
42
  return {
42
43
  ...reads,
43
44
  ...createCoreOperations(purgingCachedRoutes(db, options.onCachedRoutesChange), options),
45
+ ...createNativeBuildApi(db, options),
44
46
  /** One read, which checks the schema settings first. */
45
47
  ready: async () => {
46
48
  await reads.listReleaseCatalogs({ limit: 1 });
@@ -0,0 +1,232 @@
1
+ const require_reads = require("./reads.cjs");
2
+ const require_writes = require("./writes.cjs");
3
+ let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
4
+ let _hot_updater_protocol = require("@hot-updater/protocol");
5
+ //#region src/core/nativeBuilds.ts
6
+ /** The artifacts each platform's build produces. */
7
+ const ARTIFACT_KINDS = {
8
+ android: ["apk", "aab"],
9
+ ios: ["ipa", "app"]
10
+ };
11
+ /** `native_builds.scope_key`'s bound in core's schema. */
12
+ const SCOPE_KEY_MAX_LENGTH = 2048;
13
+ /** The `git_commit_hash` bound of `bundles` and `native_builds`. */
14
+ const GIT_COMMIT_HASH_MAX_LENGTH = 255;
15
+ /**
16
+ * A native build key: `nbk1:` and a SHA-256, as the CLI computes it. The
17
+ * format is the CLI's convention, implemented here so the registry stores
18
+ * and looks up nothing else.
19
+ */
20
+ const BUILD_KEY = /^nbk1:[0-9a-f]{64}$/;
21
+ /** A native build or scope core cannot store or look up: the caller's input. */
22
+ var NativeBuildInputError = class extends TypeError {
23
+ name = "NativeBuildInputError";
24
+ };
25
+ const pick = (row) => ({
26
+ id: row.id,
27
+ scope_key: row.scope_key,
28
+ platform: row.platform,
29
+ channel_id: row.channel_id,
30
+ fingerprint_hash: row.fingerprint_hash ?? null,
31
+ app_version: row.app_version ?? null,
32
+ min_bundle_id: row.min_bundle_id,
33
+ artifact_kind: row.artifact_kind,
34
+ artifact_storage_uri: row.artifact_storage_uri,
35
+ byte_size: row.byte_size,
36
+ git_commit_hash: row.git_commit_hash ?? null,
37
+ created_at_ms: row.created_at_ms,
38
+ build_key: row.build_key ?? null,
39
+ build_number: row.build_number ?? null,
40
+ submitted_to: row.submitted_to ?? null,
41
+ submitted_at_ms: row.submitted_at_ms ?? null,
42
+ submitted_version: row.submitted_version ?? null
43
+ });
44
+ /** The store track each platform's builds are submitted to. */
45
+ const SUBMISSION_DESTINATIONS = {
46
+ android: "play-internal",
47
+ ios: "testflight"
48
+ };
49
+ /** `native_builds.build_number`'s bound in core's schema. */
50
+ const BUILD_NUMBER_MAX_LENGTH = 64;
51
+ /** `native_builds.submitted_version`'s bound in core's schema. */
52
+ const SUBMITTED_VERSION_MAX_LENGTH = 255;
53
+ /**
54
+ * A native build scope's key: the Release Catalog scope key of a
55
+ * fingerprint, or of the app-version strategy followed by the exact app
56
+ * version the build compiled in. One key serves both strategies, so one
57
+ * index finds the newest build of either.
58
+ */
59
+ const nativeBuildScopeKey = (scope) => {
60
+ let scopeKey;
61
+ try {
62
+ const channelKey = (0, _hot_updater_protocol.encodeChannelKey)(scope.channel);
63
+ scopeKey = scope.fingerprintHash === null ? `${(0, _hot_updater_protocol.createReleaseCatalogScopeKey)({
64
+ channelKey,
65
+ platform: scope.platform,
66
+ strategy: "APP_VERSION"
67
+ })}:${encodeURIComponent(scope.appVersion)}` : (0, _hot_updater_protocol.createReleaseCatalogScopeKey)({
68
+ channelKey,
69
+ fingerprintHash: scope.fingerprintHash,
70
+ platform: scope.platform,
71
+ strategy: "FINGERPRINT"
72
+ });
73
+ } catch (error) {
74
+ throw new NativeBuildInputError(`Invalid native build scope: ${error instanceof Error ? error.message : String(error)}.`);
75
+ }
76
+ if (scopeKey.length > SCOPE_KEY_MAX_LENGTH) throw new NativeBuildInputError(`Invalid native build scope: its key is longer than ${SCOPE_KEY_MAX_LENGTH} characters; shorten the channel or app version.`);
77
+ return scopeKey;
78
+ };
79
+ /** Whether `bundleId` is a native build's built-in bundle: one unique read. */
80
+ const isBuiltInBundle = async (db, bundleId) => await db.findOne("native_builds", { min_bundle_id: bundleId }) !== null;
81
+ const problemOf = ({ id, minBundleId, platform, channel, fingerprintHash, appVersion, gitCommitHash, buildKey, buildNumber, artifact }) => {
82
+ if (!(0, _hot_updater_plugin_core.isUUIDv7)(id)) return "id must be a UUIDv7";
83
+ if (!(0, _hot_updater_plugin_core.isUUIDv7)(minBundleId)) return "minBundleId must be the UUIDv7 a release build compiles in";
84
+ if (platform !== "ios" && platform !== "android") return "platform must be ios or android";
85
+ if (channel.trim().length === 0) return "channel is required";
86
+ if (gitCommitHash !== null && gitCommitHash.length > GIT_COMMIT_HASH_MAX_LENGTH) return `gitCommitHash must be at most ${GIT_COMMIT_HASH_MAX_LENGTH} characters`;
87
+ if (fingerprintHash === null === (appVersion === null)) return "exactly one of fingerprintHash and appVersion is required";
88
+ if (buildKey !== null && !BUILD_KEY.test(buildKey)) return "buildKey must be nbk1: and a SHA-256, or null";
89
+ if (buildNumber !== void 0 && buildNumber !== null && (buildNumber.trim().length === 0 || buildNumber.length > BUILD_NUMBER_MAX_LENGTH)) return `buildNumber must be the artifact's build number, up to ${BUILD_NUMBER_MAX_LENGTH} characters, or null`;
90
+ const kinds = ARTIFACT_KINDS[platform];
91
+ if (!kinds.includes(artifact.kind)) return `artifact.kind of an ${platform} build must be ${kinds.join(" or ")}`;
92
+ if (!Number.isSafeInteger(artifact.byteSize) || artifact.byteSize < 0) return "artifact.byteSize must be a byte count";
93
+ return null;
94
+ };
95
+ /** The native build registry: core's `native_builds` and the built-in bundle rows. */
96
+ const createNativeBuildApi = (db, { now = Date.now } = {}) => ({
97
+ async registerNativeBuild(registration) {
98
+ const problem = problemOf(registration);
99
+ if (problem !== null) throw new NativeBuildInputError(`Invalid native build: ${problem}.`);
100
+ const scopeKey = nativeBuildScopeKey(registration);
101
+ const channelId = `channel:${(0, _hot_updater_protocol.encodeChannelKey)(registration.channel)}`;
102
+ const { embeddedBundle } = registration;
103
+ const row = {
104
+ id: registration.id,
105
+ scope_key: scopeKey,
106
+ platform: registration.platform,
107
+ channel_id: channelId,
108
+ fingerprint_hash: registration.fingerprintHash,
109
+ app_version: registration.appVersion,
110
+ min_bundle_id: registration.minBundleId,
111
+ artifact_kind: registration.artifact.kind,
112
+ artifact_storage_uri: registration.artifact.storageUri,
113
+ byte_size: registration.artifact.byteSize,
114
+ git_commit_hash: registration.gitCommitHash,
115
+ created_at_ms: now(),
116
+ build_key: registration.buildKey,
117
+ build_number: registration.buildNumber ?? null,
118
+ submitted_to: null,
119
+ submitted_at_ms: null,
120
+ submitted_version: null
121
+ };
122
+ for (let attempt = 1;; attempt += 1) {
123
+ await require_writes.insertChannel(db, {
124
+ id: channelId,
125
+ name: registration.channel
126
+ });
127
+ try {
128
+ await db.transaction(async (tx) => {
129
+ tx.create("bundles", {
130
+ id: registration.minBundleId,
131
+ platform: registration.platform,
132
+ git_commit_hash: registration.gitCommitHash,
133
+ metadata: embeddedBundle.metadata ?? {},
134
+ manifest_storage_uri: embeddedBundle.manifestStorageUri,
135
+ manifest_file_hash: embeddedBundle.manifestFileHash,
136
+ asset_base_storage_uri: embeddedBundle.assetBaseStorageUri
137
+ });
138
+ tx.create("native_builds", row);
139
+ });
140
+ break;
141
+ } catch (error) {
142
+ if (!(error instanceof _hot_updater_plugin_core.DatabaseConstraintError)) throw error;
143
+ if (error.reason === "exists" || error.reason === "unique") throw new _hot_updater_plugin_core.DatabaseConstraintError(error.reason, "native_builds");
144
+ if (attempt < 3 && error.reason === "not_found" && error.model === "channels") continue;
145
+ throw error;
146
+ }
147
+ }
148
+ return row;
149
+ },
150
+ async getNativeBuild(id) {
151
+ const row = await db.findOne("native_builds", { id });
152
+ return row === null ? null : pick(row);
153
+ },
154
+ async findNativeBuild(scope, options) {
155
+ const scopeKey = nativeBuildScopeKey(scope);
156
+ const buildKey = options?.buildKey;
157
+ if (buildKey !== void 0 && !BUILD_KEY.test(buildKey)) throw new NativeBuildInputError("Invalid native build key: it must be nbk1: and a SHA-256.");
158
+ const { rows } = buildKey === void 0 ? await db.findMany("native_builds", {
159
+ index: "byScope",
160
+ where: { scope_key: scopeKey },
161
+ order: "desc",
162
+ limit: 1
163
+ }) : await db.findMany("native_builds", {
164
+ index: "byBuildKey",
165
+ where: {
166
+ scope_key: scopeKey,
167
+ build_key: buildKey
168
+ },
169
+ order: "desc",
170
+ limit: 1
171
+ });
172
+ return rows[0] === void 0 ? null : pick(rows[0]);
173
+ },
174
+ async recordNativeBuildSubmission(id, { destination, version }) {
175
+ if (version.trim().length === 0 || version.length > SUBMITTED_VERSION_MAX_LENGTH) throw new NativeBuildInputError(`Invalid native build submission: version must be the store's label of the build, up to ${SUBMITTED_VERSION_MAX_LENGTH} characters.`);
176
+ return db.transaction(async (tx) => {
177
+ const row = await tx.findOne("native_builds", { id });
178
+ if (row === null) return null;
179
+ const build = pick(row);
180
+ if (SUBMISSION_DESTINATIONS[build.platform] !== destination) throw new NativeBuildInputError(`Invalid native build submission: an ${build.platform} build is submitted to ${SUBMISSION_DESTINATIONS[build.platform]}, not ${destination}.`);
181
+ const set = {
182
+ submitted_to: destination,
183
+ submitted_at_ms: now(),
184
+ submitted_version: version
185
+ };
186
+ tx.update("native_builds", row, set);
187
+ return {
188
+ ...build,
189
+ ...set
190
+ };
191
+ });
192
+ },
193
+ async deleteNativeBuild(id) {
194
+ return db.transaction(async (tx) => {
195
+ const row = await tx.findOne("native_builds", { id });
196
+ if (row === null) return {
197
+ deleted: false,
198
+ reason: "not_found"
199
+ };
200
+ await tx.delete("native_builds", row);
201
+ const bundle = await tx.findOne("bundles", { id: row.min_bundle_id });
202
+ if (bundle !== null) await tx.delete("bundles", bundle);
203
+ return {
204
+ deleted: true,
205
+ nativeBuild: pick(row)
206
+ };
207
+ });
208
+ },
209
+ async listNativeBuilds(input) {
210
+ const page = {
211
+ order: input.order ?? "asc",
212
+ limit: input.limit
213
+ };
214
+ const { rows } = input.channelId === void 0 ? await db.findMany("native_builds", {
215
+ index: "all",
216
+ where: {},
217
+ ...page,
218
+ ...require_reads.after(input)
219
+ }) : await db.findMany("native_builds", {
220
+ index: "byChannel",
221
+ where: { channel_id: input.channelId },
222
+ ...page,
223
+ ...require_reads.after(input)
224
+ });
225
+ return rows.map(pick);
226
+ }
227
+ });
228
+ //#endregion
229
+ exports.NativeBuildInputError = NativeBuildInputError;
230
+ exports.createNativeBuildApi = createNativeBuildApi;
231
+ exports.isBuiltInBundle = isBuiltInBundle;
232
+ exports.nativeBuildScopeKey = nativeBuildScopeKey;
@@ -0,0 +1,229 @@
1
+ import { after } from "./reads.mjs";
2
+ import { insertChannel } from "./writes.mjs";
3
+ import { DatabaseConstraintError, isUUIDv7 } from "@hot-updater/plugin-core";
4
+ import { createReleaseCatalogScopeKey, encodeChannelKey } from "@hot-updater/protocol";
5
+ //#region src/core/nativeBuilds.ts
6
+ /** The artifacts each platform's build produces. */
7
+ const ARTIFACT_KINDS = {
8
+ android: ["apk", "aab"],
9
+ ios: ["ipa", "app"]
10
+ };
11
+ /** `native_builds.scope_key`'s bound in core's schema. */
12
+ const SCOPE_KEY_MAX_LENGTH = 2048;
13
+ /** The `git_commit_hash` bound of `bundles` and `native_builds`. */
14
+ const GIT_COMMIT_HASH_MAX_LENGTH = 255;
15
+ /**
16
+ * A native build key: `nbk1:` and a SHA-256, as the CLI computes it. The
17
+ * format is the CLI's convention, implemented here so the registry stores
18
+ * and looks up nothing else.
19
+ */
20
+ const BUILD_KEY = /^nbk1:[0-9a-f]{64}$/;
21
+ /** A native build or scope core cannot store or look up: the caller's input. */
22
+ var NativeBuildInputError = class extends TypeError {
23
+ name = "NativeBuildInputError";
24
+ };
25
+ const pick = (row) => ({
26
+ id: row.id,
27
+ scope_key: row.scope_key,
28
+ platform: row.platform,
29
+ channel_id: row.channel_id,
30
+ fingerprint_hash: row.fingerprint_hash ?? null,
31
+ app_version: row.app_version ?? null,
32
+ min_bundle_id: row.min_bundle_id,
33
+ artifact_kind: row.artifact_kind,
34
+ artifact_storage_uri: row.artifact_storage_uri,
35
+ byte_size: row.byte_size,
36
+ git_commit_hash: row.git_commit_hash ?? null,
37
+ created_at_ms: row.created_at_ms,
38
+ build_key: row.build_key ?? null,
39
+ build_number: row.build_number ?? null,
40
+ submitted_to: row.submitted_to ?? null,
41
+ submitted_at_ms: row.submitted_at_ms ?? null,
42
+ submitted_version: row.submitted_version ?? null
43
+ });
44
+ /** The store track each platform's builds are submitted to. */
45
+ const SUBMISSION_DESTINATIONS = {
46
+ android: "play-internal",
47
+ ios: "testflight"
48
+ };
49
+ /** `native_builds.build_number`'s bound in core's schema. */
50
+ const BUILD_NUMBER_MAX_LENGTH = 64;
51
+ /** `native_builds.submitted_version`'s bound in core's schema. */
52
+ const SUBMITTED_VERSION_MAX_LENGTH = 255;
53
+ /**
54
+ * A native build scope's key: the Release Catalog scope key of a
55
+ * fingerprint, or of the app-version strategy followed by the exact app
56
+ * version the build compiled in. One key serves both strategies, so one
57
+ * index finds the newest build of either.
58
+ */
59
+ const nativeBuildScopeKey = (scope) => {
60
+ let scopeKey;
61
+ try {
62
+ const channelKey = encodeChannelKey(scope.channel);
63
+ scopeKey = scope.fingerprintHash === null ? `${createReleaseCatalogScopeKey({
64
+ channelKey,
65
+ platform: scope.platform,
66
+ strategy: "APP_VERSION"
67
+ })}:${encodeURIComponent(scope.appVersion)}` : createReleaseCatalogScopeKey({
68
+ channelKey,
69
+ fingerprintHash: scope.fingerprintHash,
70
+ platform: scope.platform,
71
+ strategy: "FINGERPRINT"
72
+ });
73
+ } catch (error) {
74
+ throw new NativeBuildInputError(`Invalid native build scope: ${error instanceof Error ? error.message : String(error)}.`);
75
+ }
76
+ if (scopeKey.length > SCOPE_KEY_MAX_LENGTH) throw new NativeBuildInputError(`Invalid native build scope: its key is longer than ${SCOPE_KEY_MAX_LENGTH} characters; shorten the channel or app version.`);
77
+ return scopeKey;
78
+ };
79
+ /** Whether `bundleId` is a native build's built-in bundle: one unique read. */
80
+ const isBuiltInBundle = async (db, bundleId) => await db.findOne("native_builds", { min_bundle_id: bundleId }) !== null;
81
+ const problemOf = ({ id, minBundleId, platform, channel, fingerprintHash, appVersion, gitCommitHash, buildKey, buildNumber, artifact }) => {
82
+ if (!isUUIDv7(id)) return "id must be a UUIDv7";
83
+ if (!isUUIDv7(minBundleId)) return "minBundleId must be the UUIDv7 a release build compiles in";
84
+ if (platform !== "ios" && platform !== "android") return "platform must be ios or android";
85
+ if (channel.trim().length === 0) return "channel is required";
86
+ if (gitCommitHash !== null && gitCommitHash.length > GIT_COMMIT_HASH_MAX_LENGTH) return `gitCommitHash must be at most ${GIT_COMMIT_HASH_MAX_LENGTH} characters`;
87
+ if (fingerprintHash === null === (appVersion === null)) return "exactly one of fingerprintHash and appVersion is required";
88
+ if (buildKey !== null && !BUILD_KEY.test(buildKey)) return "buildKey must be nbk1: and a SHA-256, or null";
89
+ if (buildNumber !== void 0 && buildNumber !== null && (buildNumber.trim().length === 0 || buildNumber.length > BUILD_NUMBER_MAX_LENGTH)) return `buildNumber must be the artifact's build number, up to ${BUILD_NUMBER_MAX_LENGTH} characters, or null`;
90
+ const kinds = ARTIFACT_KINDS[platform];
91
+ if (!kinds.includes(artifact.kind)) return `artifact.kind of an ${platform} build must be ${kinds.join(" or ")}`;
92
+ if (!Number.isSafeInteger(artifact.byteSize) || artifact.byteSize < 0) return "artifact.byteSize must be a byte count";
93
+ return null;
94
+ };
95
+ /** The native build registry: core's `native_builds` and the built-in bundle rows. */
96
+ const createNativeBuildApi = (db, { now = Date.now } = {}) => ({
97
+ async registerNativeBuild(registration) {
98
+ const problem = problemOf(registration);
99
+ if (problem !== null) throw new NativeBuildInputError(`Invalid native build: ${problem}.`);
100
+ const scopeKey = nativeBuildScopeKey(registration);
101
+ const channelId = `channel:${encodeChannelKey(registration.channel)}`;
102
+ const { embeddedBundle } = registration;
103
+ const row = {
104
+ id: registration.id,
105
+ scope_key: scopeKey,
106
+ platform: registration.platform,
107
+ channel_id: channelId,
108
+ fingerprint_hash: registration.fingerprintHash,
109
+ app_version: registration.appVersion,
110
+ min_bundle_id: registration.minBundleId,
111
+ artifact_kind: registration.artifact.kind,
112
+ artifact_storage_uri: registration.artifact.storageUri,
113
+ byte_size: registration.artifact.byteSize,
114
+ git_commit_hash: registration.gitCommitHash,
115
+ created_at_ms: now(),
116
+ build_key: registration.buildKey,
117
+ build_number: registration.buildNumber ?? null,
118
+ submitted_to: null,
119
+ submitted_at_ms: null,
120
+ submitted_version: null
121
+ };
122
+ for (let attempt = 1;; attempt += 1) {
123
+ await insertChannel(db, {
124
+ id: channelId,
125
+ name: registration.channel
126
+ });
127
+ try {
128
+ await db.transaction(async (tx) => {
129
+ tx.create("bundles", {
130
+ id: registration.minBundleId,
131
+ platform: registration.platform,
132
+ git_commit_hash: registration.gitCommitHash,
133
+ metadata: embeddedBundle.metadata ?? {},
134
+ manifest_storage_uri: embeddedBundle.manifestStorageUri,
135
+ manifest_file_hash: embeddedBundle.manifestFileHash,
136
+ asset_base_storage_uri: embeddedBundle.assetBaseStorageUri
137
+ });
138
+ tx.create("native_builds", row);
139
+ });
140
+ break;
141
+ } catch (error) {
142
+ if (!(error instanceof DatabaseConstraintError)) throw error;
143
+ if (error.reason === "exists" || error.reason === "unique") throw new DatabaseConstraintError(error.reason, "native_builds");
144
+ if (attempt < 3 && error.reason === "not_found" && error.model === "channels") continue;
145
+ throw error;
146
+ }
147
+ }
148
+ return row;
149
+ },
150
+ async getNativeBuild(id) {
151
+ const row = await db.findOne("native_builds", { id });
152
+ return row === null ? null : pick(row);
153
+ },
154
+ async findNativeBuild(scope, options) {
155
+ const scopeKey = nativeBuildScopeKey(scope);
156
+ const buildKey = options?.buildKey;
157
+ if (buildKey !== void 0 && !BUILD_KEY.test(buildKey)) throw new NativeBuildInputError("Invalid native build key: it must be nbk1: and a SHA-256.");
158
+ const { rows } = buildKey === void 0 ? await db.findMany("native_builds", {
159
+ index: "byScope",
160
+ where: { scope_key: scopeKey },
161
+ order: "desc",
162
+ limit: 1
163
+ }) : await db.findMany("native_builds", {
164
+ index: "byBuildKey",
165
+ where: {
166
+ scope_key: scopeKey,
167
+ build_key: buildKey
168
+ },
169
+ order: "desc",
170
+ limit: 1
171
+ });
172
+ return rows[0] === void 0 ? null : pick(rows[0]);
173
+ },
174
+ async recordNativeBuildSubmission(id, { destination, version }) {
175
+ if (version.trim().length === 0 || version.length > SUBMITTED_VERSION_MAX_LENGTH) throw new NativeBuildInputError(`Invalid native build submission: version must be the store's label of the build, up to ${SUBMITTED_VERSION_MAX_LENGTH} characters.`);
176
+ return db.transaction(async (tx) => {
177
+ const row = await tx.findOne("native_builds", { id });
178
+ if (row === null) return null;
179
+ const build = pick(row);
180
+ if (SUBMISSION_DESTINATIONS[build.platform] !== destination) throw new NativeBuildInputError(`Invalid native build submission: an ${build.platform} build is submitted to ${SUBMISSION_DESTINATIONS[build.platform]}, not ${destination}.`);
181
+ const set = {
182
+ submitted_to: destination,
183
+ submitted_at_ms: now(),
184
+ submitted_version: version
185
+ };
186
+ tx.update("native_builds", row, set);
187
+ return {
188
+ ...build,
189
+ ...set
190
+ };
191
+ });
192
+ },
193
+ async deleteNativeBuild(id) {
194
+ return db.transaction(async (tx) => {
195
+ const row = await tx.findOne("native_builds", { id });
196
+ if (row === null) return {
197
+ deleted: false,
198
+ reason: "not_found"
199
+ };
200
+ await tx.delete("native_builds", row);
201
+ const bundle = await tx.findOne("bundles", { id: row.min_bundle_id });
202
+ if (bundle !== null) await tx.delete("bundles", bundle);
203
+ return {
204
+ deleted: true,
205
+ nativeBuild: pick(row)
206
+ };
207
+ });
208
+ },
209
+ async listNativeBuilds(input) {
210
+ const page = {
211
+ order: input.order ?? "asc",
212
+ limit: input.limit
213
+ };
214
+ const { rows } = input.channelId === void 0 ? await db.findMany("native_builds", {
215
+ index: "all",
216
+ where: {},
217
+ ...page,
218
+ ...after(input)
219
+ }) : await db.findMany("native_builds", {
220
+ index: "byChannel",
221
+ where: { channel_id: input.channelId },
222
+ ...page,
223
+ ...after(input)
224
+ });
225
+ return rows.map(pick);
226
+ }
227
+ });
228
+ //#endregion
229
+ export { NativeBuildInputError, createNativeBuildApi, isBuiltInBundle, nativeBuildScopeKey };
@@ -1,5 +1,6 @@
1
1
  const require_reads = require("./reads.cjs");
2
2
  const require_writes = require("./writes.cjs");
3
+ const require_nativeBuilds = require("./nativeBuilds.cjs");
3
4
  const require_releases = require("./releases.cjs");
4
5
  let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
5
6
  let _hot_updater_protocol = require("@hot-updater/protocol");
@@ -131,6 +132,7 @@ const createCoreOperations = (db, { now = Date.now } = {}) => {
131
132
  if (!("bundleId" in deployment) || platforms.has(deployment.bundleId)) continue;
132
133
  const row = await db.findOne("bundles", { id: deployment.bundleId });
133
134
  if (row === null) throw new _hot_updater_plugin_core.DatabaseBundleNotFoundError(deployment.bundleId);
135
+ if (await require_nativeBuilds.isBuiltInBundle(db, deployment.bundleId)) throw new _hot_updater_plugin_core.ReleaseManagementError("TARGET_RELEASE_INVALID", `Bundle "${deployment.bundleId}" is a native build's built-in bundle, which no release publishes.`);
134
136
  platforms.set(deployment.bundleId, require_reads.toBundleRow(row).platform);
135
137
  }
136
138
  const updatedAtMs = now();
@@ -326,7 +328,8 @@ const createCoreOperations = (db, { now = Date.now } = {}) => {
326
328
  id
327
329
  };
328
330
  const { id: _id, ...set } = (0, _hot_updater_plugin_core.bundleToRow)(next);
329
- require_writes.updateBundle(tx, current, set);
331
+ const counted = set.platform === current.platform || !await require_nativeBuilds.isBuiltInBundle(tx, id);
332
+ require_writes.updateBundle(tx, current, set, { counted });
330
333
  if (patches !== void 0) await require_writes.replaceBundlePatches(tx, current, (0, _hot_updater_plugin_core.bundleToPatchRows)({
331
334
  ...next,
332
335
  patches
@@ -337,7 +340,9 @@ const createCoreOperations = (db, { now = Date.now } = {}) => {
337
340
  await db.transaction(async (tx) => {
338
341
  for (const id of new Set(ids)) {
339
342
  const current = await tx.findOne("bundles", { id });
340
- if (current !== null) await require_writes.deleteBundle(tx, current);
343
+ if (current === null) continue;
344
+ if (await require_nativeBuilds.isBuiltInBundle(tx, id)) throw new _hot_updater_plugin_core.DatabaseRowReferencedError();
345
+ await require_writes.deleteBundle(tx, current);
341
346
  }
342
347
  });
343
348
  } catch (error) {
@@ -1,5 +1,6 @@
1
1
  import { toBundleRow, toReleaseRow } from "./reads.mjs";
2
2
  import { deleteBundle, deleteChannel, insertChannel, replaceBundlePatches, updateBundle } from "./writes.mjs";
3
+ import { isBuiltInBundle } from "./nativeBuilds.mjs";
3
4
  import { changeRelease, changeReleases, readCatalog, rebuildScope } from "./releases.mjs";
4
5
  import { DatabaseBundleNotFoundError, DatabaseConstraintError, DatabaseRowReferencedError, ReleaseCatalogMutationError, ReleaseManagementError, bundleToPatchRows, bundleToRow, createUUIDv7After, isUUIDv7, rowToBundle } from "@hot-updater/plugin-core";
5
6
  import { createReleaseCatalogScopeKey, decodeChannelKey, encodeChannelKey } from "@hot-updater/protocol";
@@ -131,6 +132,7 @@ const createCoreOperations = (db, { now = Date.now } = {}) => {
131
132
  if (!("bundleId" in deployment) || platforms.has(deployment.bundleId)) continue;
132
133
  const row = await db.findOne("bundles", { id: deployment.bundleId });
133
134
  if (row === null) throw new DatabaseBundleNotFoundError(deployment.bundleId);
135
+ if (await isBuiltInBundle(db, deployment.bundleId)) throw new ReleaseManagementError("TARGET_RELEASE_INVALID", `Bundle "${deployment.bundleId}" is a native build's built-in bundle, which no release publishes.`);
134
136
  platforms.set(deployment.bundleId, toBundleRow(row).platform);
135
137
  }
136
138
  const updatedAtMs = now();
@@ -326,7 +328,8 @@ const createCoreOperations = (db, { now = Date.now } = {}) => {
326
328
  id
327
329
  };
328
330
  const { id: _id, ...set } = bundleToRow(next);
329
- updateBundle(tx, current, set);
331
+ const counted = set.platform === current.platform || !await isBuiltInBundle(tx, id);
332
+ updateBundle(tx, current, set, { counted });
330
333
  if (patches !== void 0) await replaceBundlePatches(tx, current, bundleToPatchRows({
331
334
  ...next,
332
335
  patches
@@ -337,7 +340,9 @@ const createCoreOperations = (db, { now = Date.now } = {}) => {
337
340
  await db.transaction(async (tx) => {
338
341
  for (const id of new Set(ids)) {
339
342
  const current = await tx.findOne("bundles", { id });
340
- if (current !== null) await deleteBundle(tx, current);
343
+ if (current === null) continue;
344
+ if (await isBuiltInBundle(tx, id)) throw new DatabaseRowReferencedError();
345
+ await deleteBundle(tx, current);
341
346
  }
342
347
  });
343
348
  } catch (error) {
@@ -73,6 +73,8 @@ const drain = async (read) => {
73
73
  } while (cursor !== void 0);
74
74
  return rows;
75
75
  };
76
+ /** A keyset page's range: the keys strictly after `after`, in `order`. */
77
+ const after = (input) => input.after === void 0 ? {} : { range: (input.order ?? "asc") === "asc" ? { gt: input.after } : { lt: input.after } };
76
78
  /** Core's reads, each through one declared index or key. */
77
79
  const createCoreReads = (db, storage) => {
78
80
  /** A bundle row's patches, exactly as many as its reference counter holds. */
@@ -97,7 +99,6 @@ const createCoreReads = (db, storage) => {
97
99
  childCount: row._refs_bundle_patches_base_bundle_id ?? 0
98
100
  };
99
101
  };
100
- const after = (input) => input.after === void 0 ? {} : { range: (input.order ?? "asc") === "asc" ? { gt: input.after } : { lt: input.after } };
101
102
  return {
102
103
  /** The update check: one point read of its scope's catalog. */
103
104
  async getReleaseCatalog(input) {
@@ -278,6 +279,7 @@ const createCoreReads = (db, storage) => {
278
279
  };
279
280
  };
280
281
  //#endregion
282
+ exports.after = after;
281
283
  exports.createCoreReads = createCoreReads;
282
284
  exports.drain = drain;
283
285
  exports.toBundleRow = toBundleRow;
@@ -73,6 +73,8 @@ const drain = async (read) => {
73
73
  } while (cursor !== void 0);
74
74
  return rows;
75
75
  };
76
+ /** A keyset page's range: the keys strictly after `after`, in `order`. */
77
+ const after = (input) => input.after === void 0 ? {} : { range: (input.order ?? "asc") === "asc" ? { gt: input.after } : { lt: input.after } };
76
78
  /** Core's reads, each through one declared index or key. */
77
79
  const createCoreReads = (db, storage) => {
78
80
  /** A bundle row's patches, exactly as many as its reference counter holds. */
@@ -97,7 +99,6 @@ const createCoreReads = (db, storage) => {
97
99
  childCount: row._refs_bundle_patches_base_bundle_id ?? 0
98
100
  };
99
101
  };
100
- const after = (input) => input.after === void 0 ? {} : { range: (input.order ?? "asc") === "asc" ? { gt: input.after } : { lt: input.after } };
101
102
  return {
102
103
  /** The update check: one point read of its scope's catalog. */
103
104
  async getReleaseCatalog(input) {
@@ -278,4 +279,4 @@ const createCoreReads = (db, storage) => {
278
279
  };
279
280
  };
280
281
  //#endregion
281
- export { createCoreReads, drain, toBundleRow, toCatalogRow, toChannelRow, toPatchRow, toReleaseRow };
282
+ export { after, createCoreReads, drain, toBundleRow, toCatalogRow, toChannelRow, toPatchRow, toReleaseRow };
@@ -10,9 +10,13 @@ const insertBundle = (tx, row) => {
10
10
  tx.create("bundles", row);
11
11
  countBundle(tx, row.platform, 1);
12
12
  };
13
- const updateBundle = (tx, current, set) => {
13
+ /**
14
+ * Updates a bundle; a platform change moves it between platform totals,
15
+ * except for a native build's built-in bundle, which they never count.
16
+ */
17
+ const updateBundle = (tx, current, set, { counted = true } = {}) => {
14
18
  tx.update("bundles", current, set);
15
- if (set.platform !== void 0 && set.platform !== current.platform) {
19
+ if (counted && set.platform !== void 0 && set.platform !== current.platform) {
16
20
  countBundle(tx, current.platform, -1);
17
21
  countBundle(tx, set.platform, 1);
18
22
  }
@@ -73,7 +77,7 @@ const insertChannel = async (db, row) => {
73
77
  throw error;
74
78
  }
75
79
  };
76
- /** Deletes a channel no release uses: 1 read + 1 write. */
80
+ /** Deletes a channel no release or native build uses: 1 read, 1 query, 1 write. */
77
81
  const deleteChannel = (db, id) => db.transaction(async (tx) => {
78
82
  const row = await tx.findOne("channels", { id });
79
83
  if (row === null) return {
@@ -84,6 +88,15 @@ const deleteChannel = (db, id) => db.transaction(async (tx) => {
84
88
  deleted: false,
85
89
  reason: "not_empty"
86
90
  };
91
+ const { rows: builds } = await tx.findMany("native_builds", {
92
+ index: "byChannel",
93
+ where: { channel_id: id },
94
+ limit: 1
95
+ });
96
+ if (builds.length > 0) return {
97
+ deleted: false,
98
+ reason: "not_empty"
99
+ };
87
100
  await tx.delete("channels", row);
88
101
  return { deleted: true };
89
102
  });
@@ -10,9 +10,13 @@ const insertBundle = (tx, row) => {
10
10
  tx.create("bundles", row);
11
11
  countBundle(tx, row.platform, 1);
12
12
  };
13
- const updateBundle = (tx, current, set) => {
13
+ /**
14
+ * Updates a bundle; a platform change moves it between platform totals,
15
+ * except for a native build's built-in bundle, which they never count.
16
+ */
17
+ const updateBundle = (tx, current, set, { counted = true } = {}) => {
14
18
  tx.update("bundles", current, set);
15
- if (set.platform !== void 0 && set.platform !== current.platform) {
19
+ if (counted && set.platform !== void 0 && set.platform !== current.platform) {
16
20
  countBundle(tx, current.platform, -1);
17
21
  countBundle(tx, set.platform, 1);
18
22
  }
@@ -73,7 +77,7 @@ const insertChannel = async (db, row) => {
73
77
  throw error;
74
78
  }
75
79
  };
76
- /** Deletes a channel no release uses: 1 read + 1 write. */
80
+ /** Deletes a channel no release or native build uses: 1 read, 1 query, 1 write. */
77
81
  const deleteChannel = (db, id) => db.transaction(async (tx) => {
78
82
  const row = await tx.findOne("channels", { id });
79
83
  if (row === null) return {
@@ -84,6 +88,15 @@ const deleteChannel = (db, id) => db.transaction(async (tx) => {
84
88
  deleted: false,
85
89
  reason: "not_empty"
86
90
  };
91
+ const { rows: builds } = await tx.findMany("native_builds", {
92
+ index: "byChannel",
93
+ where: { channel_id: id },
94
+ limit: 1
95
+ });
96
+ if (builds.length > 0) return {
97
+ deleted: false,
98
+ reason: "not_empty"
99
+ };
87
100
  await tx.delete("channels", row);
88
101
  return { deleted: true };
89
102
  });
@@ -1,3 +1,4 @@
1
+ const require_nativeBuilds = require("./core/nativeBuilds.cjs");
1
2
  const require_handlerErrors = require("./handlerErrors.cjs");
2
3
  const require_handlerParameters = require("./handlerParameters.cjs");
3
4
  let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
@@ -27,6 +28,10 @@ const answer = async (operation) => {
27
28
  code: "REFERENCED",
28
29
  error: error.message
29
30
  }, 409);
31
+ if (error instanceof _hot_updater_plugin_core.DatabaseConstraintError && error.model === "native_builds") return json({
32
+ code: "NATIVE_BUILD_EXISTS",
33
+ error: "A native build with this ID or built-in bundle ID is already registered."
34
+ }, 409);
30
35
  throw error;
31
36
  }
32
37
  };
@@ -129,6 +134,42 @@ const bundleUpdateOf = (value, bundleId) => {
129
134
  if (id !== void 0 && id !== bundleId) throw new require_handlerErrors.HandlerBadRequestError("Bundle id mismatch");
130
135
  return update;
131
136
  };
137
+ const NATIVE_BUILD_ARTIFACT_KINDS = /* @__PURE__ */ new Set([
138
+ "apk",
139
+ "aab",
140
+ "ipa",
141
+ "app"
142
+ ]);
143
+ /** A native build's scope: `fingerprintHash` or `appVersion`, never both. */
144
+ const nativeBuildScopeOf = (url) => {
145
+ const params = url.searchParams;
146
+ const platform = platformOf(url);
147
+ const channel = params.get("channel");
148
+ const fingerprintHash = params.get("fingerprintHash");
149
+ const appVersion = params.get("appVersion");
150
+ if (platform === void 0 || !isText(channel) || fingerprintHash === null === (appVersion === null) || fingerprintHash === "" || appVersion === "") throw new require_handlerErrors.HandlerBadRequestError("A native build lookup needs platform, channel, and fingerprintHash or appVersion.");
151
+ return fingerprintHash !== null ? {
152
+ platform,
153
+ channel,
154
+ fingerprintHash,
155
+ appVersion: null
156
+ } : {
157
+ platform,
158
+ channel,
159
+ fingerprintHash: null,
160
+ appVersion
161
+ };
162
+ };
163
+ /** A native build or scope core refuses: a bad request. */
164
+ const refusingInvalid = async (operation) => {
165
+ try {
166
+ return await operation();
167
+ } catch (error) {
168
+ if (error instanceof require_nativeBuilds.NativeBuildInputError) throw new require_handlerErrors.HandlerBadRequestError(error.message);
169
+ throw error;
170
+ }
171
+ };
172
+ const isNativeBuildRegistration = (value) => isRecord(value) && isText(value.id) && isText(value.minBundleId) && typeof value.platform === "string" && require_handlerParameters.isPlatform(value.platform) && isText(value.channel) && isTextOrNull(value.fingerprintHash) && isTextOrNull(value.appVersion) && value.fingerprintHash === null !== (value.appVersion === null) && isTextOrNull(value.gitCommitHash) && isTextOrNull(value.buildKey) && (value.buildNumber === void 0 || isTextOrNull(value.buildNumber)) && isRecord(value.artifact) && typeof value.artifact.kind === "string" && NATIVE_BUILD_ARTIFACT_KINDS.has(value.artifact.kind) && isText(value.artifact.storageUri) && Number.isSafeInteger(value.artifact.byteSize) && value.artifact.byteSize >= 0 && isRecord(value.embeddedBundle) && isText(value.embeddedBundle.manifestStorageUri) && isText(value.embeddedBundle.manifestFileHash) && isText(value.embeddedBundle.assetBaseStorageUri) && (value.embeddedBundle.metadata === void 0 || isRecord(value.embeddedBundle.metadata));
132
173
  const scopeKeyOf = (params) => require_handlerParameters.decodeMaybe(require_handlerParameters.requireRouteParam(params, "scopeKey")) ?? "";
133
174
  /** Every admin route's handler, by the name `ADMIN_ROUTES` mounts it under. */
134
175
  const createAdminRouteHandlers = () => ({
@@ -248,6 +289,37 @@ const createAdminRouteHandlers = () => ({
248
289
  }) });
249
290
  }),
250
291
  preflightReleaseCatalogRebuild: onCore(async (core, _request, params) => json({ data: await core.preflightReleaseCatalogRebuild(scopeKeyOf(params)) })),
292
+ registerNativeBuild: onCore(async (core, request) => {
293
+ const input = await body(request);
294
+ if (!isNativeBuildRegistration(input)) throw new require_handlerErrors.HandlerBadRequestError("Invalid native build");
295
+ return json({ data: await refusingInvalid(() => core.registerNativeBuild(input)) }, 201);
296
+ }),
297
+ findNativeBuild: onCore(async (core, request) => {
298
+ const url = new URL(request.url);
299
+ const scope = nativeBuildScopeOf(url);
300
+ const buildKey = url.searchParams.get("buildKey");
301
+ return json({ data: await refusingInvalid(() => buildKey === null ? core.findNativeBuild(scope) : core.findNativeBuild(scope, { buildKey })) });
302
+ }),
303
+ getNativeBuild: onCore(async (core, _request, params) => json({ data: await core.getNativeBuild(require_handlerParameters.requireRouteParam(params, "id")) })),
304
+ listNativeBuilds: onCore(async (core, request) => {
305
+ const url = new URL(request.url);
306
+ const input = keyset(url);
307
+ const channelId = url.searchParams.get("channelId");
308
+ return page(await core.listNativeBuilds({
309
+ ...input,
310
+ ...channelId === null ? {} : { channelId }
311
+ }), input, ({ id }) => id);
312
+ }),
313
+ recordNativeBuildSubmission: onCore(async (core, request, params) => {
314
+ const input = await body(request);
315
+ if (!isRecord(input) || input.destination !== "testflight" && input.destination !== "play-internal" || !isText(input.version)) throw new require_handlerErrors.HandlerBadRequestError("A native build submission needs destination (testflight or play-internal) and version.");
316
+ const submission = {
317
+ destination: input.destination,
318
+ version: input.version
319
+ };
320
+ return json({ data: await refusingInvalid(() => core.recordNativeBuildSubmission(require_handlerParameters.requireRouteParam(params, "id"), submission)) });
321
+ }),
322
+ deleteNativeBuild: onCore(async (core, _request, params) => json({ data: await core.deleteNativeBuild(require_handlerParameters.requireRouteParam(params, "id")) })),
251
323
  deleteBundles: onCore(async (core, request) => {
252
324
  const { ids } = await body(request);
253
325
  if (!Array.isArray(ids) || !ids.every((id) => typeof id === "string" && id.length > 0)) throw new require_handlerErrors.HandlerBadRequestError("Bundle deletion needs ids");
@@ -366,6 +438,36 @@ const ADMIN_ROUTES = [
366
438
  method: "GET",
367
439
  path: "/base-candidates/:candidateKey",
368
440
  handler: "findBaseCandidates"
441
+ },
442
+ {
443
+ method: "POST",
444
+ path: "/native-builds",
445
+ handler: "registerNativeBuild"
446
+ },
447
+ {
448
+ method: "GET",
449
+ path: "/native-builds/latest",
450
+ handler: "findNativeBuild"
451
+ },
452
+ {
453
+ method: "GET",
454
+ path: "/native-builds/:id",
455
+ handler: "getNativeBuild"
456
+ },
457
+ {
458
+ method: "GET",
459
+ path: "/native-builds",
460
+ handler: "listNativeBuilds"
461
+ },
462
+ {
463
+ method: "POST",
464
+ path: "/native-builds/:id/submission",
465
+ handler: "recordNativeBuildSubmission"
466
+ },
467
+ {
468
+ method: "DELETE",
469
+ path: "/native-builds/:id",
470
+ handler: "deleteNativeBuild"
369
471
  }
370
472
  ];
371
473
  //#endregion
@@ -1,6 +1,7 @@
1
+ import { NativeBuildInputError } from "./core/nativeBuilds.mjs";
1
2
  import { HandlerBadRequestError } from "./handlerErrors.mjs";
2
3
  import { decodeMaybe, isPlatform, parseBooleanSearchParam, requireRouteParam } from "./handlerParameters.mjs";
3
- import { DatabaseBundleNotFoundError, DatabaseRowReferencedError, ReleaseCatalogMutationError, ReleaseManagementError } from "@hot-updater/plugin-core";
4
+ import { DatabaseBundleNotFoundError, DatabaseConstraintError, DatabaseRowReferencedError, ReleaseCatalogMutationError, ReleaseManagementError } from "@hot-updater/plugin-core";
4
5
  //#region src/handlerAdminRoutes.ts
5
6
  const json = (body, status = 200) => Response.json(body, { status });
6
7
  const notFound = () => json({ error: "Not found" }, 404);
@@ -27,6 +28,10 @@ const answer = async (operation) => {
27
28
  code: "REFERENCED",
28
29
  error: error.message
29
30
  }, 409);
31
+ if (error instanceof DatabaseConstraintError && error.model === "native_builds") return json({
32
+ code: "NATIVE_BUILD_EXISTS",
33
+ error: "A native build with this ID or built-in bundle ID is already registered."
34
+ }, 409);
30
35
  throw error;
31
36
  }
32
37
  };
@@ -129,6 +134,42 @@ const bundleUpdateOf = (value, bundleId) => {
129
134
  if (id !== void 0 && id !== bundleId) throw new HandlerBadRequestError("Bundle id mismatch");
130
135
  return update;
131
136
  };
137
+ const NATIVE_BUILD_ARTIFACT_KINDS = /* @__PURE__ */ new Set([
138
+ "apk",
139
+ "aab",
140
+ "ipa",
141
+ "app"
142
+ ]);
143
+ /** A native build's scope: `fingerprintHash` or `appVersion`, never both. */
144
+ const nativeBuildScopeOf = (url) => {
145
+ const params = url.searchParams;
146
+ const platform = platformOf(url);
147
+ const channel = params.get("channel");
148
+ const fingerprintHash = params.get("fingerprintHash");
149
+ const appVersion = params.get("appVersion");
150
+ if (platform === void 0 || !isText(channel) || fingerprintHash === null === (appVersion === null) || fingerprintHash === "" || appVersion === "") throw new HandlerBadRequestError("A native build lookup needs platform, channel, and fingerprintHash or appVersion.");
151
+ return fingerprintHash !== null ? {
152
+ platform,
153
+ channel,
154
+ fingerprintHash,
155
+ appVersion: null
156
+ } : {
157
+ platform,
158
+ channel,
159
+ fingerprintHash: null,
160
+ appVersion
161
+ };
162
+ };
163
+ /** A native build or scope core refuses: a bad request. */
164
+ const refusingInvalid = async (operation) => {
165
+ try {
166
+ return await operation();
167
+ } catch (error) {
168
+ if (error instanceof NativeBuildInputError) throw new HandlerBadRequestError(error.message);
169
+ throw error;
170
+ }
171
+ };
172
+ const isNativeBuildRegistration = (value) => isRecord(value) && isText(value.id) && isText(value.minBundleId) && typeof value.platform === "string" && isPlatform(value.platform) && isText(value.channel) && isTextOrNull(value.fingerprintHash) && isTextOrNull(value.appVersion) && value.fingerprintHash === null !== (value.appVersion === null) && isTextOrNull(value.gitCommitHash) && isTextOrNull(value.buildKey) && (value.buildNumber === void 0 || isTextOrNull(value.buildNumber)) && isRecord(value.artifact) && typeof value.artifact.kind === "string" && NATIVE_BUILD_ARTIFACT_KINDS.has(value.artifact.kind) && isText(value.artifact.storageUri) && Number.isSafeInteger(value.artifact.byteSize) && value.artifact.byteSize >= 0 && isRecord(value.embeddedBundle) && isText(value.embeddedBundle.manifestStorageUri) && isText(value.embeddedBundle.manifestFileHash) && isText(value.embeddedBundle.assetBaseStorageUri) && (value.embeddedBundle.metadata === void 0 || isRecord(value.embeddedBundle.metadata));
132
173
  const scopeKeyOf = (params) => decodeMaybe(requireRouteParam(params, "scopeKey")) ?? "";
133
174
  /** Every admin route's handler, by the name `ADMIN_ROUTES` mounts it under. */
134
175
  const createAdminRouteHandlers = () => ({
@@ -248,6 +289,37 @@ const createAdminRouteHandlers = () => ({
248
289
  }) });
249
290
  }),
250
291
  preflightReleaseCatalogRebuild: onCore(async (core, _request, params) => json({ data: await core.preflightReleaseCatalogRebuild(scopeKeyOf(params)) })),
292
+ registerNativeBuild: onCore(async (core, request) => {
293
+ const input = await body(request);
294
+ if (!isNativeBuildRegistration(input)) throw new HandlerBadRequestError("Invalid native build");
295
+ return json({ data: await refusingInvalid(() => core.registerNativeBuild(input)) }, 201);
296
+ }),
297
+ findNativeBuild: onCore(async (core, request) => {
298
+ const url = new URL(request.url);
299
+ const scope = nativeBuildScopeOf(url);
300
+ const buildKey = url.searchParams.get("buildKey");
301
+ return json({ data: await refusingInvalid(() => buildKey === null ? core.findNativeBuild(scope) : core.findNativeBuild(scope, { buildKey })) });
302
+ }),
303
+ getNativeBuild: onCore(async (core, _request, params) => json({ data: await core.getNativeBuild(requireRouteParam(params, "id")) })),
304
+ listNativeBuilds: onCore(async (core, request) => {
305
+ const url = new URL(request.url);
306
+ const input = keyset(url);
307
+ const channelId = url.searchParams.get("channelId");
308
+ return page(await core.listNativeBuilds({
309
+ ...input,
310
+ ...channelId === null ? {} : { channelId }
311
+ }), input, ({ id }) => id);
312
+ }),
313
+ recordNativeBuildSubmission: onCore(async (core, request, params) => {
314
+ const input = await body(request);
315
+ if (!isRecord(input) || input.destination !== "testflight" && input.destination !== "play-internal" || !isText(input.version)) throw new HandlerBadRequestError("A native build submission needs destination (testflight or play-internal) and version.");
316
+ const submission = {
317
+ destination: input.destination,
318
+ version: input.version
319
+ };
320
+ return json({ data: await refusingInvalid(() => core.recordNativeBuildSubmission(requireRouteParam(params, "id"), submission)) });
321
+ }),
322
+ deleteNativeBuild: onCore(async (core, _request, params) => json({ data: await core.deleteNativeBuild(requireRouteParam(params, "id")) })),
251
323
  deleteBundles: onCore(async (core, request) => {
252
324
  const { ids } = await body(request);
253
325
  if (!Array.isArray(ids) || !ids.every((id) => typeof id === "string" && id.length > 0)) throw new HandlerBadRequestError("Bundle deletion needs ids");
@@ -366,6 +438,36 @@ const ADMIN_ROUTES = [
366
438
  method: "GET",
367
439
  path: "/base-candidates/:candidateKey",
368
440
  handler: "findBaseCandidates"
441
+ },
442
+ {
443
+ method: "POST",
444
+ path: "/native-builds",
445
+ handler: "registerNativeBuild"
446
+ },
447
+ {
448
+ method: "GET",
449
+ path: "/native-builds/latest",
450
+ handler: "findNativeBuild"
451
+ },
452
+ {
453
+ method: "GET",
454
+ path: "/native-builds/:id",
455
+ handler: "getNativeBuild"
456
+ },
457
+ {
458
+ method: "GET",
459
+ path: "/native-builds",
460
+ handler: "listNativeBuilds"
461
+ },
462
+ {
463
+ method: "POST",
464
+ path: "/native-builds/:id/submission",
465
+ handler: "recordNativeBuildSubmission"
466
+ },
467
+ {
468
+ method: "DELETE",
469
+ path: "/native-builds/:id",
470
+ handler: "deleteNativeBuild"
369
471
  }
370
472
  ];
371
473
  //#endregion
package/dist/package.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "1.1.3";
2
+ var version = "1.2.0-rc.0";
3
3
  //#endregion
4
4
  Object.defineProperty(exports, "version", {
5
5
  enumerable: true,
package/dist/package.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "1.1.3";
2
+ var version = "1.2.0-rc.0";
3
3
  //#endregion
4
4
  export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/server",
3
- "version": "1.1.3",
3
+ "version": "1.2.0-rc.0",
4
4
  "type": "module",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "sideEffects": false,
@@ -50,11 +50,11 @@
50
50
  "dependencies": {
51
51
  "rou3": "0.7.9",
52
52
  "verkit": "0.4.0",
53
- "@hot-updater/protocol": "1.1.3",
54
- "@hot-updater/plugin-core": "1.1.3",
55
- "@hot-updater/plugin-api-keys": "1.1.3",
56
- "@hot-updater/plugin-insights": "1.1.3",
57
- "@hot-updater/plugin-remote-config": "1.1.3"
53
+ "@hot-updater/protocol": "1.2.0-rc.0",
54
+ "@hot-updater/plugin-core": "1.2.0-rc.0",
55
+ "@hot-updater/plugin-api-keys": "1.2.0-rc.0",
56
+ "@hot-updater/plugin-insights": "1.2.0-rc.0",
57
+ "@hot-updater/plugin-remote-config": "1.2.0-rc.0"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@prisma/client": "*",
@@ -95,8 +95,8 @@
95
95
  "prisma": "6.19.3",
96
96
  "uuidv7": "^1.0.2",
97
97
  "wrangler": "^4.69.0",
98
- "@hot-updater/test-utils": "1.1.3",
99
- "@hot-updater/standalone": "1.1.3"
98
+ "@hot-updater/standalone": "1.2.0-rc.0",
99
+ "@hot-updater/test-utils": "1.2.0-rc.0"
100
100
  },
101
101
  "nx": {
102
102
  "implicitDependencies": [