Merge branch 'sql-absurd-sql-back'
This commit is contained in:
29
src/services/AbsurdSqlDatabaseService.d.ts
vendored
Normal file
29
src/services/AbsurdSqlDatabaseService.d.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
import { DatabaseService } from "../interfaces/database";
|
||||
|
||||
declare module "@jlongster/sql.js" {
|
||||
interface SQL {
|
||||
Database: unknown;
|
||||
FS: unknown;
|
||||
register_for_idb: (fs: unknown) => void;
|
||||
}
|
||||
|
||||
function initSqlJs(config: {
|
||||
locateFile: (file: string) => string;
|
||||
}): Promise<SQL>;
|
||||
export default initSqlJs;
|
||||
}
|
||||
|
||||
declare module "absurd-sql" {
|
||||
export class SQLiteFS {
|
||||
constructor(fs: unknown, backend: unknown);
|
||||
}
|
||||
}
|
||||
|
||||
declare module "absurd-sql/dist/indexeddb-backend" {
|
||||
export default class IndexedDBBackend {
|
||||
constructor();
|
||||
}
|
||||
}
|
||||
|
||||
declare const databaseService: DatabaseService;
|
||||
export default databaseService;
|
||||
231
src/services/AbsurdSqlDatabaseService.ts
Normal file
231
src/services/AbsurdSqlDatabaseService.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import initSqlJs from "@jlongster/sql.js";
|
||||
import { SQLiteFS } from "absurd-sql";
|
||||
import IndexedDBBackend from "absurd-sql/dist/indexeddb-backend";
|
||||
|
||||
import { runMigrations } from "../db-sql/migration";
|
||||
import type { DatabaseService, QueryExecResult } from "../interfaces/database";
|
||||
import { logger } from "@/utils/logger";
|
||||
|
||||
interface QueuedOperation {
|
||||
type: "run" | "query";
|
||||
sql: string;
|
||||
params: unknown[];
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
}
|
||||
|
||||
interface AbsurdSqlDatabase {
|
||||
exec: (sql: string, params?: unknown[]) => Promise<QueryExecResult[]>;
|
||||
run: (
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
) => Promise<{ changes: number; lastId?: number }>;
|
||||
}
|
||||
|
||||
class AbsurdSqlDatabaseService implements DatabaseService {
|
||||
private static instance: AbsurdSqlDatabaseService | null = null;
|
||||
private db: AbsurdSqlDatabase | null;
|
||||
private initialized: boolean;
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
private operationQueue: Array<QueuedOperation> = [];
|
||||
private isProcessingQueue: boolean = false;
|
||||
|
||||
private constructor() {
|
||||
this.db = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
static getInstance(): AbsurdSqlDatabaseService {
|
||||
if (!AbsurdSqlDatabaseService.instance) {
|
||||
AbsurdSqlDatabaseService.instance = new AbsurdSqlDatabaseService();
|
||||
}
|
||||
return AbsurdSqlDatabaseService.instance;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// If already initialized, return immediately
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If initialization is in progress, wait for it
|
||||
if (this.initializationPromise) {
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
// Start initialization
|
||||
this.initializationPromise = this._initialize();
|
||||
try {
|
||||
await this.initializationPromise;
|
||||
} catch (error) {
|
||||
logger.error(`AbsurdSqlDatabaseService initialize method failed:`, error);
|
||||
this.initializationPromise = null; // Reset on failure
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async _initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const SQL = await initSqlJs({
|
||||
locateFile: (file: string) => {
|
||||
return new URL(
|
||||
`/node_modules/@jlongster/sql.js/dist/${file}`,
|
||||
import.meta.url,
|
||||
).href;
|
||||
},
|
||||
});
|
||||
|
||||
const sqlFS = new SQLiteFS(SQL.FS, new IndexedDBBackend());
|
||||
SQL.register_for_idb(sqlFS);
|
||||
|
||||
SQL.FS.mkdir("/sql");
|
||||
SQL.FS.mount(sqlFS, {}, "/sql");
|
||||
|
||||
const path = "/sql/timesafari.absurd-sql";
|
||||
if (typeof SharedArrayBuffer === "undefined") {
|
||||
const stream = SQL.FS.open(path, "a+");
|
||||
await stream.node.contents.readIfFallback();
|
||||
SQL.FS.close(stream);
|
||||
}
|
||||
|
||||
this.db = new SQL.Database(path, { filename: true });
|
||||
if (!this.db) {
|
||||
throw new Error(
|
||||
"The database initialization failed. We recommend you restart or reinstall.",
|
||||
);
|
||||
}
|
||||
|
||||
// An error is thrown without this pragma: "File has invalid page size. (the first block of a new file must be written first)"
|
||||
await this.db.exec(`PRAGMA journal_mode=MEMORY;`);
|
||||
const sqlExec = this.db.run.bind(this.db);
|
||||
const sqlQuery = this.db.exec.bind(this.db);
|
||||
|
||||
// Extract the migration names for the absurd-sql format
|
||||
const extractMigrationNames: (result: QueryExecResult[]) => Set<string> = (
|
||||
result,
|
||||
) => {
|
||||
// Even with the "select name" query, the QueryExecResult may be [] (which doesn't make sense to me).
|
||||
const names = result?.[0]?.values.map((row) => row[0] as string) || [];
|
||||
return new Set(names);
|
||||
};
|
||||
|
||||
// Run migrations
|
||||
await runMigrations(sqlExec, sqlQuery, extractMigrationNames);
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
// Start processing the queue after initialization
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isProcessingQueue || !this.initialized || !this.db) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessingQueue = true;
|
||||
|
||||
while (this.operationQueue.length > 0) {
|
||||
const operation = this.operationQueue.shift();
|
||||
if (!operation) continue;
|
||||
|
||||
try {
|
||||
let result: unknown;
|
||||
switch (operation.type) {
|
||||
case "run":
|
||||
result = await this.db.run(operation.sql, operation.params);
|
||||
break;
|
||||
case "query":
|
||||
result = await this.db.exec(operation.sql, operation.params);
|
||||
break;
|
||||
}
|
||||
operation.resolve(result);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error while processing SQL queue:",
|
||||
error,
|
||||
" ... for sql:",
|
||||
operation.sql,
|
||||
" ... with params:",
|
||||
operation.params,
|
||||
);
|
||||
operation.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
this.isProcessingQueue = false;
|
||||
}
|
||||
|
||||
private async queueOperation<R>(
|
||||
type: QueuedOperation["type"],
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<R> {
|
||||
return new Promise<R>((resolve, reject) => {
|
||||
const operation: QueuedOperation = {
|
||||
type,
|
||||
sql,
|
||||
params,
|
||||
resolve: (value: unknown) => resolve(value as R),
|
||||
reject,
|
||||
};
|
||||
this.operationQueue.push(operation);
|
||||
|
||||
// If we're already initialized, start processing the queue
|
||||
if (this.initialized && this.db) {
|
||||
this.processQueue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async waitForInitialization(): Promise<void> {
|
||||
// If we have an initialization promise, wait for it
|
||||
if (this.initializationPromise) {
|
||||
await this.initializationPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
// If not initialized and no promise, start initialization
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
return;
|
||||
}
|
||||
|
||||
// If initialized but no db, something went wrong
|
||||
if (!this.db) {
|
||||
logger.error(
|
||||
`Database not properly initialized after await waitForInitialization() - initialized flag is true but db is null`,
|
||||
);
|
||||
throw new Error(
|
||||
`The database could not be initialized. We recommend you restart or reinstall.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Used for inserts, updates, and deletes
|
||||
async run(
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<{ changes: number; lastId?: number }> {
|
||||
await this.waitForInitialization();
|
||||
return this.queueOperation<{ changes: number; lastId?: number }>(
|
||||
"run",
|
||||
sql,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
// Note that the resulting array may be empty if there are no results from the query
|
||||
async query(sql: string, params: unknown[] = []): Promise<QueryExecResult[]> {
|
||||
await this.waitForInitialization();
|
||||
return this.queueOperation<QueryExecResult[]>("query", sql, params);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
const databaseService = AbsurdSqlDatabaseService.getInstance();
|
||||
|
||||
export default databaseService;
|
||||
@@ -1,3 +1,5 @@
|
||||
import { QueryExecResult } from "@/interfaces/database";
|
||||
|
||||
/**
|
||||
* Represents the result of an image capture or selection operation.
|
||||
* Contains both the image data as a Blob and the associated filename.
|
||||
@@ -106,4 +108,26 @@ export interface PlatformService {
|
||||
* @returns Promise that resolves when the deep link has been handled
|
||||
*/
|
||||
handleDeepLink(url: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Executes a SQL query on the database.
|
||||
* @param sql - The SQL query to execute
|
||||
* @param params - The parameters to pass to the query
|
||||
* @returns Promise resolving to the query result
|
||||
*/
|
||||
dbQuery(
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
): Promise<QueryExecResult | undefined>;
|
||||
|
||||
/**
|
||||
* Executes a create/update/delete on the database.
|
||||
* @param sql - The SQL statement to execute
|
||||
* @param params - The parameters to pass to the statement
|
||||
* @returns Promise resolving to the result of the statement
|
||||
*/
|
||||
dbExec(
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
): Promise<{ changes: number; lastId?: number }>;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,13 @@ import {
|
||||
StartScanOptions,
|
||||
LensFacing,
|
||||
} from "@capacitor-mlkit/barcode-scanning";
|
||||
import { QRScannerService, ScanListener, QRScannerOptions } from "./types";
|
||||
import {
|
||||
QRScannerService,
|
||||
ScanListener,
|
||||
QRScannerOptions,
|
||||
CameraStateListener,
|
||||
CameraState,
|
||||
} from "./types";
|
||||
import { logger } from "@/utils/logger";
|
||||
|
||||
export class CapacitorQRScanner implements QRScannerService {
|
||||
@@ -12,6 +18,9 @@ export class CapacitorQRScanner implements QRScannerService {
|
||||
private isScanning = false;
|
||||
private listenerHandles: Array<() => Promise<void>> = [];
|
||||
private cleanupPromise: Promise<void> | null = null;
|
||||
private cameraStateListeners: Set<CameraStateListener> = new Set();
|
||||
private currentState: CameraState = "off";
|
||||
private currentStateMessage?: string;
|
||||
|
||||
async checkPermissions(): Promise<boolean> {
|
||||
try {
|
||||
@@ -79,8 +88,11 @@ export class CapacitorQRScanner implements QRScannerService {
|
||||
}
|
||||
|
||||
try {
|
||||
this.updateCameraState("initializing", "Starting camera...");
|
||||
|
||||
// Ensure we have permissions before starting
|
||||
if (!(await this.checkPermissions())) {
|
||||
this.updateCameraState("permission_denied", "Camera permission denied");
|
||||
logger.debug("Requesting camera permissions");
|
||||
const granted = await this.requestPermissions();
|
||||
if (!granted) {
|
||||
@@ -90,11 +102,16 @@ export class CapacitorQRScanner implements QRScannerService {
|
||||
|
||||
// Check if scanning is supported
|
||||
if (!(await this.isSupported())) {
|
||||
this.updateCameraState(
|
||||
"error",
|
||||
"QR scanning not supported on this device",
|
||||
);
|
||||
throw new Error("QR scanning not supported on this device");
|
||||
}
|
||||
|
||||
logger.info("Starting MLKit scanner");
|
||||
this.isScanning = true;
|
||||
this.updateCameraState("active", "Camera is active");
|
||||
|
||||
const scanOptions: StartScanOptions = {
|
||||
formats: [BarcodeFormat.QrCode],
|
||||
@@ -126,6 +143,7 @@ export class CapacitorQRScanner implements QRScannerService {
|
||||
stack: wrappedError.stack,
|
||||
});
|
||||
this.isScanning = false;
|
||||
this.updateCameraState("error", wrappedError.message);
|
||||
await this.cleanup();
|
||||
this.scanListener?.onError?.(wrappedError);
|
||||
throw wrappedError;
|
||||
@@ -140,6 +158,7 @@ export class CapacitorQRScanner implements QRScannerService {
|
||||
|
||||
try {
|
||||
logger.debug("Stopping QR scanner");
|
||||
this.updateCameraState("off", "Camera stopped");
|
||||
await BarcodeScanner.stopScan();
|
||||
logger.info("QR scanner stopped successfully");
|
||||
} catch (error) {
|
||||
@@ -149,6 +168,7 @@ export class CapacitorQRScanner implements QRScannerService {
|
||||
error: wrappedError.message,
|
||||
stack: wrappedError.stack,
|
||||
});
|
||||
this.updateCameraState("error", wrappedError.message);
|
||||
this.scanListener?.onError?.(wrappedError);
|
||||
throw wrappedError;
|
||||
} finally {
|
||||
@@ -207,4 +227,23 @@ export class CapacitorQRScanner implements QRScannerService {
|
||||
// No-op for native scanner
|
||||
callback(null);
|
||||
}
|
||||
|
||||
addCameraStateListener(listener: CameraStateListener): void {
|
||||
this.cameraStateListeners.add(listener);
|
||||
// Immediately notify the new listener of current state
|
||||
listener.onStateChange(this.currentState, this.currentStateMessage);
|
||||
}
|
||||
|
||||
removeCameraStateListener(listener: CameraStateListener): void {
|
||||
this.cameraStateListeners.delete(listener);
|
||||
}
|
||||
|
||||
private updateCameraState(state: CameraState, message?: string): void {
|
||||
this.currentState = state;
|
||||
this.currentStateMessage = message;
|
||||
// Notify all listeners of state change
|
||||
for (const listener of this.cameraStateListeners) {
|
||||
listener.onStateChange(state, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,14 +30,16 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
private cameraStateListeners: Set<CameraStateListener> = new Set();
|
||||
private currentState: CameraState = "off";
|
||||
private currentStateMessage?: string;
|
||||
private options: QRScannerOptions;
|
||||
|
||||
constructor(private options?: QRScannerOptions) {
|
||||
constructor(options?: QRScannerOptions) {
|
||||
// Generate a short random ID for this scanner instance
|
||||
this.id = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
this.options = options ?? {};
|
||||
logger.error(
|
||||
`[WebInlineQRScanner:${this.id}] Initializing scanner with options:`,
|
||||
{
|
||||
...options,
|
||||
...this.options,
|
||||
buildId: BUILD_ID,
|
||||
targetFps: this.TARGET_FPS,
|
||||
},
|
||||
@@ -494,26 +496,34 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
}
|
||||
}
|
||||
|
||||
async startScan(): Promise<void> {
|
||||
async startScan(options?: QRScannerOptions): Promise<void> {
|
||||
if (this.isScanning) {
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Scanner already running`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update options if provided
|
||||
if (options) {
|
||||
this.options = { ...this.options, ...options };
|
||||
}
|
||||
|
||||
try {
|
||||
this.isScanning = true;
|
||||
this.scanAttempts = 0;
|
||||
this.lastScanTime = Date.now();
|
||||
this.updateCameraState("initializing", "Starting camera...");
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Starting scan`);
|
||||
logger.error(
|
||||
`[WebInlineQRScanner:${this.id}] Starting scan with options:`,
|
||||
this.options,
|
||||
);
|
||||
|
||||
// Get camera stream
|
||||
// Get camera stream with options
|
||||
logger.error(
|
||||
`[WebInlineQRScanner:${this.id}] Requesting camera stream...`,
|
||||
);
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: "environment",
|
||||
facingMode: this.options.camera === "front" ? "user" : "environment",
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
},
|
||||
@@ -527,11 +537,18 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
label: t.label,
|
||||
readyState: t.readyState,
|
||||
})),
|
||||
options: this.options,
|
||||
});
|
||||
|
||||
// Set up video element
|
||||
if (this.video) {
|
||||
this.video.srcObject = this.stream;
|
||||
// Only show preview if showPreview is true
|
||||
if (this.options.showPreview) {
|
||||
this.video.style.display = "block";
|
||||
} else {
|
||||
this.video.style.display = "none";
|
||||
}
|
||||
await this.video.play();
|
||||
logger.error(
|
||||
`[WebInlineQRScanner:${this.id}] Video element started playing`,
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
routeSchema,
|
||||
DeepLinkRoute,
|
||||
} from "../interfaces/deepLinks";
|
||||
import { logConsoleAndDb } from "../db";
|
||||
import { logConsoleAndDb } from "../db/databaseUtil";
|
||||
import type { DeepLinkError } from "../interfaces/deepLinks";
|
||||
|
||||
/**
|
||||
@@ -119,6 +119,15 @@ export class DeepLinkHandler {
|
||||
const [path, queryString] = parts[1].split("?");
|
||||
const [routePath, param] = path.split("/");
|
||||
|
||||
// Validate route exists before proceeding
|
||||
if (!this.ROUTE_MAP[routePath]) {
|
||||
throw {
|
||||
code: "INVALID_ROUTE",
|
||||
message: `Invalid route path: ${routePath}`,
|
||||
details: { routePath },
|
||||
};
|
||||
}
|
||||
|
||||
const query: Record<string, string> = {};
|
||||
if (queryString) {
|
||||
new URLSearchParams(queryString).forEach((value, key) => {
|
||||
@@ -128,11 +137,9 @@ export class DeepLinkHandler {
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (param) {
|
||||
if (this.ROUTE_MAP[routePath].paramKey) {
|
||||
params[this.ROUTE_MAP[routePath].paramKey] = param;
|
||||
} else {
|
||||
params["id"] = param;
|
||||
}
|
||||
// Now we know routePath exists in ROUTE_MAP
|
||||
const routeConfig = this.ROUTE_MAP[routePath];
|
||||
params[routeConfig.paramKey ?? "id"] = param;
|
||||
}
|
||||
return { path: routePath, params, query };
|
||||
}
|
||||
|
||||
60
src/services/migrationService.ts
Normal file
60
src/services/migrationService.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
interface Migration {
|
||||
name: string;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
export class MigrationService {
|
||||
private static instance: MigrationService;
|
||||
private migrations: Migration[] = [];
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): MigrationService {
|
||||
if (!MigrationService.instance) {
|
||||
MigrationService.instance = new MigrationService();
|
||||
}
|
||||
return MigrationService.instance;
|
||||
}
|
||||
|
||||
registerMigration(migration: Migration) {
|
||||
this.migrations.push(migration);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param sqlExec - A function that executes a SQL statement and returns some update result
|
||||
* @param sqlQuery - A function that executes a SQL query and returns the result in some format
|
||||
* @param extractMigrationNames - A function that extracts the names (string array) from a "select name from migrations" query
|
||||
*/
|
||||
async runMigrations<T>(
|
||||
// note that this does not take parameters because the Capacitor SQLite 'execute' is different
|
||||
sqlExec: (sql: string) => Promise<unknown>,
|
||||
sqlQuery: (sql: string) => Promise<T>,
|
||||
extractMigrationNames: (result: T) => Set<string>,
|
||||
): Promise<void> {
|
||||
// Create migrations table if it doesn't exist
|
||||
await sqlExec(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// Get list of executed migrations
|
||||
const result1: T = await sqlQuery("SELECT name FROM migrations;");
|
||||
const executedMigrations = extractMigrationNames(result1);
|
||||
|
||||
// Run pending migrations in order
|
||||
for (const migration of this.migrations) {
|
||||
if (!executedMigrations.has(migration.name)) {
|
||||
await sqlExec(migration.sql);
|
||||
|
||||
await sqlExec(
|
||||
`INSERT INTO migrations (name) VALUES ('${migration.name}')`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MigrationService.getInstance();
|
||||
@@ -1,23 +1,237 @@
|
||||
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
|
||||
import { Camera, CameraResultType, CameraSource, CameraDirection } from "@capacitor/camera";
|
||||
import { Share } from "@capacitor/share";
|
||||
import {
|
||||
SQLiteConnection,
|
||||
SQLiteDBConnection,
|
||||
CapacitorSQLite,
|
||||
capSQLiteChanges,
|
||||
DBSQLiteValues,
|
||||
} from "@capacitor-community/sqlite";
|
||||
|
||||
import { runMigrations } from "@/db-sql/migration";
|
||||
import { QueryExecResult } from "@/interfaces/database";
|
||||
import {
|
||||
ImageResult,
|
||||
PlatformService,
|
||||
PlatformCapabilities,
|
||||
} from "../PlatformService";
|
||||
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
|
||||
import { Camera, CameraResultType, CameraSource, CameraDirection } from "@capacitor/camera";
|
||||
import { Share } from "@capacitor/share";
|
||||
import { logger } from "../../utils/logger";
|
||||
|
||||
interface QueuedOperation {
|
||||
type: "run" | "query";
|
||||
sql: string;
|
||||
params: unknown[];
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform service implementation for Capacitor (mobile) platform.
|
||||
* Provides native mobile functionality through Capacitor plugins for:
|
||||
* - File system operations
|
||||
* - Camera and image picker
|
||||
* - Platform-specific features
|
||||
* - SQLite database operations
|
||||
*/
|
||||
export class CapacitorPlatformService implements PlatformService {
|
||||
/** Current camera direction */
|
||||
private currentDirection: CameraDirection = 'BACK';
|
||||
private currentDirection: CameraDirection = "BACK";
|
||||
|
||||
private sqlite: SQLiteConnection;
|
||||
private db: SQLiteDBConnection | null = null;
|
||||
private dbName = "timesafari.sqlite";
|
||||
private initialized = false;
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
private operationQueue: Array<QueuedOperation> = [];
|
||||
private isProcessingQueue: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.sqlite = new SQLiteConnection(CapacitorSQLite);
|
||||
}
|
||||
|
||||
private async initializeDatabase(): Promise<void> {
|
||||
// If already initialized, return immediately
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If initialization is in progress, wait for it
|
||||
if (this.initializationPromise) {
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
// Start initialization
|
||||
this.initializationPromise = this._initialize();
|
||||
try {
|
||||
await this.initializationPromise;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[CapacitorPlatformService] Initialize method failed:",
|
||||
error,
|
||||
);
|
||||
this.initializationPromise = null; // Reset on failure
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async _initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create/Open database
|
||||
this.db = await this.sqlite.createConnection(
|
||||
this.dbName,
|
||||
false,
|
||||
"no-encryption",
|
||||
1,
|
||||
false,
|
||||
);
|
||||
|
||||
await this.db.open();
|
||||
|
||||
// Set journal mode to WAL for better performance
|
||||
// await this.db.execute("PRAGMA journal_mode=WAL;");
|
||||
|
||||
// Run migrations
|
||||
await this.runCapacitorMigrations();
|
||||
|
||||
this.initialized = true;
|
||||
logger.log(
|
||||
"[CapacitorPlatformService] SQLite database initialized successfully",
|
||||
);
|
||||
|
||||
// Start processing the queue after initialization
|
||||
this.processQueue();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[CapacitorPlatformService] Error initializing SQLite database:",
|
||||
error,
|
||||
);
|
||||
throw new Error(
|
||||
"[CapacitorPlatformService] Failed to initialize database",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isProcessingQueue || !this.initialized || !this.db) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessingQueue = true;
|
||||
|
||||
while (this.operationQueue.length > 0) {
|
||||
const operation = this.operationQueue.shift();
|
||||
if (!operation) continue;
|
||||
|
||||
try {
|
||||
let result: unknown;
|
||||
switch (operation.type) {
|
||||
case "run": {
|
||||
const runResult = await this.db.run(
|
||||
operation.sql,
|
||||
operation.params,
|
||||
);
|
||||
result = {
|
||||
changes: runResult.changes?.changes || 0,
|
||||
lastId: runResult.changes?.lastId,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "query": {
|
||||
const queryResult = await this.db.query(
|
||||
operation.sql,
|
||||
operation.params,
|
||||
);
|
||||
result = {
|
||||
columns: Object.keys(queryResult.values?.[0] || {}),
|
||||
values: (queryResult.values || []).map((row) =>
|
||||
Object.values(row),
|
||||
),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
operation.resolve(result);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[CapacitorPlatformService] Error while processing SQL queue:",
|
||||
error,
|
||||
);
|
||||
operation.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
this.isProcessingQueue = false;
|
||||
}
|
||||
|
||||
private async queueOperation<R>(
|
||||
type: QueuedOperation["type"],
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<R> {
|
||||
return new Promise<R>((resolve, reject) => {
|
||||
const operation: QueuedOperation = {
|
||||
type,
|
||||
sql,
|
||||
params,
|
||||
resolve: (value: unknown) => resolve(value as R),
|
||||
reject,
|
||||
};
|
||||
this.operationQueue.push(operation);
|
||||
|
||||
// If we're already initialized, start processing the queue
|
||||
if (this.initialized && this.db) {
|
||||
this.processQueue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async waitForInitialization(): Promise<void> {
|
||||
// If we have an initialization promise, wait for it
|
||||
if (this.initializationPromise) {
|
||||
await this.initializationPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
// If not initialized and no promise, start initialization
|
||||
if (!this.initialized) {
|
||||
await this.initializeDatabase();
|
||||
return;
|
||||
}
|
||||
|
||||
// If initialized but no db, something went wrong
|
||||
if (!this.db) {
|
||||
logger.error(
|
||||
"[CapacitorPlatformService] Database not properly initialized after await waitForInitialization() - initialized flag is true but db is null",
|
||||
);
|
||||
throw new Error(
|
||||
"[CapacitorPlatformService] The database could not be initialized. We recommend you restart or reinstall.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async runCapacitorMigrations(): Promise<void> {
|
||||
if (!this.db) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
|
||||
const sqlExec: (sql: string) => Promise<capSQLiteChanges> =
|
||||
this.db.execute.bind(this.db);
|
||||
const sqlQuery: (sql: string) => Promise<DBSQLiteValues> =
|
||||
this.db.query.bind(this.db);
|
||||
const extractMigrationNames: (result: DBSQLiteValues) => Set<string> = (
|
||||
result,
|
||||
) => {
|
||||
const names =
|
||||
result.values?.map((row: { name: string }) => row.name) || [];
|
||||
return new Set(names);
|
||||
};
|
||||
runMigrations(sqlExec, sqlQuery, extractMigrationNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the capabilities of the Capacitor platform
|
||||
@@ -189,6 +403,9 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
*/
|
||||
async writeFile(fileName: string, content: string): Promise<void> {
|
||||
try {
|
||||
// Check storage permissions before proceeding
|
||||
await this.checkStoragePermissions();
|
||||
|
||||
const logData = {
|
||||
targetFileName: fileName,
|
||||
contentLength: content.length,
|
||||
@@ -330,6 +547,9 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2));
|
||||
|
||||
try {
|
||||
// Check storage permissions before proceeding
|
||||
await this.checkStoragePermissions();
|
||||
|
||||
const { uri } = await Filesystem.writeFile({
|
||||
path: fileName,
|
||||
data: content,
|
||||
@@ -490,4 +710,27 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
// This is just a placeholder for the interface
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* @see PlatformService.dbQuery
|
||||
*/
|
||||
async dbQuery(sql: string, params?: unknown[]): Promise<QueryExecResult> {
|
||||
await this.waitForInitialization();
|
||||
return this.queueOperation<QueryExecResult>("query", sql, params || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see PlatformService.dbExec
|
||||
*/
|
||||
async dbExec(
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
): Promise<{ changes: number; lastId?: number }> {
|
||||
await this.waitForInitialization();
|
||||
return this.queueOperation<{ changes: number; lastId?: number }>(
|
||||
"run",
|
||||
sql,
|
||||
params || [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,20 +4,195 @@ import {
|
||||
PlatformCapabilities,
|
||||
} from "../PlatformService";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { QueryExecResult, SqlValue } from "@/interfaces/database";
|
||||
import {
|
||||
SQLiteConnection,
|
||||
SQLiteDBConnection,
|
||||
CapacitorSQLite,
|
||||
Changes,
|
||||
} from "@capacitor-community/sqlite";
|
||||
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform service implementation for Electron (desktop) platform.
|
||||
* Note: This is a placeholder implementation with most methods currently unimplemented.
|
||||
* Implements the PlatformService interface but throws "Not implemented" errors for most operations.
|
||||
*
|
||||
* @remarks
|
||||
* This service is intended for desktop application functionality through Electron.
|
||||
* Future implementations should provide:
|
||||
* - Native file system access
|
||||
* - Desktop camera integration
|
||||
* - System-level features
|
||||
* Provides native desktop functionality through Electron and Capacitor plugins for:
|
||||
* - File system operations (TODO)
|
||||
* - Camera integration (TODO)
|
||||
* - SQLite database operations
|
||||
* - System-level features (TODO)
|
||||
*/
|
||||
export class ElectronPlatformService implements PlatformService {
|
||||
private sqlite: SQLiteConnection;
|
||||
private db: SQLiteDBConnection | null = null;
|
||||
private dbName = "timesafari.db";
|
||||
private initialized = false;
|
||||
|
||||
constructor() {
|
||||
this.sqlite = new SQLiteConnection(CapacitorSQLite);
|
||||
}
|
||||
|
||||
private async initializeDatabase(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create/Open database
|
||||
this.db = await this.sqlite.createConnection(
|
||||
this.dbName,
|
||||
false,
|
||||
"no-encryption",
|
||||
1,
|
||||
false,
|
||||
);
|
||||
|
||||
await this.db.open();
|
||||
|
||||
// Set journal mode to WAL for better performance
|
||||
await this.db.execute("PRAGMA journal_mode=WAL;");
|
||||
|
||||
// Run migrations
|
||||
await this.runMigrations();
|
||||
|
||||
this.initialized = true;
|
||||
logger.log(
|
||||
"[ElectronPlatformService] SQLite database initialized successfully",
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[ElectronPlatformService] Error initializing SQLite database:",
|
||||
error,
|
||||
);
|
||||
throw new Error(
|
||||
"[ElectronPlatformService] Failed to initialize database",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async runMigrations(): Promise<void> {
|
||||
if (!this.db) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
|
||||
// Create migrations table if it doesn't exist
|
||||
await this.db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// Get list of executed migrations
|
||||
const result = await this.db.query("SELECT name FROM migrations;");
|
||||
const executedMigrations = new Set(
|
||||
result.values?.map((row) => row[0]) || [],
|
||||
);
|
||||
|
||||
// Run pending migrations in order
|
||||
const migrations: Migration[] = [
|
||||
{
|
||||
name: "001_initial",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dateCreated TEXT NOT NULL,
|
||||
derivationPath TEXT,
|
||||
did TEXT NOT NULL,
|
||||
identityEncrBase64 TEXT,
|
||||
mnemonicEncrBase64 TEXT,
|
||||
passkeyCredIdHex TEXT,
|
||||
publicKeyHex TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_did ON accounts(did);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS secret (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
secretBase64 TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
accountDid TEXT,
|
||||
activeDid TEXT,
|
||||
apiServer TEXT,
|
||||
filterFeedByNearby BOOLEAN,
|
||||
filterFeedByVisible BOOLEAN,
|
||||
finishedOnboarding BOOLEAN,
|
||||
firstName TEXT,
|
||||
hideRegisterPromptOnNewContact BOOLEAN,
|
||||
isRegistered BOOLEAN,
|
||||
lastName TEXT,
|
||||
lastAckedOfferToUserJwtId TEXT,
|
||||
lastAckedOfferToUserProjectsJwtId TEXT,
|
||||
lastNotifiedClaimId TEXT,
|
||||
lastViewedClaimId TEXT,
|
||||
notifyingNewActivityTime TEXT,
|
||||
notifyingReminderMessage TEXT,
|
||||
notifyingReminderTime TEXT,
|
||||
partnerApiServer TEXT,
|
||||
passkeyExpirationMinutes INTEGER,
|
||||
profileImageUrl TEXT,
|
||||
searchBoxes TEXT,
|
||||
showContactGivesInline BOOLEAN,
|
||||
showGeneralAdvanced BOOLEAN,
|
||||
showShortcutBvc BOOLEAN,
|
||||
vapid TEXT,
|
||||
warnIfProdServer BOOLEAN,
|
||||
warnIfTestServer BOOLEAN,
|
||||
webPushServer TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
|
||||
|
||||
INSERT INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
did TEXT NOT NULL,
|
||||
name TEXT,
|
||||
contactMethods TEXT,
|
||||
nextPubKeyHashB64 TEXT,
|
||||
notes TEXT,
|
||||
profileImageUrl TEXT,
|
||||
publicKeyBase64 TEXT,
|
||||
seesMe BOOLEAN,
|
||||
registered BOOLEAN
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
date TEXT PRIMARY KEY,
|
||||
message TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS temp (
|
||||
id TEXT PRIMARY KEY,
|
||||
blobB64 TEXT
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const migration of migrations) {
|
||||
if (!executedMigrations.has(migration.name)) {
|
||||
await this.db.execute(migration.sql);
|
||||
await this.db.run("INSERT INTO migrations (name) VALUES (?)", [
|
||||
migration.name,
|
||||
]);
|
||||
logger.log(`Migration ${migration.name} executed successfully`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the capabilities of the Electron platform
|
||||
* @returns Platform capabilities object
|
||||
@@ -55,6 +230,17 @@ export class ElectronPlatformService implements PlatformService {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes content to a file and opens the system share dialog.
|
||||
* @param _fileName - Name of the file to create
|
||||
* @param _content - Content to write to the file
|
||||
* @throws Error with "Not implemented" message
|
||||
* @todo Implement using Electron's dialog and file system APIs
|
||||
*/
|
||||
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a file from the filesystem.
|
||||
* @param _path - Path to the file to delete
|
||||
@@ -108,4 +294,55 @@ export class ElectronPlatformService implements PlatformService {
|
||||
logger.error("handleDeepLink not implemented in Electron platform");
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* @see PlatformService.dbQuery
|
||||
*/
|
||||
async dbQuery(sql: string, params?: unknown[]): Promise<QueryExecResult> {
|
||||
await this.initializeDatabase();
|
||||
if (!this.db) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.db.query(sql, params || []);
|
||||
const values = result.values || [];
|
||||
return {
|
||||
columns: [], // SQLite plugin doesn't provide column names in query result
|
||||
values: values as SqlValue[][],
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error executing query:", error);
|
||||
throw new Error(
|
||||
`Database query failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @see PlatformService.dbExec
|
||||
*/
|
||||
async dbExec(
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
): Promise<{ changes: number; lastId?: number }> {
|
||||
await this.initializeDatabase();
|
||||
if (!this.db) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.db.run(sql, params || []);
|
||||
const changes = result.changes as Changes;
|
||||
return {
|
||||
changes: changes?.changes || 0,
|
||||
lastId: changes?.lastId,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error executing statement:", error);
|
||||
throw new Error(
|
||||
`Database execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
PlatformCapabilities,
|
||||
} from "../PlatformService";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { QueryExecResult } from "@/interfaces/database";
|
||||
|
||||
/**
|
||||
* Platform service implementation for PyWebView platform.
|
||||
@@ -109,4 +110,26 @@ export class PyWebViewPlatformService implements PlatformService {
|
||||
logger.error("handleDeepLink not implemented in PyWebView platform");
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
dbQuery(sql: string, params?: unknown[]): Promise<QueryExecResult> {
|
||||
throw new Error("Not implemented for " + sql + " with params " + params);
|
||||
}
|
||||
dbExec(
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
): Promise<{ changes: number; lastId?: number }> {
|
||||
throw new Error("Not implemented for " + sql + " with params " + params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Should write and share a file using the Python backend.
|
||||
* @param _fileName - Name of the file to write and share
|
||||
* @param _content - Content to write to the file
|
||||
* @throws Error with "Not implemented" message
|
||||
* @todo Implement file writing and sharing through pywebview's Python-JavaScript bridge
|
||||
*/
|
||||
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
|
||||
logger.error("writeAndShareFile not implemented in PyWebView platform");
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
PlatformCapabilities,
|
||||
} from "../PlatformService";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { QueryExecResult } from "@/interfaces/database";
|
||||
import databaseService from "../AbsurdSqlDatabaseService";
|
||||
|
||||
/**
|
||||
* Platform service implementation for web browser platform.
|
||||
@@ -359,4 +361,33 @@ export class WebPlatformService implements PlatformService {
|
||||
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
|
||||
throw new Error("File system access not available in web platform");
|
||||
}
|
||||
|
||||
/**
|
||||
* @see PlatformService.dbQuery
|
||||
*/
|
||||
dbQuery(
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
): Promise<QueryExecResult | undefined> {
|
||||
return databaseService.query(sql, params).then((result) => result[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see PlatformService.dbExec
|
||||
*/
|
||||
dbExec(
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
): Promise<{ changes: number; lastId?: number }> {
|
||||
return databaseService.run(sql, params);
|
||||
}
|
||||
|
||||
async dbGetOneRow(
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
): Promise<unknown[] | undefined> {
|
||||
return databaseService
|
||||
.query(sql, params)
|
||||
.then((result: QueryExecResult[]) => result[0]?.values[0]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user