diff --git a/apps/client-standalone/src/lightweight/browser_routes.ts b/apps/client-standalone/src/lightweight/browser_routes.ts index e6492f7462..739fae625e 100644 --- a/apps/client-standalone/src/lightweight/browser_routes.ts +++ b/apps/client-standalone/src/lightweight/browser_routes.ts @@ -122,6 +122,45 @@ function createRoute(router: BrowserRouter) { }; } +/** + * Async variant of createRoute for handlers that return Promises (e.g. import). + * Uses transactionalAsync (manual BEGIN/COMMIT/ROLLBACK) instead of the synchronous + * transactional() wrapper, which would commit an empty transaction immediately when + * passed an async callback. + */ +function createAsyncRoute(router: BrowserRouter) { + return (method: HttpMethod, path: string, _middleware: any[], handler: (req: any, res: any) => Promise, resultHandler?: ((req: any, res: any, result: unknown) => unknown) | null) => { + router.register(method, path, (req: BrowserRequest) => { + return getContext().init(async () => { + setContextFromHeaders(req); + const expressLikeReq = toExpressLikeReq(req); + const mockRes = createMockExpressResponse(); + const result = await getSql().transactionalAsync(() => handler(expressLikeReq, mockRes)); + + // If the handler used the mock response (e.g. image routes that call res.send()), + // return it as a raw response so BrowserRouter doesn't JSON-serialize it. + if (mockRes._used) { + return { + [RAW_RESPONSE]: true as const, + status: mockRes._status, + headers: mockRes._headers, + body: mockRes._body + }; + } + + if (resultHandler) { + // Create a minimal response object that captures what apiResultHandler sets. + const res = createResultHandlerResponse(); + resultHandler(expressLikeReq, res, result); + return res.result; + } + + return result; + }); + }); + }; +} + /** * Creates a mock Express response object that captures calls to set(), send(), sendStatus(), etc. * Used for route handlers (like image routes) that write directly to the response. @@ -220,7 +259,7 @@ export function registerRoutes(router: BrowserRouter): void { const apiRoute = createApiRoute(router, true); routes.buildSharedApiRoutes({ route: createRoute(router), - asyncRoute: createRoute(router), + asyncRoute: createAsyncRoute(router), apiRoute, asyncApiRoute: createApiRoute(router, false), apiResultHandler, diff --git a/apps/client-standalone/src/lightweight/sql_provider.ts b/apps/client-standalone/src/lightweight/sql_provider.ts index e7bd80ba34..4b22833de4 100644 --- a/apps/client-standalone/src/lightweight/sql_provider.ts +++ b/apps/client-standalone/src/lightweight/sql_provider.ts @@ -501,9 +501,12 @@ export default class BrowserSqlProvider implements DatabaseProvider { // Helper function to execute within a transaction const executeTransaction = (beginStatement: string, ...args: unknown[]): T => { - // If we're already in a transaction, use SAVEPOINTs for nesting - // This mimics better-sqlite3's behavior - if (self._inTransaction) { + // If we're already in a transaction (either tracked via JS flag or via actual SQLite + // autocommit state), use SAVEPOINTs for nesting — this handles the case where a manual + // BEGIN was issued directly (e.g. transactionalAsync) without going through transaction(). + const sqliteInTransaction = self.db?.pointer !== undefined + && (self.sqlite3!.capi as any).sqlite3_get_autocommit(self.db!.pointer) === 0; + if (self._inTransaction || sqliteInTransaction) { const savepointName = `sp_${++savepointCounter}_${Date.now()}`; self.db!.exec(`SAVEPOINT ${savepointName}`); try { diff --git a/packages/trilium-core/src/routes/api/import.ts b/packages/trilium-core/src/routes/api/import.ts index b3bcdff357..ea78365928 100644 --- a/packages/trilium-core/src/routes/api/import.ts +++ b/packages/trilium-core/src/routes/api/import.ts @@ -7,7 +7,7 @@ interface ImportRequest

extends Request

{ import becca from "../../becca/becca.js"; import type BNote from "../../becca/entities/bnote.js"; -import enexImportService from "../../services/import/enex.js"; +// import enexImportService from "../../services/import/enex.js"; import opmlImportService from "../../services/import/opml.js"; import singleImportService from "../../services/import/single.js"; import zipImportService from "../../services/import/zip.js"; @@ -64,12 +64,12 @@ async function importNotesToBranch(req: ImportRequest<{ parentNoteId: string }>) return importResult; } } else if (extension === ".enex" && options.explodeArchives) { - const importResult = await enexImportService.importEnex(taskContext, file, parentNote); - if (!Array.isArray(importResult)) { - note = importResult; - } else { - return importResult; - } + // const importResult = await enexImportService.importEnex(taskContext, file, parentNote); + // if (!Array.isArray(importResult)) { + // note = importResult; + // } else { + // return importResult; + // } } else { note = singleImportService.importSingleFile(taskContext, file, parentNote); } diff --git a/packages/trilium-core/src/services/sql/sql.ts b/packages/trilium-core/src/services/sql/sql.ts index d878936f2c..90631c5bfd 100644 --- a/packages/trilium-core/src/services/sql/sql.ts +++ b/packages/trilium-core/src/services/sql/sql.ts @@ -312,6 +312,26 @@ export class SqlService { } } + /** + * Async-safe transaction wrapper for use in Web Workers and other single-threaded async contexts. + * Uses manual BEGIN/COMMIT/ROLLBACK because the synchronous `transactional()` cannot await promises. + */ + async transactionalAsync(func: () => Promise): Promise { + this.execute("BEGIN IMMEDIATE"); + try { + const result = await func(); + this.execute("COMMIT"); + if (!this.dbConnection.inTransaction) { + this.params.onTransactionCommit(); + } + return result; + } catch (e) { + this.execute("ROLLBACK"); + this.params.onTransactionRollback(); + throw e; + } + } + fillParamList(paramIds: string[] | Set, truncate = true) { if ("length" in paramIds && paramIds.length === 0) { return;