@hot-updater/server 0.29.2 → 0.29.4

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/db/index.cjs CHANGED
@@ -21,7 +21,10 @@ function createHotUpdater(options) {
21
21
  return resolveStoragePluginUrl(storageUri, context);
22
22
  };
23
23
  const database = options.database;
24
- const core = require_types.isDatabasePluginFactory(database) || require_types.isDatabasePlugin(database) ? require_pluginCore.createPluginDatabaseCore(require_types.isDatabasePluginFactory(database) ? database() : database, resolveFileUrl) : require_ormCore.createOrmDatabaseCore({
24
+ const core = require_types.isDatabasePluginFactory(database) || require_types.isDatabasePlugin(database) ? (() => {
25
+ const plugin = require_types.isDatabasePluginFactory(database) ? database() : database;
26
+ return require_pluginCore.createPluginDatabaseCore(() => plugin, resolveFileUrl, require_types.isDatabasePluginFactory(database) ? { createMutationPlugin: () => database() } : void 0);
27
+ })() : require_ormCore.createOrmDatabaseCore({
25
28
  database,
26
29
  resolveFileUrl
27
30
  });
package/dist/db/index.mjs CHANGED
@@ -21,7 +21,10 @@ function createHotUpdater(options) {
21
21
  return resolveStoragePluginUrl(storageUri, context);
22
22
  };
23
23
  const database = options.database;
24
- const core = isDatabasePluginFactory(database) || isDatabasePlugin(database) ? createPluginDatabaseCore(isDatabasePluginFactory(database) ? database() : database, resolveFileUrl) : createOrmDatabaseCore({
24
+ const core = isDatabasePluginFactory(database) || isDatabasePlugin(database) ? (() => {
25
+ const plugin = isDatabasePluginFactory(database) ? database() : database;
26
+ return createPluginDatabaseCore(() => plugin, resolveFileUrl, isDatabasePluginFactory(database) ? { createMutationPlugin: () => database() } : void 0);
27
+ })() : createOrmDatabaseCore({
25
28
  database,
26
29
  resolveFileUrl
27
30
  });
@@ -46,9 +46,17 @@ const INIT_BUNDLE_ROLLBACK_UPDATE_INFO = {
46
46
  storageUri: null,
47
47
  fileHash: null
48
48
  };
49
- function createPluginDatabaseCore(plugin, resolveFileUrl) {
49
+ function createPluginDatabaseCore(getPlugin, resolveFileUrl, options) {
50
+ const runWithMutationPlugin = async (operation) => {
51
+ const plugin = options?.createMutationPlugin?.() ?? getPlugin();
52
+ try {
53
+ return await operation(plugin);
54
+ } finally {
55
+ if (options?.createMutationPlugin) await options.cleanupMutationPlugin?.(plugin);
56
+ }
57
+ };
50
58
  const getSortedBundlePage = async (options, context) => {
51
- const result = await plugin.getBundles({
59
+ const result = await getPlugin().getBundles({
52
60
  ...options,
53
61
  orderBy: options.orderBy ?? DESC_ORDER
54
62
  }, context);
@@ -102,7 +110,7 @@ function createPluginDatabaseCore(plugin, resolveFileUrl) {
102
110
  return {
103
111
  api: {
104
112
  async getBundleById(id, context) {
105
- return plugin.getBundleById(id, context);
113
+ return getPlugin().getBundleById(id, context);
106
114
  },
107
115
  async getUpdateInfo(args, context) {
108
116
  const channel = args.channel ?? "production";
@@ -143,27 +151,33 @@ function createPluginDatabaseCore(plugin, resolveFileUrl) {
143
151
  };
144
152
  },
145
153
  async getChannels(context) {
146
- return plugin.getChannels(context);
154
+ return getPlugin().getChannels(context);
147
155
  },
148
156
  async getBundles(options, context) {
149
- return plugin.getBundles(options, context);
157
+ return getPlugin().getBundles(options, context);
150
158
  },
151
159
  async insertBundle(bundle, context) {
152
- await plugin.appendBundle(bundle, context);
153
- await plugin.commitBundle(context);
160
+ await runWithMutationPlugin(async (plugin) => {
161
+ await plugin.appendBundle(bundle, context);
162
+ await plugin.commitBundle(context);
163
+ });
154
164
  },
155
165
  async updateBundleById(bundleId, newBundle, context) {
156
- await plugin.updateBundle(bundleId, newBundle, context);
157
- await plugin.commitBundle(context);
166
+ await runWithMutationPlugin(async (plugin) => {
167
+ await plugin.updateBundle(bundleId, newBundle, context);
168
+ await plugin.commitBundle(context);
169
+ });
158
170
  },
159
171
  async deleteBundleById(bundleId, context) {
160
- const bundle = await plugin.getBundleById(bundleId, context);
161
- if (!bundle) return;
162
- await plugin.deleteBundle(bundle, context);
163
- await plugin.commitBundle(context);
172
+ await runWithMutationPlugin(async (plugin) => {
173
+ const bundle = await plugin.getBundleById(bundleId, context);
174
+ if (!bundle) return;
175
+ await plugin.deleteBundle(bundle, context);
176
+ await plugin.commitBundle(context);
177
+ });
164
178
  }
165
179
  },
166
- adapterName: plugin.name,
180
+ adapterName: getPlugin().name,
167
181
  createMigrator: () => {
168
182
  throw new Error("createMigrator is only available for Kysely/Prisma/Drizzle database adapters.");
169
183
  },
@@ -46,9 +46,17 @@ const INIT_BUNDLE_ROLLBACK_UPDATE_INFO = {
46
46
  storageUri: null,
47
47
  fileHash: null
48
48
  };
49
- function createPluginDatabaseCore(plugin, resolveFileUrl) {
49
+ function createPluginDatabaseCore(getPlugin, resolveFileUrl, options) {
50
+ const runWithMutationPlugin = async (operation) => {
51
+ const plugin = options?.createMutationPlugin?.() ?? getPlugin();
52
+ try {
53
+ return await operation(plugin);
54
+ } finally {
55
+ if (options?.createMutationPlugin) await options.cleanupMutationPlugin?.(plugin);
56
+ }
57
+ };
50
58
  const getSortedBundlePage = async (options, context) => {
51
- const result = await plugin.getBundles({
59
+ const result = await getPlugin().getBundles({
52
60
  ...options,
53
61
  orderBy: options.orderBy ?? DESC_ORDER
54
62
  }, context);
@@ -102,7 +110,7 @@ function createPluginDatabaseCore(plugin, resolveFileUrl) {
102
110
  return {
103
111
  api: {
104
112
  async getBundleById(id, context) {
105
- return plugin.getBundleById(id, context);
113
+ return getPlugin().getBundleById(id, context);
106
114
  },
107
115
  async getUpdateInfo(args, context) {
108
116
  const channel = args.channel ?? "production";
@@ -143,27 +151,33 @@ function createPluginDatabaseCore(plugin, resolveFileUrl) {
143
151
  };
144
152
  },
145
153
  async getChannels(context) {
146
- return plugin.getChannels(context);
154
+ return getPlugin().getChannels(context);
147
155
  },
148
156
  async getBundles(options, context) {
149
- return plugin.getBundles(options, context);
157
+ return getPlugin().getBundles(options, context);
150
158
  },
151
159
  async insertBundle(bundle, context) {
152
- await plugin.appendBundle(bundle, context);
153
- await plugin.commitBundle(context);
160
+ await runWithMutationPlugin(async (plugin) => {
161
+ await plugin.appendBundle(bundle, context);
162
+ await plugin.commitBundle(context);
163
+ });
154
164
  },
155
165
  async updateBundleById(bundleId, newBundle, context) {
156
- await plugin.updateBundle(bundleId, newBundle, context);
157
- await plugin.commitBundle(context);
166
+ await runWithMutationPlugin(async (plugin) => {
167
+ await plugin.updateBundle(bundleId, newBundle, context);
168
+ await plugin.commitBundle(context);
169
+ });
158
170
  },
159
171
  async deleteBundleById(bundleId, context) {
160
- const bundle = await plugin.getBundleById(bundleId, context);
161
- if (!bundle) return;
162
- await plugin.deleteBundle(bundle, context);
163
- await plugin.commitBundle(context);
172
+ await runWithMutationPlugin(async (plugin) => {
173
+ const bundle = await plugin.getBundleById(bundleId, context);
174
+ if (!bundle) return;
175
+ await plugin.deleteBundle(bundle, context);
176
+ await plugin.commitBundle(context);
177
+ });
164
178
  }
165
179
  },
166
- adapterName: plugin.name,
180
+ adapterName: getPlugin().name,
167
181
  createMigrator: () => {
168
182
  throw new Error("createMigrator is only available for Kysely/Prisma/Drizzle database adapters.");
169
183
  },
@@ -1,4 +1,4 @@
1
- import { PaginationInfo } from "../types/index.cjs";
1
+ import { PaginatedResult } from "../types/index.cjs";
2
2
  import { DatabaseBundleQueryOptions, DatabasePlugin, HotUpdaterContext, StoragePlugin } from "@hot-updater/plugin-core";
3
3
  import { AppUpdateInfo, Bundle, GetBundlesArgs, UpdateInfo } from "@hot-updater/core";
4
4
  import { FumaDBAdapter } from "fumadb/adapters";
@@ -11,10 +11,7 @@ interface DatabaseAPI<TContext = unknown> {
11
11
  getUpdateInfo(args: GetBundlesArgs, context?: HotUpdaterContext<TContext>): Promise<UpdateInfo | null>;
12
12
  getAppUpdateInfo(args: GetBundlesArgs, context?: HotUpdaterContext<TContext>): Promise<AppUpdateInfo | null>;
13
13
  getChannels(context?: HotUpdaterContext<TContext>): Promise<string[]>;
14
- getBundles(options: DatabaseBundleQueryOptions, context?: HotUpdaterContext<TContext>): Promise<{
15
- data: Bundle[];
16
- pagination: PaginationInfo;
17
- }>;
14
+ getBundles(options: DatabaseBundleQueryOptions, context?: HotUpdaterContext<TContext>): Promise<PaginatedResult>;
18
15
  insertBundle(bundle: Bundle, context?: HotUpdaterContext<TContext>): Promise<void>;
19
16
  updateBundleById(bundleId: string, newBundle: Partial<Bundle>, context?: HotUpdaterContext<TContext>): Promise<void>;
20
17
  deleteBundleById(bundleId: string, context?: HotUpdaterContext<TContext>): Promise<void>;
@@ -1,4 +1,4 @@
1
- import { PaginationInfo } from "../types/index.mjs";
1
+ import { PaginatedResult } from "../types/index.mjs";
2
2
  import { AppUpdateInfo, Bundle, GetBundlesArgs, UpdateInfo } from "@hot-updater/core";
3
3
  import { DatabaseBundleQueryOptions, DatabasePlugin, HotUpdaterContext, StoragePlugin } from "@hot-updater/plugin-core";
4
4
  import { FumaDBAdapter } from "fumadb/adapters";
@@ -11,10 +11,7 @@ interface DatabaseAPI<TContext = unknown> {
11
11
  getUpdateInfo(args: GetBundlesArgs, context?: HotUpdaterContext<TContext>): Promise<UpdateInfo | null>;
12
12
  getAppUpdateInfo(args: GetBundlesArgs, context?: HotUpdaterContext<TContext>): Promise<AppUpdateInfo | null>;
13
13
  getChannels(context?: HotUpdaterContext<TContext>): Promise<string[]>;
14
- getBundles(options: DatabaseBundleQueryOptions, context?: HotUpdaterContext<TContext>): Promise<{
15
- data: Bundle[];
16
- pagination: PaginationInfo;
17
- }>;
14
+ getBundles(options: DatabaseBundleQueryOptions, context?: HotUpdaterContext<TContext>): Promise<PaginatedResult>;
18
15
  insertBundle(bundle: Bundle, context?: HotUpdaterContext<TContext>): Promise<void>;
19
16
  updateBundleById(bundleId: string, newBundle: Partial<Bundle>, context?: HotUpdaterContext<TContext>): Promise<void>;
20
17
  deleteBundleById(bundleId: string, context?: HotUpdaterContext<TContext>): Promise<void>;
package/dist/handler.cjs CHANGED
@@ -7,7 +7,7 @@ var HandlerBadRequestError = class extends Error {
7
7
  }
8
8
  };
9
9
  const handleVersion = async () => {
10
- return new Response(JSON.stringify({ version: "0.29.2" }), {
10
+ return new Response(JSON.stringify({ version: "0.29.4" }), {
11
11
  status: 200,
12
12
  headers: { "Content-Type": "application/json" }
13
13
  });
@@ -107,7 +107,7 @@ const handleGetBundles = async (_params, request, api, context) => {
107
107
  limit,
108
108
  offset
109
109
  }, context);
110
- return new Response(JSON.stringify(result.data), {
110
+ return new Response(JSON.stringify(result), {
111
111
  status: 200,
112
112
  headers: { "Content-Type": "application/json" }
113
113
  });
@@ -140,8 +140,8 @@ const handleDeleteBundle = async (params, _request, api, context) => {
140
140
  });
141
141
  };
142
142
  const handleGetChannels = async (_params, _request, api, context) => {
143
- const channels = await api.getChannels(context);
144
- return new Response(JSON.stringify({ channels }), {
143
+ const response = { data: { channels: await api.getChannels(context) } };
144
+ return new Response(JSON.stringify(response), {
145
145
  status: 200,
146
146
  headers: { "Content-Type": "application/json" }
147
147
  });
@@ -1,4 +1,4 @@
1
- import { PaginationInfo } from "./types/index.cjs";
1
+ import { PaginatedResult } from "./types/index.cjs";
2
2
  import { DatabaseBundleQueryOptions, HotUpdaterContext } from "@hot-updater/plugin-core";
3
3
  import { AppUpdateInfo, AppVersionGetBundlesArgs, Bundle, FingerprintGetBundlesArgs } from "@hot-updater/core";
4
4
 
@@ -6,10 +6,7 @@ import { AppUpdateInfo, AppVersionGetBundlesArgs, Bundle, FingerprintGetBundlesA
6
6
  interface HandlerAPI<TContext = unknown> {
7
7
  getAppUpdateInfo: (args: AppVersionGetBundlesArgs | FingerprintGetBundlesArgs, context?: HotUpdaterContext<TContext>) => Promise<AppUpdateInfo | null>;
8
8
  getBundleById: (id: string, context?: HotUpdaterContext<TContext>) => Promise<Bundle | null>;
9
- getBundles: (options: DatabaseBundleQueryOptions, context?: HotUpdaterContext<TContext>) => Promise<{
10
- data: Bundle[];
11
- pagination: PaginationInfo;
12
- }>;
9
+ getBundles: (options: DatabaseBundleQueryOptions, context?: HotUpdaterContext<TContext>) => Promise<PaginatedResult>;
13
10
  insertBundle: (bundle: Bundle, context?: HotUpdaterContext<TContext>) => Promise<void>;
14
11
  updateBundleById: (bundleId: string, bundle: Partial<Bundle>, context?: HotUpdaterContext<TContext>) => Promise<void>;
15
12
  deleteBundleById: (bundleId: string, context?: HotUpdaterContext<TContext>) => Promise<void>;
@@ -1,4 +1,4 @@
1
- import { PaginationInfo } from "./types/index.mjs";
1
+ import { PaginatedResult } from "./types/index.mjs";
2
2
  import { AppUpdateInfo, AppVersionGetBundlesArgs, Bundle, FingerprintGetBundlesArgs } from "@hot-updater/core";
3
3
  import { DatabaseBundleQueryOptions, HotUpdaterContext } from "@hot-updater/plugin-core";
4
4
 
@@ -6,10 +6,7 @@ import { DatabaseBundleQueryOptions, HotUpdaterContext } from "@hot-updater/plug
6
6
  interface HandlerAPI<TContext = unknown> {
7
7
  getAppUpdateInfo: (args: AppVersionGetBundlesArgs | FingerprintGetBundlesArgs, context?: HotUpdaterContext<TContext>) => Promise<AppUpdateInfo | null>;
8
8
  getBundleById: (id: string, context?: HotUpdaterContext<TContext>) => Promise<Bundle | null>;
9
- getBundles: (options: DatabaseBundleQueryOptions, context?: HotUpdaterContext<TContext>) => Promise<{
10
- data: Bundle[];
11
- pagination: PaginationInfo;
12
- }>;
9
+ getBundles: (options: DatabaseBundleQueryOptions, context?: HotUpdaterContext<TContext>) => Promise<PaginatedResult>;
13
10
  insertBundle: (bundle: Bundle, context?: HotUpdaterContext<TContext>) => Promise<void>;
14
11
  updateBundleById: (bundleId: string, bundle: Partial<Bundle>, context?: HotUpdaterContext<TContext>) => Promise<void>;
15
12
  deleteBundleById: (bundleId: string, context?: HotUpdaterContext<TContext>) => Promise<void>;
package/dist/handler.mjs CHANGED
@@ -7,7 +7,7 @@ var HandlerBadRequestError = class extends Error {
7
7
  }
8
8
  };
9
9
  const handleVersion = async () => {
10
- return new Response(JSON.stringify({ version: "0.29.2" }), {
10
+ return new Response(JSON.stringify({ version: "0.29.4" }), {
11
11
  status: 200,
12
12
  headers: { "Content-Type": "application/json" }
13
13
  });
@@ -107,7 +107,7 @@ const handleGetBundles = async (_params, request, api, context) => {
107
107
  limit,
108
108
  offset
109
109
  }, context);
110
- return new Response(JSON.stringify(result.data), {
110
+ return new Response(JSON.stringify(result), {
111
111
  status: 200,
112
112
  headers: { "Content-Type": "application/json" }
113
113
  });
@@ -140,8 +140,8 @@ const handleDeleteBundle = async (params, _request, api, context) => {
140
140
  });
141
141
  };
142
142
  const handleGetChannels = async (_params, _request, api, context) => {
143
- const channels = await api.getChannels(context);
144
- return new Response(JSON.stringify({ channels }), {
143
+ const response = { data: { channels: await api.getChannels(context) } };
144
+ return new Response(JSON.stringify(response), {
145
145
  status: 200,
146
146
  headers: { "Content-Type": "application/json" }
147
147
  });
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { Bundle, PaginatedResult, PaginationInfo, PaginationOptions } from "./types/index.cjs";
1
+ import { Bundle, ChannelsResponse, DataResponse, Paginated, PaginatedResult, PaginationInfo, PaginationOptions } from "./types/index.cjs";
2
2
  import { HandlerAPI, HandlerOptions, HandlerRoutes, createHandler } from "./handler.cjs";
3
3
  import { HotUpdaterClient, HotUpdaterDB, Migrator } from "./db/ormCore.cjs";
4
4
  import { CreateHotUpdaterOptions, HotUpdaterAPI, createHotUpdater } from "./db/index.cjs";
5
- export { Bundle, CreateHotUpdaterOptions, HandlerAPI, HandlerOptions, HandlerRoutes, HotUpdaterAPI, HotUpdaterClient, HotUpdaterDB, Migrator, PaginatedResult, PaginationInfo, PaginationOptions, createHandler, createHotUpdater };
5
+ export { Bundle, ChannelsResponse, CreateHotUpdaterOptions, DataResponse, HandlerAPI, HandlerOptions, HandlerRoutes, HotUpdaterAPI, HotUpdaterClient, HotUpdaterDB, Migrator, Paginated, PaginatedResult, PaginationInfo, PaginationOptions, createHandler, createHotUpdater };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { Bundle, PaginatedResult, PaginationInfo, PaginationOptions } from "./types/index.mjs";
1
+ import { Bundle, ChannelsResponse, DataResponse, Paginated, PaginatedResult, PaginationInfo, PaginationOptions } from "./types/index.mjs";
2
2
  import { HandlerAPI, HandlerOptions, HandlerRoutes, createHandler } from "./handler.mjs";
3
3
  import { HotUpdaterClient, HotUpdaterDB, Migrator } from "./db/ormCore.mjs";
4
4
  import { CreateHotUpdaterOptions, HotUpdaterAPI, createHotUpdater } from "./db/index.mjs";
5
- export { Bundle, CreateHotUpdaterOptions, HandlerAPI, HandlerOptions, HandlerRoutes, HotUpdaterAPI, HotUpdaterClient, HotUpdaterDB, Migrator, PaginatedResult, PaginationInfo, PaginationOptions, createHandler, createHotUpdater };
5
+ export { Bundle, ChannelsResponse, CreateHotUpdaterOptions, DataResponse, HandlerAPI, HandlerOptions, HandlerRoutes, HotUpdaterAPI, HotUpdaterClient, HotUpdaterDB, Migrator, Paginated, PaginatedResult, PaginationInfo, PaginationOptions, createHandler, createHotUpdater };
package/dist/runtime.cjs CHANGED
@@ -5,6 +5,7 @@ const require_pluginCore = require("./db/pluginCore.cjs");
5
5
  const require_types = require("./db/types.cjs");
6
6
  //#region src/runtime.ts
7
7
  function createHotUpdater(options) {
8
+ const database = options.database;
8
9
  const basePath = require_route.normalizeBasePath(options.basePath ?? "/api");
9
10
  const storagePlugins = (options.storages ?? options.storagePlugins ?? []).map((plugin) => typeof plugin === "function" ? plugin() : plugin);
10
11
  const resolveStoragePluginUrl = async (storageUri, context) => {
@@ -17,8 +18,9 @@ function createHotUpdater(options) {
17
18
  if (!fileUrl) throw new Error("Storage plugin returned empty fileUrl");
18
19
  return fileUrl;
19
20
  };
20
- if (!require_types.isDatabasePluginFactory(options.database) && !require_types.isDatabasePlugin(options.database)) throw new Error("@hot-updater/server/runtime only supports database plugins.");
21
- const core = require_pluginCore.createPluginDatabaseCore(require_types.isDatabasePluginFactory(options.database) ? options.database() : options.database, resolveStoragePluginUrl);
21
+ if (!require_types.isDatabasePluginFactory(database) && !require_types.isDatabasePlugin(database)) throw new Error("@hot-updater/server/runtime only supports database plugins.");
22
+ const plugin = require_types.isDatabasePluginFactory(database) ? database() : database;
23
+ const core = require_pluginCore.createPluginDatabaseCore(() => plugin, resolveStoragePluginUrl, require_types.isDatabasePluginFactory(database) ? { createMutationPlugin: () => database() } : void 0);
22
24
  const api = {
23
25
  ...core.api,
24
26
  handler: require_handler.createHandler(core.api, {
package/dist/runtime.mjs CHANGED
@@ -4,6 +4,7 @@ import { createPluginDatabaseCore } from "./db/pluginCore.mjs";
4
4
  import { isDatabasePlugin, isDatabasePluginFactory } from "./db/types.mjs";
5
5
  //#region src/runtime.ts
6
6
  function createHotUpdater(options) {
7
+ const database = options.database;
7
8
  const basePath = normalizeBasePath(options.basePath ?? "/api");
8
9
  const storagePlugins = (options.storages ?? options.storagePlugins ?? []).map((plugin) => typeof plugin === "function" ? plugin() : plugin);
9
10
  const resolveStoragePluginUrl = async (storageUri, context) => {
@@ -16,8 +17,9 @@ function createHotUpdater(options) {
16
17
  if (!fileUrl) throw new Error("Storage plugin returned empty fileUrl");
17
18
  return fileUrl;
18
19
  };
19
- if (!isDatabasePluginFactory(options.database) && !isDatabasePlugin(options.database)) throw new Error("@hot-updater/server/runtime only supports database plugins.");
20
- const core = createPluginDatabaseCore(isDatabasePluginFactory(options.database) ? options.database() : options.database, resolveStoragePluginUrl);
20
+ if (!isDatabasePluginFactory(database) && !isDatabasePlugin(database)) throw new Error("@hot-updater/server/runtime only supports database plugins.");
21
+ const plugin = isDatabasePluginFactory(database) ? database() : database;
22
+ const core = createPluginDatabaseCore(() => plugin, resolveStoragePluginUrl, isDatabasePluginFactory(database) ? { createMutationPlugin: () => database() } : void 0);
21
23
  const api = {
22
24
  ...core.api,
23
25
  handler: createHandler(core.api, {
@@ -13,9 +13,15 @@ interface PaginationOptions {
13
13
  limit: number;
14
14
  offset: number;
15
15
  }
16
- interface PaginatedResult {
17
- data: Bundle[];
16
+ interface DataResponse<TData> {
17
+ data: TData;
18
+ }
19
+ interface Paginated<TData> extends DataResponse<TData> {
18
20
  pagination: PaginationInfo;
19
21
  }
22
+ type PaginatedResult = Paginated<Bundle[]>;
23
+ type ChannelsResponse = DataResponse<{
24
+ channels: string[];
25
+ }>;
20
26
  //#endregion
21
- export { type Bundle$1 as Bundle, PaginatedResult, PaginationInfo, PaginationOptions };
27
+ export { type Bundle$1 as Bundle, ChannelsResponse, DataResponse, Paginated, PaginatedResult, PaginationInfo, PaginationOptions };
@@ -13,9 +13,15 @@ interface PaginationOptions {
13
13
  limit: number;
14
14
  offset: number;
15
15
  }
16
- interface PaginatedResult {
17
- data: Bundle[];
16
+ interface DataResponse<TData> {
17
+ data: TData;
18
+ }
19
+ interface Paginated<TData> extends DataResponse<TData> {
18
20
  pagination: PaginationInfo;
19
21
  }
22
+ type PaginatedResult = Paginated<Bundle[]>;
23
+ type ChannelsResponse = DataResponse<{
24
+ channels: string[];
25
+ }>;
20
26
  //#endregion
21
- export { type Bundle$1 as Bundle, PaginatedResult, PaginationInfo, PaginationOptions };
27
+ export { type Bundle$1 as Bundle, ChannelsResponse, DataResponse, Paginated, PaginatedResult, PaginationInfo, PaginationOptions };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/server",
3
- "version": "0.29.2",
3
+ "version": "0.29.4",
4
4
  "type": "module",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "sideEffects": false,
@@ -53,9 +53,9 @@
53
53
  "fumadb": "0.2.2",
54
54
  "rou3": "0.7.9",
55
55
  "semver": "^7.7.2",
56
- "@hot-updater/plugin-core": "0.29.2",
57
- "@hot-updater/core": "0.29.2",
58
- "@hot-updater/js": "0.29.2"
56
+ "@hot-updater/plugin-core": "0.29.4",
57
+ "@hot-updater/core": "0.29.4",
58
+ "@hot-updater/js": "0.29.4"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@electric-sql/pglite": "^0.2.17",
@@ -66,8 +66,8 @@
66
66
  "kysely-pglite-dialect": "^1.2.0",
67
67
  "msw": "^2.7.0",
68
68
  "uuidv7": "^1.0.2",
69
- "@hot-updater/standalone": "0.29.2",
70
- "@hot-updater/test-utils": "0.29.2"
69
+ "@hot-updater/standalone": "0.29.4",
70
+ "@hot-updater/test-utils": "0.29.4"
71
71
  },
72
72
  "scripts": {
73
73
  "build": "tsdown",
@@ -5,6 +5,7 @@ import type {
5
5
  StoragePlugin,
6
6
  StorageResolveContext,
7
7
  } from "@hot-updater/plugin-core";
8
+ import { createDatabasePlugin } from "@hot-updater/plugin-core";
8
9
  import {
9
10
  setupBundleMethodsTestSuite,
10
11
  setupGetUpdateInfoTestSuite,
@@ -21,6 +22,7 @@ import {
21
22
  it,
22
23
  vi,
23
24
  } from "vitest";
25
+
24
26
  import { kyselyAdapter } from "../adapters/kysely";
25
27
  import { createHotUpdater } from "./index";
26
28
 
@@ -361,4 +363,95 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
361
363
  );
362
364
  });
363
365
  });
366
+
367
+ describe("database plugin factories", () => {
368
+ it("isolates pending mutation state between overlapping writes", async () => {
369
+ const committedBundleIds: string[][] = [];
370
+ const onUnmount = vi.fn(async () => undefined);
371
+ let releaseFirstCommit!: () => void;
372
+ let notifyFirstCommitStarted!: () => void;
373
+ const firstCommitStarted = new Promise<void>((resolve) => {
374
+ notifyFirstCommitStarted = resolve;
375
+ });
376
+ const firstCommitGate = new Promise<void>((resolve) => {
377
+ releaseFirstCommit = resolve;
378
+ });
379
+ let commitCount = 0;
380
+
381
+ const isolatedHotUpdater = createHotUpdater({
382
+ database: createDatabasePlugin({
383
+ name: "isolatedPlugin",
384
+ factory: () => ({
385
+ async getBundleById() {
386
+ return null;
387
+ },
388
+ async getBundles() {
389
+ return {
390
+ data: [],
391
+ pagination: {
392
+ hasNextPage: false,
393
+ hasPreviousPage: false,
394
+ currentPage: 1,
395
+ totalPages: 1,
396
+ total: 0,
397
+ },
398
+ };
399
+ },
400
+ async getChannels() {
401
+ return [];
402
+ },
403
+ onUnmount,
404
+ async commitBundle({ changedSets }) {
405
+ commitCount += 1;
406
+ committedBundleIds.push(
407
+ changedSets.map((change) => change.data.id),
408
+ );
409
+
410
+ if (commitCount === 1) {
411
+ notifyFirstCommitStarted();
412
+ await firstCommitGate;
413
+ }
414
+ },
415
+ }),
416
+ })({}),
417
+ });
418
+
419
+ const firstBundleId = "00000000-0000-0000-0000-000000000030";
420
+ const secondBundleId = "00000000-0000-0000-0000-000000000031";
421
+
422
+ const firstInsert = isolatedHotUpdater.insertBundle({
423
+ id: firstBundleId,
424
+ platform: "ios",
425
+ shouldForceUpdate: false,
426
+ enabled: true,
427
+ fileHash: "hash-1",
428
+ gitCommitHash: null,
429
+ message: "first bundle",
430
+ channel: "production",
431
+ storageUri: "s3://test-bucket/first.zip",
432
+ targetAppVersion: "1.0.0",
433
+ fingerprintHash: null,
434
+ });
435
+ await firstCommitStarted;
436
+
437
+ const secondInsert = isolatedHotUpdater.insertBundle({
438
+ id: secondBundleId,
439
+ platform: "ios",
440
+ shouldForceUpdate: false,
441
+ enabled: true,
442
+ fileHash: "hash-2",
443
+ gitCommitHash: null,
444
+ message: "second bundle",
445
+ channel: "production",
446
+ storageUri: "s3://test-bucket/second.zip",
447
+ targetAppVersion: "1.0.0",
448
+ fingerprintHash: null,
449
+ });
450
+
451
+ releaseFirstCommit();
452
+ await Promise.all([firstInsert, secondInsert]);
453
+
454
+ expect(committedBundleIds).toEqual([[firstBundleId], [secondBundleId]]);
455
+ });
456
+ });
364
457
  });
package/src/db/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type {
2
+ DatabasePlugin,
2
3
  HotUpdaterContext,
3
4
  StoragePlugin,
4
5
  } from "@hot-updater/plugin-core";
6
+
5
7
  import { createHandler, type HandlerRoutes } from "../handler";
6
8
  import { normalizeBasePath } from "../route";
7
9
  import {
@@ -92,10 +94,23 @@ export function createHotUpdater<TContext = unknown>(
92
94
 
93
95
  const core =
94
96
  isDatabasePluginFactory(database) || isDatabasePlugin(database)
95
- ? createPluginDatabaseCore<TContext>(
96
- isDatabasePluginFactory(database) ? database() : database,
97
- resolveFileUrl,
98
- )
97
+ ? (() => {
98
+ const plugin: DatabasePlugin<TContext> = isDatabasePluginFactory(
99
+ database,
100
+ )
101
+ ? database()
102
+ : database;
103
+
104
+ return createPluginDatabaseCore<TContext>(
105
+ () => plugin,
106
+ resolveFileUrl,
107
+ isDatabasePluginFactory(database)
108
+ ? {
109
+ createMutationPlugin: () => database(),
110
+ }
111
+ : undefined,
112
+ );
113
+ })()
99
114
  : createOrmDatabaseCore<TContext>({
100
115
  database,
101
116
  resolveFileUrl,
package/src/db/ormCore.ts CHANGED
@@ -21,10 +21,11 @@ import { semverSatisfies } from "@hot-updater/plugin-core";
21
21
  import type { InferFumaDB } from "fumadb";
22
22
  import { fumadb } from "fumadb";
23
23
  import type { FumaDBAdapter } from "fumadb/adapters";
24
+
24
25
  import { calculatePagination } from "../calculatePagination";
25
26
  import { v0_21_0 } from "../schema/v0_21_0";
26
27
  import { v0_29_0 } from "../schema/v0_29_0";
27
- import type { PaginationInfo } from "../types";
28
+ import type { Paginated } from "../types";
28
29
  import type { DatabaseAPI } from "./types";
29
30
 
30
31
  const parseTargetCohorts = (value: unknown): string[] | null => {
@@ -508,7 +509,7 @@ export function createOrmDatabaseCore<TContext = unknown>({
508
509
 
509
510
  async getBundles(
510
511
  options: DatabaseBundleQueryOptions,
511
- ): Promise<{ data: Bundle[]; pagination: PaginationInfo }> {
512
+ ): Promise<Paginated<Bundle[]>> {
512
513
  const orm = await ensureORM();
513
514
  const { where, limit, offset, orderBy } = options;
514
515
 
@@ -2,6 +2,7 @@ import { PGlite } from "@electric-sql/pglite";
2
2
  import { Kysely } from "kysely";
3
3
  import { PGliteDialect } from "kysely-pglite-dialect";
4
4
  import { bench, describe } from "vitest";
5
+
5
6
  import { filterCompatibleAppVersions } from "../../../../plugins/plugin-core/src";
6
7
  import type {
7
8
  AppVersionGetBundlesArgs,
@@ -16,10 +16,10 @@ import {
16
16
  type HotUpdaterContext,
17
17
  semverSatisfies,
18
18
  } from "@hot-updater/plugin-core";
19
+
19
20
  import type { DatabaseAPI } from "./types";
20
21
 
21
22
  const PAGE_SIZE = 100;
22
-
23
23
  const DESC_ORDER = { field: "id", direction: "desc" } as const;
24
24
 
25
25
  const bundleMatchesQueryWhere = (
@@ -100,22 +100,42 @@ const INIT_BUNDLE_ROLLBACK_UPDATE_INFO: UpdateInfo = {
100
100
  };
101
101
 
102
102
  export function createPluginDatabaseCore<TContext = unknown>(
103
- plugin: DatabasePlugin<TContext>,
103
+ getPlugin: () => DatabasePlugin<TContext>,
104
104
  resolveFileUrl: (
105
105
  storageUri: string | null,
106
106
  context?: HotUpdaterContext<TContext>,
107
107
  ) => Promise<string | null>,
108
+ options?: {
109
+ createMutationPlugin?: () => DatabasePlugin<TContext>;
110
+ cleanupMutationPlugin?: (
111
+ plugin: DatabasePlugin<TContext>,
112
+ ) => Promise<void> | void;
113
+ },
108
114
  ): {
109
115
  api: DatabaseAPI<TContext>;
110
116
  adapterName: string;
111
117
  createMigrator: () => never;
112
118
  generateSchema: () => never;
113
119
  } {
120
+ const runWithMutationPlugin = async <T>(
121
+ operation: (plugin: DatabasePlugin<TContext>) => Promise<T>,
122
+ ): Promise<T> => {
123
+ const plugin = options?.createMutationPlugin?.() ?? getPlugin();
124
+
125
+ try {
126
+ return await operation(plugin);
127
+ } finally {
128
+ if (options?.createMutationPlugin) {
129
+ await options.cleanupMutationPlugin?.(plugin);
130
+ }
131
+ }
132
+ };
133
+
114
134
  const getSortedBundlePage = async (
115
135
  options: DatabaseBundleQueryOptions,
116
136
  context?: HotUpdaterContext<TContext>,
117
137
  ): Promise<Awaited<ReturnType<DatabasePlugin<TContext>["getBundles"]>>> => {
118
- const result = await plugin.getBundles(
138
+ const result = await getPlugin().getBundles(
119
139
  {
120
140
  ...options,
121
141
  orderBy: options.orderBy ?? DESC_ORDER,
@@ -242,7 +262,7 @@ export function createPluginDatabaseCore<TContext = unknown>(
242
262
  id: string,
243
263
  context?: HotUpdaterContext<TContext>,
244
264
  ): Promise<Bundle | null> {
245
- return plugin.getBundleById(id, context);
265
+ return getPlugin().getBundleById(id, context);
246
266
  },
247
267
 
248
268
  async getUpdateInfo(
@@ -314,19 +334,21 @@ export function createPluginDatabaseCore<TContext = unknown>(
314
334
  async getChannels(
315
335
  context?: HotUpdaterContext<TContext>,
316
336
  ): Promise<string[]> {
317
- return plugin.getChannels(context);
337
+ return getPlugin().getChannels(context);
318
338
  },
319
339
 
320
340
  async getBundles(options, context?: HotUpdaterContext<TContext>) {
321
- return plugin.getBundles(options, context);
341
+ return getPlugin().getBundles(options, context);
322
342
  },
323
343
 
324
344
  async insertBundle(
325
345
  bundle: Bundle,
326
346
  context?: HotUpdaterContext<TContext>,
327
347
  ): Promise<void> {
328
- await plugin.appendBundle(bundle, context);
329
- await plugin.commitBundle(context);
348
+ await runWithMutationPlugin(async (plugin) => {
349
+ await plugin.appendBundle(bundle, context);
350
+ await plugin.commitBundle(context);
351
+ });
330
352
  },
331
353
 
332
354
  async updateBundleById(
@@ -334,26 +356,30 @@ export function createPluginDatabaseCore<TContext = unknown>(
334
356
  newBundle: Partial<Bundle>,
335
357
  context?: HotUpdaterContext<TContext>,
336
358
  ): Promise<void> {
337
- await plugin.updateBundle(bundleId, newBundle, context);
338
- await plugin.commitBundle(context);
359
+ await runWithMutationPlugin(async (plugin) => {
360
+ await plugin.updateBundle(bundleId, newBundle, context);
361
+ await plugin.commitBundle(context);
362
+ });
339
363
  },
340
364
 
341
365
  async deleteBundleById(
342
366
  bundleId: string,
343
367
  context?: HotUpdaterContext<TContext>,
344
368
  ): Promise<void> {
345
- const bundle = await plugin.getBundleById(bundleId, context);
346
- if (!bundle) {
347
- return;
348
- }
349
- await plugin.deleteBundle(bundle, context);
350
- await plugin.commitBundle(context);
369
+ await runWithMutationPlugin(async (plugin) => {
370
+ const bundle = await plugin.getBundleById(bundleId, context);
371
+ if (!bundle) {
372
+ return;
373
+ }
374
+ await plugin.deleteBundle(bundle, context);
375
+ await plugin.commitBundle(context);
376
+ });
351
377
  },
352
378
  };
353
379
 
354
380
  return {
355
381
  api,
356
- adapterName: plugin.name,
382
+ adapterName: getPlugin().name,
357
383
  createMigrator: () => {
358
384
  throw new Error(
359
385
  "createMigrator is only available for Kysely/Prisma/Drizzle database adapters.",
@@ -1,4 +1,5 @@
1
1
  import { bench, describe } from "vitest";
2
+
2
3
  import type {
3
4
  DatabaseBundleQueryOptions,
4
5
  DatabaseBundleQueryWhere,
@@ -230,7 +231,10 @@ describe("plugin update check benchmark", () => {
230
231
  };
231
232
 
232
233
  const plugin = createBenchPlugin(bundles);
233
- const currentApi = createPluginDatabaseCore(plugin, async () => null).api;
234
+ const currentApi = createPluginDatabaseCore(
235
+ () => plugin,
236
+ async () => null,
237
+ ).api;
234
238
 
235
239
  bench(
236
240
  "pluginCore legacy full fetch",
package/src/db/types.ts CHANGED
@@ -11,7 +11,8 @@ import type {
11
11
  StoragePlugin,
12
12
  } from "@hot-updater/plugin-core";
13
13
  import type { FumaDBAdapter } from "fumadb/adapters";
14
- import type { PaginationInfo } from "../types";
14
+
15
+ import type { PaginatedResult } from "../types";
15
16
 
16
17
  export type DatabasePluginFactory<TContext = unknown> =
17
18
  () => DatabasePlugin<TContext>;
@@ -62,7 +63,7 @@ export interface DatabaseAPI<TContext = unknown> {
62
63
  getBundles(
63
64
  options: DatabaseBundleQueryOptions,
64
65
  context?: HotUpdaterContext<TContext>,
65
- ): Promise<{ data: Bundle[]; pagination: PaginationInfo }>;
66
+ ): Promise<PaginatedResult>;
66
67
  insertBundle(
67
68
  bundle: Bundle,
68
69
  context?: HotUpdaterContext<TContext>,
@@ -2,13 +2,14 @@ import { PGlite } from "@electric-sql/pglite";
2
2
  import type { Bundle } from "@hot-updater/core";
3
3
  import { NIL_UUID } from "@hot-updater/core";
4
4
  import { createBlobDatabasePlugin } from "@hot-updater/plugin-core";
5
- import { standaloneRepository } from "@hot-updater/standalone";
6
5
  import { Kysely } from "kysely";
7
6
  import { PGliteDialect } from "kysely-pglite-dialect";
8
7
  import { HttpResponse, http } from "msw";
9
8
  import { setupServer } from "msw/node";
10
9
  import { uuidv7 } from "uuidv7";
11
10
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
11
+
12
+ import { standaloneRepository } from "../../../plugins/standalone/src";
12
13
  import { kyselyAdapter } from "./adapters/kysely";
13
14
  import { createHotUpdater } from "./db";
14
15
 
@@ -66,6 +67,9 @@ beforeAll(async () => {
66
67
  http.get(`${baseUrl}/hot-updater/api/bundles`, ({ request }) =>
67
68
  handleRequest(request),
68
69
  ),
70
+ http.get(`${baseUrl}/hot-updater/api/bundles/channels`, ({ request }) =>
71
+ handleRequest(request),
72
+ ),
69
73
  http.get(`${baseUrl}/hot-updater/api/bundles/:id`, ({ request }) =>
70
74
  handleRequest(request),
71
75
  ),
@@ -245,6 +249,26 @@ describe("Handler <-> Standalone Repository Integration", () => {
245
249
  expect(prodResult.data).toHaveLength(2);
246
250
  });
247
251
 
252
+ it("Real integration: getChannels → handler GET /bundles/channels", async () => {
253
+ await api.insertBundle(
254
+ createTestBundle({ id: uuidv7(), channel: "production" }),
255
+ );
256
+ await api.insertBundle(createTestBundle({ id: uuidv7(), channel: "beta" }));
257
+ await api.insertBundle(
258
+ createTestBundle({ id: uuidv7(), channel: "production" }),
259
+ );
260
+
261
+ const repo = standaloneRepository({
262
+ baseUrl: `${baseUrl}/hot-updater`,
263
+ })();
264
+
265
+ const channels = await repo.getChannels();
266
+
267
+ expect(channels).toHaveLength(2);
268
+ expect(channels).toContain("production");
269
+ expect(channels).toContain("beta");
270
+ });
271
+
248
272
  it("Full E2E: create → retrieve → update → delete via standalone", async () => {
249
273
  const repo = standaloneRepository({
250
274
  baseUrl: `${baseUrl}/hot-updater`,
@@ -1,5 +1,6 @@
1
- import { NIL_UUID } from "@hot-updater/core";
1
+ import { type Bundle, NIL_UUID } from "@hot-updater/core";
2
2
  import { describe, expect, it, vi } from "vitest";
3
+
3
4
  import { createHandler, type HandlerAPI } from "./handler";
4
5
 
5
6
  type TestEnv = {
@@ -10,22 +11,41 @@ type TestContext = {
10
11
  env: TestEnv;
11
12
  };
12
13
 
13
- const createApi = (): HandlerAPI<TestContext> => ({
14
- getAppUpdateInfo: vi.fn().mockResolvedValue({
15
- fileHash: null,
16
- fileUrl: null,
17
- id: NIL_UUID,
18
- message: null,
19
- shouldForceUpdate: true,
20
- status: "ROLLBACK",
21
- }),
22
- getBundleById: vi.fn(),
23
- getBundles: vi.fn(),
24
- getChannels: vi.fn(),
25
- insertBundle: vi.fn(),
26
- updateBundleById: vi.fn(),
27
- deleteBundleById: vi.fn(),
28
- });
14
+ const testBundle: Bundle = {
15
+ id: "bundle-1",
16
+ platform: "ios",
17
+ shouldForceUpdate: false,
18
+ enabled: true,
19
+ fileHash: "hash123",
20
+ gitCommitHash: null,
21
+ message: "Test bundle",
22
+ channel: "production",
23
+ storageUri: "s3://test-bucket/bundles/bundle-1.zip",
24
+ targetAppVersion: "1.0.0",
25
+ fingerprintHash: null,
26
+ };
27
+
28
+ const createApi = () =>
29
+ ({
30
+ getAppUpdateInfo: vi
31
+ .fn<HandlerAPI<TestContext>["getAppUpdateInfo"]>()
32
+ .mockResolvedValue({
33
+ fileHash: null,
34
+ fileUrl: null,
35
+ id: NIL_UUID,
36
+ message: null,
37
+ shouldForceUpdate: true,
38
+ status: "ROLLBACK",
39
+ }),
40
+ getBundleById: vi.fn<HandlerAPI<TestContext>["getBundleById"]>(),
41
+ getBundles: vi.fn<HandlerAPI<TestContext>["getBundles"]>(),
42
+ getChannels: vi
43
+ .fn<HandlerAPI<TestContext>["getChannels"]>()
44
+ .mockResolvedValue(["production"]),
45
+ insertBundle: vi.fn<HandlerAPI<TestContext>["insertBundle"]>(),
46
+ updateBundleById: vi.fn<HandlerAPI<TestContext>["updateBundleById"]>(),
47
+ deleteBundleById: vi.fn<HandlerAPI<TestContext>["deleteBundleById"]>(),
48
+ }) satisfies HandlerAPI<TestContext>;
29
49
 
30
50
  describe("createHandler", () => {
31
51
  it("supports the app-version route without a cohort segment", async () => {
@@ -134,9 +154,60 @@ describe("createHandler", () => {
134
154
  );
135
155
 
136
156
  expect(channelsResponse.status).toBe(200);
157
+ await expect(channelsResponse.json()).resolves.toEqual({
158
+ data: {
159
+ channels: ["production"],
160
+ },
161
+ });
162
+ expect(api.getChannels).toHaveBeenCalledWith(undefined);
137
163
  expect(updateResponse.status).toBe(404);
138
164
  });
139
165
 
166
+ it("returns paginated bundle results in the response body", async () => {
167
+ const api = createApi();
168
+ api.getBundles.mockResolvedValue({
169
+ data: [testBundle],
170
+ pagination: {
171
+ total: 51,
172
+ hasNextPage: true,
173
+ hasPreviousPage: true,
174
+ currentPage: 6,
175
+ totalPages: 26,
176
+ },
177
+ });
178
+ const handler = createHandler(api, { basePath: "/hot-updater" });
179
+
180
+ const response = await handler(
181
+ new Request(
182
+ "http://localhost/hot-updater/api/bundles?channel=production&platform=ios&limit=2&offset=10",
183
+ ),
184
+ );
185
+
186
+ expect(response.status).toBe(200);
187
+ expect(response.headers.get("X-Total-Count")).toBeNull();
188
+ await expect(response.json()).resolves.toEqual({
189
+ data: [testBundle],
190
+ pagination: {
191
+ total: 51,
192
+ hasNextPage: true,
193
+ hasPreviousPage: true,
194
+ currentPage: 6,
195
+ totalPages: 26,
196
+ },
197
+ });
198
+ expect(api.getBundles).toHaveBeenCalledWith(
199
+ {
200
+ where: {
201
+ channel: "production",
202
+ platform: "ios",
203
+ },
204
+ limit: 2,
205
+ offset: 10,
206
+ },
207
+ undefined,
208
+ );
209
+ });
210
+
140
211
  it("returns 400 when the platform route parameter is invalid", async () => {
141
212
  const api = createApi();
142
213
  const handler = createHandler(api, { basePath: "/hot-updater" });
package/src/handler.ts CHANGED
@@ -9,8 +9,9 @@ import type {
9
9
  DatabaseBundleQueryOptions,
10
10
  HotUpdaterContext,
11
11
  } from "@hot-updater/plugin-core";
12
+
12
13
  import { addRoute, createRouter, findRoute } from "./internalRouter";
13
- import type { PaginationInfo } from "./types";
14
+ import type { ChannelsResponse, PaginatedResult } from "./types";
14
15
 
15
16
  declare const __VERSION__: string;
16
17
 
@@ -27,7 +28,7 @@ export interface HandlerAPI<TContext = unknown> {
27
28
  getBundles: (
28
29
  options: DatabaseBundleQueryOptions,
29
30
  context?: HotUpdaterContext<TContext>,
30
- ) => Promise<{ data: Bundle[]; pagination: PaginationInfo }>;
31
+ ) => Promise<PaginatedResult>;
31
32
  insertBundle: (
32
33
  bundle: Bundle,
33
34
  context?: HotUpdaterContext<TContext>,
@@ -262,7 +263,7 @@ const handleGetBundles: RouteHandler = async (
262
263
  context,
263
264
  );
264
265
 
265
- return new Response(JSON.stringify(result.data), {
266
+ return new Response(JSON.stringify(result), {
266
267
  status: 200,
267
268
  headers: { "Content-Type": "application/json" },
268
269
  });
@@ -327,8 +328,13 @@ const handleGetChannels: RouteHandler = async (
327
328
  context,
328
329
  ) => {
329
330
  const channels = await api.getChannels(context);
331
+ const response: ChannelsResponse = {
332
+ data: {
333
+ channels,
334
+ },
335
+ };
330
336
 
331
- return new Response(JSON.stringify({ channels }), {
337
+ return new Response(JSON.stringify(response), {
332
338
  status: 200,
333
339
  headers: { "Content-Type": "application/json" },
334
340
  });
@@ -5,7 +5,9 @@ import type {
5
5
  RequestEnvContext,
6
6
  StoragePlugin,
7
7
  } from "@hot-updater/plugin-core";
8
+ import { createDatabasePlugin } from "@hot-updater/plugin-core";
8
9
  import { describe, expect, expectTypeOf, it, vi } from "vitest";
10
+
9
11
  import { createHotUpdater } from "./runtime";
10
12
 
11
13
  const bundle: Bundle = {
@@ -274,4 +276,157 @@ describe("runtime createHotUpdater", () => {
274
276
  expect(response.status).toBe(200);
275
277
  expect(getBundles).toHaveBeenCalledWith(expect.any(Object), undefined);
276
278
  });
279
+
280
+ it("clears pending plugin changes after a failed mutation commit", async () => {
281
+ const committedBundles = new Map<string, Bundle>();
282
+ let commitAttempt = 0;
283
+
284
+ const database = createDatabasePlugin({
285
+ name: "failingPlugin",
286
+ factory: () => ({
287
+ async getBundleById(bundleId) {
288
+ return committedBundles.get(bundleId) ?? null;
289
+ },
290
+ async getBundles() {
291
+ return {
292
+ data: Array.from(committedBundles.values()),
293
+ pagination: {
294
+ hasNextPage: false,
295
+ hasPreviousPage: false,
296
+ currentPage: 1,
297
+ totalPages: 1,
298
+ total: committedBundles.size,
299
+ },
300
+ };
301
+ },
302
+ async getChannels() {
303
+ return [];
304
+ },
305
+ async commitBundle({ changedSets }) {
306
+ commitAttempt += 1;
307
+
308
+ if (commitAttempt === 1) {
309
+ throw new Error("commit failed");
310
+ }
311
+
312
+ for (const change of changedSets) {
313
+ if (change.operation === "delete") {
314
+ committedBundles.delete(change.data.id);
315
+ continue;
316
+ }
317
+
318
+ committedBundles.set(change.data.id, change.data);
319
+ }
320
+ },
321
+ }),
322
+ })({});
323
+
324
+ const hotUpdater = createHotUpdater({
325
+ database,
326
+ basePath: "/api/check-update",
327
+ routes: {
328
+ updateCheck: false,
329
+ bundles: false,
330
+ },
331
+ });
332
+
333
+ const failedBundle: Bundle = {
334
+ ...bundle,
335
+ id: "00000000-0000-0000-0000-000000000010",
336
+ message: "failed bundle",
337
+ };
338
+ const succeedingBundle: Bundle = {
339
+ ...bundle,
340
+ id: "00000000-0000-0000-0000-000000000011",
341
+ message: "succeeding bundle",
342
+ };
343
+
344
+ await expect(hotUpdater.insertBundle(failedBundle)).rejects.toThrow(
345
+ "commit failed",
346
+ );
347
+ await hotUpdater.insertBundle(succeedingBundle);
348
+
349
+ expect(await hotUpdater.getBundleById(failedBundle.id)).toBeNull();
350
+ await expect(
351
+ hotUpdater.getBundleById(succeedingBundle.id),
352
+ ).resolves.toEqual(succeedingBundle);
353
+ });
354
+
355
+ it("isolates pending mutation state between overlapping writes", async () => {
356
+ const committedBundleIds: string[][] = [];
357
+ const onUnmount = vi.fn(async () => undefined);
358
+ let releaseFirstCommit!: () => void;
359
+ let notifyFirstCommitStarted!: () => void;
360
+ const firstCommitStarted = new Promise<void>((resolve) => {
361
+ notifyFirstCommitStarted = resolve;
362
+ });
363
+ const firstCommitGate = new Promise<void>((resolve) => {
364
+ releaseFirstCommit = resolve;
365
+ });
366
+ let commitCount = 0;
367
+
368
+ const database = createDatabasePlugin({
369
+ name: "isolatedPlugin",
370
+ factory: () => ({
371
+ async getBundleById() {
372
+ return null;
373
+ },
374
+ async getBundles() {
375
+ return {
376
+ data: [],
377
+ pagination: {
378
+ hasNextPage: false,
379
+ hasPreviousPage: false,
380
+ currentPage: 1,
381
+ totalPages: 1,
382
+ total: 0,
383
+ },
384
+ };
385
+ },
386
+ async getChannels() {
387
+ return [];
388
+ },
389
+ onUnmount,
390
+ async commitBundle({ changedSets }) {
391
+ commitCount += 1;
392
+ committedBundleIds.push(changedSets.map((change) => change.data.id));
393
+
394
+ if (commitCount === 1) {
395
+ notifyFirstCommitStarted();
396
+ await firstCommitGate;
397
+ }
398
+ },
399
+ }),
400
+ })({});
401
+
402
+ const hotUpdater = createHotUpdater({
403
+ database,
404
+ basePath: "/api/check-update",
405
+ routes: {
406
+ updateCheck: false,
407
+ bundles: false,
408
+ },
409
+ });
410
+
411
+ const firstBundleId = "00000000-0000-0000-0000-000000000020";
412
+ const secondBundleId = "00000000-0000-0000-0000-000000000021";
413
+
414
+ const firstInsert = hotUpdater.insertBundle({
415
+ ...bundle,
416
+ id: firstBundleId,
417
+ message: "first bundle",
418
+ });
419
+ await firstCommitStarted;
420
+
421
+ const secondInsert = hotUpdater.insertBundle({
422
+ ...bundle,
423
+ id: secondBundleId,
424
+ message: "second bundle",
425
+ });
426
+
427
+ releaseFirstCommit();
428
+ await Promise.all([firstInsert, secondInsert]);
429
+
430
+ expect(committedBundleIds).toEqual([[firstBundleId], [secondBundleId]]);
431
+ });
277
432
  });
package/src/runtime.ts CHANGED
@@ -2,6 +2,7 @@ import type {
2
2
  HotUpdaterContext,
3
3
  StoragePlugin,
4
4
  } from "@hot-updater/plugin-core";
5
+
5
6
  import { createPluginDatabaseCore } from "./db/pluginCore";
6
7
  import {
7
8
  type DatabaseAdapter,
@@ -34,6 +35,7 @@ export interface CreateHotUpdaterOptions<TContext = unknown> {
34
35
  export function createHotUpdater<TContext = unknown>(
35
36
  options: CreateHotUpdaterOptions<TContext>,
36
37
  ): HotUpdaterAPI<TContext> {
38
+ const database = options.database;
37
39
  const basePath = normalizeBasePath(options.basePath ?? "/api");
38
40
  const storagePlugins = (options.storages ?? options.storagePlugins ?? []).map(
39
41
  (plugin) => (typeof plugin === "function" ? plugin() : plugin),
@@ -70,21 +72,21 @@ export function createHotUpdater<TContext = unknown>(
70
72
  return fileUrl;
71
73
  };
72
74
 
73
- if (
74
- !isDatabasePluginFactory(options.database) &&
75
- !isDatabasePlugin(options.database)
76
- ) {
75
+ if (!isDatabasePluginFactory(database) && !isDatabasePlugin(database)) {
77
76
  throw new Error(
78
77
  "@hot-updater/server/runtime only supports database plugins.",
79
78
  );
80
79
  }
81
80
 
82
- const plugin = isDatabasePluginFactory(options.database)
83
- ? options.database()
84
- : options.database;
81
+ const plugin = isDatabasePluginFactory(database) ? database() : database;
85
82
  const core = createPluginDatabaseCore<TContext>(
86
- plugin,
83
+ () => plugin,
87
84
  resolveStoragePluginUrl,
85
+ isDatabasePluginFactory(database)
86
+ ? {
87
+ createMutationPlugin: () => database(),
88
+ }
89
+ : undefined,
88
90
  );
89
91
 
90
92
  const api = {
@@ -16,7 +16,16 @@ export interface PaginationOptions {
16
16
  offset: number;
17
17
  }
18
18
 
19
- export interface PaginatedResult {
20
- data: Bundle[];
19
+ export interface DataResponse<TData> {
20
+ data: TData;
21
+ }
22
+
23
+ export interface Paginated<TData> extends DataResponse<TData> {
21
24
  pagination: PaginationInfo;
22
25
  }
26
+
27
+ export type PaginatedResult = Paginated<Bundle[]>;
28
+
29
+ export type ChannelsResponse = DataResponse<{
30
+ channels: string[];
31
+ }>;