@hot-updater/server 0.29.2 → 0.29.3

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