Fix worker-only database architecture and Vue Proxy serialization

- Implement worker-only database access to eliminate double migrations
- Add parameter serialization in usePlatformService to prevent Capacitor "object could not be cloned" errors
- Fix infinite logging loop with circuit breaker in databaseUtil
- Use dynamic imports in WebPlatformService to prevent worker thread errors
- Add higher-level database methods (getContacts, getSettings) to composable
- Eliminate Vue Proxy objects through JSON serialization and Object.freeze protection

Resolves Proxy(Array) serialization failures and worker context conflicts across Web/Capacitor/Electron platforms.
This commit is contained in:
Matthew Raymer
2025-07-02 07:24:51 +00:00
parent d3e0cd1c9f
commit 7b1f891c63
19 changed files with 1790 additions and 121 deletions

View File

@@ -5,7 +5,15 @@ import {
} from "../PlatformService";
import { logger } from "../../utils/logger";
import { QueryExecResult } from "@/interfaces/database";
import databaseService from "../AbsurdSqlDatabaseService";
// Dynamic import of initBackend to prevent worker context errors
import type {
WorkerRequest,
WorkerResponse,
QueryRequest,
ExecRequest,
QueryResult,
GetOneRowRequest,
} from "@/interfaces/worker-messages";
/**
* Platform service implementation for web browser platform.
@@ -16,11 +24,215 @@ import databaseService from "../AbsurdSqlDatabaseService";
* - Image capture using the browser's file input
* - Image selection from local filesystem
* - Image processing and conversion
* - Database operations via worker thread messaging
*
* Note: File system operations are not available in the web platform
* due to browser security restrictions. These methods throw appropriate errors.
*/
export class WebPlatformService implements PlatformService {
private static instanceCount = 0; // Debug counter
private worker: Worker | null = null;
private workerReady = false;
private workerInitPromise: Promise<void> | null = null;
private pendingMessages = new Map<
string,
{
resolve: (_value: unknown) => void;
reject: (_reason: unknown) => void;
timeout: NodeJS.Timeout;
}
>();
private messageIdCounter = 0;
private readonly messageTimeout = 30000; // 30 seconds
constructor() {
WebPlatformService.instanceCount++;
// Only warn if multiple instances (which shouldn't happen with singleton)
if (WebPlatformService.instanceCount > 1) {
console.error(`[WebPlatformService] ERROR: Multiple instances created! Count: ${WebPlatformService.instanceCount}`);
} else {
console.log(`[WebPlatformService] Initializing web platform service`);
}
// Start worker initialization but don't await it in constructor
this.workerInitPromise = this.initializeWorker();
}
/**
* Initialize the SQL worker for database operations
*/
private async initializeWorker(): Promise<void> {
try {
// logger.log("[WebPlatformService] Initializing SQL worker..."); // DISABLED
this.worker = new Worker(
new URL("../../registerSQLWorker.js", import.meta.url),
{ type: "module" },
);
// This is required for Safari compatibility with nested workers
// It installs a handler that proxies web worker creation through the main thread
// CRITICAL: Only call initBackend from main thread, not from worker context
const isMainThread = typeof window !== 'undefined';
if (isMainThread) {
// We're in the main thread - safe to dynamically import and call initBackend
try {
const { initBackend } = await import("absurd-sql/dist/indexeddb-main-thread");
initBackend(this.worker);
} catch (error) {
console.error("[WebPlatformService] Failed to import/call initBackend:", error);
throw error;
}
} else {
// We're in a worker context - skip initBackend call
console.log("[WebPlatformService] Skipping initBackend call in worker context");
}
this.worker.onmessage = (event) => {
this.handleWorkerMessage(event.data);
};
this.worker.onerror = (error) => {
// logger.error("[WebPlatformService] Worker error:", error); // DISABLED
console.error("[WebPlatformService] Worker error:", error);
this.workerReady = false;
};
// Send ping to verify worker is ready
await this.sendWorkerMessage({ type: "ping" });
this.workerReady = true;
// logger.log("[WebPlatformService] SQL worker initialized successfully"); // DISABLED
} catch (error) {
// logger.error("[WebPlatformService] Failed to initialize worker:", error); // DISABLED
console.error("[WebPlatformService] Failed to initialize worker:", error);
this.workerReady = false;
this.workerInitPromise = null;
throw new Error("Failed to initialize database worker");
}
}
/**
* Handle messages received from the worker
*/
private handleWorkerMessage(message: WorkerResponse): void {
const { id, type } = message;
// Handle absurd-sql internal messages (these are normal, don't log)
if (!id && message.type?.startsWith('__absurd:')) {
return; // Internal absurd-sql message, ignore silently
}
if (!id) {
// logger.warn("[WebPlatformService] Received message without ID:", message); // DISABLED
console.warn("[WebPlatformService] Received message without ID:", message);
return;
}
const pending = this.pendingMessages.get(id);
if (!pending) {
// logger.warn( // DISABLED
// "[WebPlatformService] Received response for unknown message ID:",
// id,
// );
console.warn(
"[WebPlatformService] Received response for unknown message ID:",
id,
);
return;
}
// Clear timeout and remove from pending
clearTimeout(pending.timeout);
this.pendingMessages.delete(id);
switch (type) {
case "success":
pending.resolve(message.data);
break;
case "error": {
const error = new Error(message.error.message);
if (message.error.stack) {
error.stack = message.error.stack;
}
pending.reject(error);
break;
}
case "init-complete":
pending.resolve(true);
break;
case "pong":
pending.resolve(true);
break;
default:
// logger.warn("[WebPlatformService] Unknown response type:", type); // DISABLED
console.warn("[WebPlatformService] Unknown response type:", type);
pending.resolve(message);
break;
}
}
/**
* Send a message to the worker and wait for response
*/
private async sendWorkerMessage<T>(
request: Omit<WorkerRequest, "id">,
): Promise<T> {
if (!this.worker) {
throw new Error("Worker not initialized");
}
const id = `msg_${++this.messageIdCounter}_${Date.now()}`;
const fullRequest: WorkerRequest = { id, ...request } as WorkerRequest;
return new Promise<T>((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingMessages.delete(id);
reject(new Error(`Worker message timeout for ${request.type} (${id})`));
}, this.messageTimeout);
this.pendingMessages.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timeout,
});
// logger.log( // DISABLED
// `[WebPlatformService] Sending message: ${request.type} (${id})`,
// );
this.worker!.postMessage(fullRequest);
});
}
/**
* Wait for worker to be ready
*/
private async ensureWorkerReady(): Promise<void> {
// Wait for initial initialization to complete
if (this.workerInitPromise) {
await this.workerInitPromise;
}
if (this.workerReady) {
return;
}
// Try to ping the worker if not ready
try {
await this.sendWorkerMessage<boolean>({ type: "ping" });
this.workerReady = true;
} catch (error) {
// logger.error("[WebPlatformService] Worker not ready:", error); // DISABLED
console.error("[WebPlatformService] Worker not ready:", error);
throw new Error("Database worker not ready");
}
}
/**
* Gets the capabilities of the web platform
* @returns Platform capabilities object
@@ -358,30 +570,43 @@ export class WebPlatformService implements PlatformService {
/**
* @see PlatformService.dbQuery
*/
dbQuery(
async dbQuery(
sql: string,
params?: unknown[],
): Promise<QueryExecResult | undefined> {
return databaseService.query(sql, params).then((result) => result[0]);
await this.ensureWorkerReady();
return this.sendWorkerMessage<QueryResult>({
type: "query",
sql,
params,
} as QueryRequest).then((result) => result.result[0]);
}
/**
* @see PlatformService.dbExec
*/
dbExec(
async dbExec(
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }> {
return databaseService.run(sql, params);
await this.ensureWorkerReady();
return this.sendWorkerMessage<{ changes: number; lastId?: number }>({
type: "exec",
sql,
params,
} as ExecRequest);
}
async dbGetOneRow(
sql: string,
params?: unknown[],
): Promise<unknown[] | undefined> {
return databaseService
.query(sql, params)
.then((result: QueryExecResult[]) => result[0]?.values[0]);
await this.ensureWorkerReady();
return this.sendWorkerMessage<unknown[] | undefined>({
type: "getOneRow",
sql,
params,
} as GetOneRowRequest);
}
/**