Browse Source

Merge remote-tracking branch 'refs/remotes/origin/sql-absurd-sql' into sql-absurd-sql

sql-absurd-sql
Matt Raymer 2 weeks ago
parent
commit
52c9e57ef4
  1. 2
      .cursor/rules/crowd-funder-for-time-pwa/docs/camera-implementation.mdc
  2. 4
      BUILDING.md
  3. 2
      src/components/DataExportSection.vue
  4. 14
      src/db-sql/migration.ts
  5. 25
      src/db/index.ts
  6. 5
      src/interfaces/database.ts
  7. 10
      src/libs/util.ts
  8. 11
      src/main.web.ts
  9. 2
      src/registerSQLWorker.js
  10. 12
      src/services/database.d.ts
  11. 65
      src/services/database.ts
  12. 18
      src/services/migrationService.ts
  13. 10
      src/views/AccountViewView.vue
  14. 10
      src/views/ContactQRScanShowView.vue
  15. 10
      src/views/NewIdentifierView.vue
  16. 64
      src/views/TestView.vue

2
.cursor/rules/crowd-funder-for-time-pwa/docs/camera-implementation.mdc

@ -1,7 +1,7 @@
---
description:
globs:
alwaysApply: true
alwaysApply: false
---
# Camera Implementation Documentation

4
BUILDING.md

@ -241,7 +241,9 @@ docker run -d \
1. Build the electron app in production mode:
```bash
npm run build:electron-prod
npm run build:web
npm run electron:build
npm run build:electron-mac
```
2. Package the Electron app for macOS:

2
src/components/DataExportSection.vue

@ -136,7 +136,7 @@ export default class DataExportSection extends Vue {
transform: (table, value, key) => {
if (table === "contacts") {
// Dexie inserts a number 0 when some are undefined, so we need to totally remove them.
Object.keys(value).forEach(prop => {
Object.keys(value).forEach((prop) => {
if (value[prop] === undefined) {
delete value[prop];
}

14
src/db-sql/migration.ts

@ -1,10 +1,10 @@
import migrationService from '../services/migrationService';
import type { QueryExecResult } from '../services/migrationService';
import migrationService from "../services/migrationService";
import type { QueryExecResult } from "../services/migrationService";
// Each migration can include multiple SQL statements (with semicolons)
const MIGRATIONS = [
{
name: '001_initial',
name: "001_initial",
// see ../db/tables files for explanations of the fields
sql: `
CREATE TABLE IF NOT EXISTS accounts (
@ -84,8 +84,8 @@ const MIGRATIONS = [
id TEXT PRIMARY KEY,
blobB64 TEXT
);
`
}
`,
},
];
export async function registerMigrations(): Promise<void> {
@ -96,8 +96,8 @@ export async function registerMigrations(): Promise<void> {
}
export async function runMigrations(
sqlExec: (sql: string, params?: any[]) => Promise<Array<QueryExecResult>>
sqlExec: (sql: string, params?: any[]) => Promise<Array<QueryExecResult>>,
): Promise<void> {
await registerMigrations();
await migrationService.runMigrations(sqlExec);
}
}

25
src/db/index.ts

@ -90,7 +90,10 @@ db.on("populate", async () => {
try {
await db.settings.add(DEFAULT_SETTINGS);
} catch (error) {
console.error("Error populating the database with default settings:", error);
console.error(
"Error populating the database with default settings:",
error,
);
}
});
@ -105,7 +108,7 @@ async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
// Create a promise that rejects after 5 seconds
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Database open timed out')), 500);
setTimeout(() => reject(new Error("Database open timed out")), 500);
});
// Race between the open operation and the timeout
@ -123,7 +126,7 @@ async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
console.error(`Attempt ${i + 1}: Database open failed:`, error);
if (i < retries - 1) {
console.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`);
await new Promise(resolve => setTimeout(resolve, delay));
await new Promise((resolve) => setTimeout(resolve, delay));
} else {
throw error;
}
@ -144,18 +147,24 @@ export async function updateDefaultSettings(
// console.log("Database version:", db.verno);
await safeOpenDatabase();
} catch (openError: unknown) {
console.error("Failed to open database:", openError);
const errorMessage = openError instanceof Error ? openError.message : String(openError);
throw new Error(`Database connection failed: ${errorMessage}. Please try again or restart the app.`);
console.error("Failed to open database:", openError, String(openError));
throw new Error(
`The database connection failed. We recommend you try again or restart the app.`,
);
}
const result = await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges);
const result = await db.settings.update(
MASTER_SETTINGS_KEY,
settingsChanges,
);
return result;
} catch (error) {
console.error("Error updating default settings:", error);
if (error instanceof Error) {
throw error; // Re-throw if it's already an Error with a message
} else {
throw new Error(`Failed to update settings: ${error}`);
throw new Error(
`Failed to update settings. We recommend you try again or restart the app.`,
);
}
}
}

5
src/interfaces/database.ts

@ -8,7 +8,10 @@ export interface QueryExecResult {
export interface DatabaseService {
initialize(): Promise<void>;
query(sql: string, params?: any[]): Promise<QueryExecResult[]>;
run(sql: string, params?: any[]): Promise<{ changes: number; lastId?: number }>;
run(
sql: string,
params?: any[],
): Promise<{ changes: number; lastId?: number }>;
getOneRow(sql: string, params?: any[]): Promise<any[] | undefined>;
getAll(sql: string, params?: any[]): Promise<any[][]>;
}

10
src/libs/util.ts

@ -561,14 +561,16 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
newId.did,
identity,
mnemonic,
newId.keys[0].publicKeyHex
]
newId.keys[0].publicKeyHex,
],
);
await updateDefaultSettings({ activeDid: newId.did });
} catch (error) {
console.error("Failed to update default settings:", error);
throw new Error("Failed to set default settings. Please try again or restart the app.");
throw new Error(
"Failed to set default settings. Please try again or restart the app.",
);
}
await updateAccountSettings(newId.did, { isRegistered: false });
return newId.did;

11
src/main.web.ts

@ -1,4 +1,4 @@
import { initBackend } from 'absurd-sql/dist/indexeddb-main-thread';
import { initBackend } from "absurd-sql/dist/indexeddb-main-thread";
import { initializeApp } from "./main.common";
import "./registerServiceWorker"; // Web PWA support
@ -6,9 +6,12 @@ const app = initializeApp();
function sqlInit() {
// see https://github.com/jlongster/absurd-sql
let worker = new Worker(new URL('./registerSQLWorker.js', import.meta.url), {
type: 'module'
});
const worker = new Worker(
new URL("./registerSQLWorker.js", import.meta.url),
{
type: "module",
},
);
// This is only required because Safari doesn't support nested
// workers. This installs a handler that will proxy creating web
// workers through the main thread

2
src/registerSQLWorker.js

@ -1,4 +1,4 @@
import databaseService from './services/database';
import databaseService from "./services/database";
async function run() {
await databaseService.initialize();

12
src/services/database.d.ts

@ -1,23 +1,25 @@
import { DatabaseService } from '../interfaces/database';
import { DatabaseService } from "../interfaces/database";
declare module '@jlongster/sql.js' {
declare module "@jlongster/sql.js" {
interface SQL {
Database: any;
FS: any;
register_for_idb: (fs: any) => void;
}
function initSqlJs(config: { locateFile: (file: string) => string }): Promise<SQL>;
function initSqlJs(config: {
locateFile: (file: string) => string;
}): Promise<SQL>;
export default initSqlJs;
}
declare module 'absurd-sql' {
declare module "absurd-sql" {
export class SQLiteFS {
constructor(fs: any, backend: any);
}
}
declare module 'absurd-sql/dist/indexeddb-backend' {
declare module "absurd-sql/dist/indexeddb-backend" {
export default class IndexedDBBackend {
constructor();
}

65
src/services/database.ts

@ -1,18 +1,21 @@
// Add type declarations for external modules
declare module '@jlongster/sql.js';
declare module 'absurd-sql';
declare module 'absurd-sql/dist/indexeddb-backend';
declare module "@jlongster/sql.js";
declare module "absurd-sql";
declare module "absurd-sql/dist/indexeddb-backend";
import initSqlJs from '@jlongster/sql.js';
import { SQLiteFS } from 'absurd-sql';
import IndexedDBBackend from 'absurd-sql/dist/indexeddb-backend';
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 { QueryExecResult } from '../interfaces/database';
import { runMigrations } from "../db-sql/migration";
import type { QueryExecResult } from "../interfaces/database";
interface SQLDatabase {
exec: (sql: string, params?: any[]) => Promise<QueryExecResult[]>;
run: (sql: string, params?: any[]) => Promise<{ changes: number; lastId?: number }>;
run: (
sql: string,
params?: any[],
) => Promise<{ changes: number; lastId?: number }>;
}
class DatabaseService {
@ -62,34 +65,39 @@ class DatabaseService {
const SQL = await initSqlJs({
locateFile: (file: string) => {
return new URL(`/node_modules/@jlongster/sql.js/dist/${file}`, import.meta.url).href;
}
return new URL(
`/node_modules/@jlongster/sql.js/dist/${file}`,
import.meta.url,
).href;
},
});
let sqlFS = new SQLiteFS(SQL.FS, new IndexedDBBackend());
const sqlFS = new SQLiteFS(SQL.FS, new IndexedDBBackend());
SQL.register_for_idb(sqlFS);
SQL.FS.mkdir('/sql');
SQL.FS.mount(sqlFS, {}, '/sql');
SQL.FS.mkdir("/sql");
SQL.FS.mount(sqlFS, {}, "/sql");
const path = '/sql/db.sqlite';
if (typeof SharedArrayBuffer === 'undefined') {
let stream = SQL.FS.open(path, 'a+');
const path = "/sql/db.sqlite";
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.');
throw new Error(
"The database initialization failed. We recommend you restart or reinstall.",
);
}
await this.db.exec(`PRAGMA journal_mode=MEMORY;`);
const sqlExec = this.db.exec.bind(this.db);
// Run migrations
await runMigrations(sqlExec);
this.initialized = true;
}
@ -108,13 +116,20 @@ class DatabaseService {
// If initialized but no db, something went wrong
if (!this.db) {
console.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.`);
console.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: any[] = []): Promise<{ changes: number; lastId?: number }> {
async run(
sql: string,
params: any[] = [],
): Promise<{ changes: number; lastId?: number }> {
await this.waitForInitialization();
return this.db!.run(sql, params);
}
@ -141,4 +156,4 @@ class DatabaseService {
// Create a singleton instance
const databaseService = DatabaseService.getInstance();
export default databaseService;
export default databaseService;

18
src/services/migrationService.ts

@ -1,4 +1,4 @@
import { QueryExecResult } from '../interfaces/database';
import { QueryExecResult } from "../interfaces/database";
interface Migration {
name: string;
@ -23,7 +23,7 @@ export class MigrationService {
}
async runMigrations(
sqlExec: (sql: string, params?: any[]) => Promise<Array<QueryExecResult>>
sqlExec: (sql: string, params?: any[]) => Promise<Array<QueryExecResult>>,
): Promise<void> {
// Create migrations table if it doesn't exist
await sqlExec(`
@ -35,12 +35,16 @@ export class MigrationService {
`);
// Get list of executed migrations
const result: QueryExecResult[] = await sqlExec('SELECT name FROM migrations;');
const result: QueryExecResult[] = await sqlExec(
"SELECT name FROM migrations;",
);
let executedMigrations: Set<string> = new Set();
// Even with that query, the QueryExecResult may be [] (which doesn't make sense to me).
if (result.length > 0) {
const singleResult = result[0];
executedMigrations = new Set(singleResult.values.map((row: any[]) => row[0]));
executedMigrations = new Set(
singleResult.values.map((row: any[]) => row[0]),
);
}
// Run pending migrations in order
@ -48,7 +52,9 @@ export class MigrationService {
if (!executedMigrations.has(migration.name)) {
try {
await sqlExec(migration.sql);
await sqlExec('INSERT INTO migrations (name) VALUES (?)', [migration.name]);
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
console.log(`Migration ${migration.name} executed successfully`);
} catch (error) {
console.error(`Error executing migration ${migration.name}:`, error);
@ -59,4 +65,4 @@ export class MigrationService {
}
}
export default MigrationService.getInstance();
export default MigrationService.getInstance();

10
src/views/AccountViewView.vue

@ -76,7 +76,8 @@
Set Your Name
</button>
<p class="text-xs text-slate-500 mt-1">
(Don't worry: this is not visible to anyone until you share it with them. It's not sent to any servers.)
(Don't worry: this is not visible to anyone until you share it with
them. It's not sent to any servers.)
</p>
<UserNameDialog ref="userNameDialog" />
</span>
@ -964,7 +965,7 @@ import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import Dexie from "dexie";
import "dexie-export-import";
// @ts-ignore - they aren't exporting it but it's there
// @ts-expect-error - they aren't exporting it but it's there
import { ImportProgress } from "dexie-export-import";
import { LeafletMouseEvent } from "leaflet";
import * as R from "ramda";
@ -1610,12 +1611,13 @@ export default class AccountViewView extends Vue {
*/
async submitImportFile() {
if (inputImportFileNameRef.value != null) {
await db.delete()
await db
.delete()
.then(async () => {
// BulkError: settings.bulkAdd(): 1 of 21 operations failed. Errors: ConstraintError: Key already exists in the object store.
await Dexie.import(inputImportFileNameRef.value as Blob, {
progressCallback: this.progressCallback,
})
});
})
.catch((error) => {
logger.error("Error importing file:", error);

10
src/views/ContactQRScanShowView.vue

@ -726,9 +726,11 @@ export default class ContactQRScanShow extends Vue {
// Apply mirroring after a short delay to ensure video element is ready
setTimeout(() => {
const videoElement = document.querySelector('.qr-scanner video') as HTMLVideoElement;
const videoElement = document.querySelector(
".qr-scanner video",
) as HTMLVideoElement;
if (videoElement) {
videoElement.style.transform = 'scaleX(-1)';
videoElement.style.transform = "scaleX(-1)";
}
}, 1000);
}
@ -943,7 +945,9 @@ export default class ContactQRScanShow extends Vue {
// Add method to detect desktop browser
private detectDesktopBrowser(): boolean {
const userAgent = navigator.userAgent.toLowerCase();
return !/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
return !/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(
userAgent,
);
}
// Update the computed property for camera mirroring

10
src/views/NewIdentifierView.vue

@ -34,9 +34,13 @@
</div>
<div v-else-if="hitError">
<span class="text-xl">Error Creating Identity</span>
<font-awesome icon="exclamation-triangle" class="fa-fw text-red-500 ml-2"></font-awesome>
<font-awesome
icon="exclamation-triangle"
class="fa-fw text-red-500 ml-2"
></font-awesome>
<p class="text-sm text-gray-500">
Try fully restarting the app. If that doesn't work, back up all data (identities and other data) and reinstall the app.
Try fully restarting the app. If that doesn't work, back up all data
(identities and other data) and reinstall the app.
</p>
</div>
<div v-else>
@ -85,7 +89,7 @@ export default class NewIdentifierView extends Vue {
.catch((error) => {
this.loading = false;
this.hitError = true;
console.error('Failed to generate identity:', error);
console.error("Failed to generate identity:", error);
});
}
}

64
src/views/TestView.vue

@ -161,6 +161,42 @@
</button>
</div>
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">SQL Operations</h2>
<div class="mb-4">
<div class="flex gap-2 mb-2">
<button
class="text-sm text-blue-600 hover:text-blue-800 underline"
@click="
sqlQuery = 'SELECT * FROM sqlite_master WHERE type=\'table\';'
"
>
All Tables
</button>
</div>
<textarea
v-model="sqlQuery"
class="w-full h-32 p-2 border border-gray-300 rounded-md font-mono"
placeholder="Enter your SQL query here..."
></textarea>
</div>
<div class="mb-4">
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="executeSql"
>
Execute
</button>
</div>
<div v-if="sqlResult" class="mt-4">
<h3 class="text-lg font-semibold mb-2">Result:</h3>
<pre class="bg-gray-100 p-4 rounded-md overflow-x-auto">{{
JSON.stringify(sqlResult, null, 2)
}}</pre>
</div>
</div>
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">Image Sharing</h2>
Populates the "shared-photo" view as if they used "share_target".
@ -271,6 +307,7 @@ import { AppString, NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import * as vcLib from "../libs/crypto/vc";
import * as cryptoLib from "../libs/crypto";
import databaseService from "../services/database";
import {
PeerSetup,
@ -316,6 +353,10 @@ export default class Help extends Vue {
peerSetup?: PeerSetup;
userName?: string;
// for SQL operations
sqlQuery = "";
sqlResult: any = null;
cryptoLib = cryptoLib;
async mounted() {
@ -492,5 +533,28 @@ export default class Help extends Vue {
);
logger.log("decoded", decoded);
}
async executeSql() {
try {
const isSelect = this.sqlQuery.trim().toLowerCase().startsWith("select");
if (isSelect) {
this.sqlResult = await databaseService.query(this.sqlQuery);
} else {
this.sqlResult = await databaseService.run(this.sqlQuery);
}
console.log("SQL Result:", this.sqlResult);
} catch (error) {
console.error("SQL Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "SQL Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
}
</script>

Loading…
Cancel
Save