diff --git a/package-lock.json b/package-lock.json index 02b2365..a0b81ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "drizzle-orm": "^0.39.1", "electron-squirrel-startup": "^1.0.1", "fastify": "^5.6.2", "find-process": "^2.0.0", @@ -10897,7 +10898,7 @@ "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -15119,6 +15120,123 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/drizzle-orm": { + "version": "0.39.3", + "resolved": "https://registry.npmmirror.com/drizzle-orm/-/drizzle-orm-0.39.3.tgz", + "integrity": "sha512-EZ8ZpYvDIvKU9C56JYLOmUskazhad+uXZCTCRN4OnRMsL+xAJ05dv1eCpAG5xzhsm1hqiuC5kAZUCS924u2DTw==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/ds-store": { "version": "0.1.6", "resolved": "https://registry.npmmirror.com/ds-store/-/ds-store-0.1.6.tgz", diff --git a/package.json b/package.json index 30403ce..d485d64 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "drizzle-orm": "^0.39.1", "electron-squirrel-startup": "^1.0.1", "fastify": "^5.6.2", "find-process": "^2.0.0", diff --git a/src/ipc/database/cloudHandler.ts b/src/ipc/database/cloudHandler.ts index af06041..f7f0299 100644 --- a/src/ipc/database/cloudHandler.ts +++ b/src/ipc/database/cloudHandler.ts @@ -2,12 +2,50 @@ import Database from 'better-sqlite3'; import path from 'path'; import fs from 'fs'; import { v4 as uuidv4 } from 'uuid'; +import { desc, eq } from 'drizzle-orm'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths'; import { logger } from '../../utils/logger'; import { CloudAccount } from '../../types/cloudAccount'; +import { ItemTableValueRowSchema, TableInfoRowSchema } from '../../types/db'; import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security'; import { ProtobufUtils } from '../../utils/protobuf'; import { GoogleAPIService } from '../../services/GoogleAPIService'; +import { getAntigravityVersion, isNewVersion } from '../../utils/antigravityVersion'; +import { parseRow, parseRows } from '../../utils/sqlite'; +import { configureDatabase, openDrizzleConnection } from './dbConnection'; +import { accounts, itemTable, settings } from './schema'; +import * as drizzleSchema from './schema'; + +const SQLITE_BUSY_CODES = new Set(['SQLITE_BUSY', 'SQLITE_LOCKED']); +const SQLITE_BUSY_TIMEOUT_MS = 3000; +const SQLITE_RETRY_DELAY_MS = 150; +const SQLITE_MAX_RETRIES = 3; + +type DrizzleExecutor = Pick< + BetterSQLite3Database, + 'insert' | 'update' | 'delete' | 'select' +>; + +function isSqliteBusyError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + const err = error as { code?: string; message?: string }; + if (err.code && SQLITE_BUSY_CODES.has(err.code)) { + return true; + } + if (typeof err.message === 'string') { + return err.message.includes('SQLITE_BUSY') || err.message.includes('SQLITE_LOCKED'); + } + return false; +} + +function sleepSync(ms: number): void { + const buffer = new SharedArrayBuffer(4); + const array = new Int32Array(buffer); + Atomics.wait(array, 0, 0, ms); +} /** * Ensures that the cloud database file and schema exist. @@ -22,7 +60,7 @@ function ensureDatabaseInitialized(dbPath: string): void { let db: Database.Database | null = null; try { db = new Database(dbPath); - db.pragma('journal_mode = WAL'); + configureDatabase(db, { busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS }); // Create accounts table // Storing complex objects (token, quota) as JSON strings for simplicity @@ -43,7 +81,8 @@ function ensureDatabaseInitialized(dbPath: string): void { `); // Migration: Check if is_active column exists - const tableInfo = db.pragma('table_info(accounts)') as any[]; + const tableInfoRaw = db.pragma('table_info(accounts)') as any[]; + const tableInfo = parseRows(TableInfoRowSchema, tableInfoRaw, 'cloud.accounts.tableInfo'); const hasIsActive = tableInfo.some((col) => col.name === 'is_active'); if (!hasIsActive) { db.exec('ALTER TABLE accounts ADD COLUMN is_active INTEGER DEFAULT 0'); @@ -71,12 +110,28 @@ function ensureDatabaseInitialized(dbPath: string): void { /** * Gets a connection to the cloud accounts database. */ -function getDb(): Database.Database { +function getCloudDb(): { + raw: Database.Database; + orm: BetterSQLite3Database; +} { const dbPath = getCloudAccountsDbPath(); ensureDatabaseInitialized(dbPath); - const db = new Database(dbPath); - db.pragma('journal_mode = WAL'); - return db; + return openDrizzleConnection( + dbPath, + { readonly: false, fileMustExist: false }, + { busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS }, + ); +} + +function getIdeDb( + dbPath: string, + readOnly: boolean, +): { raw: Database.Database; orm: BetterSQLite3Database } { + return openDrizzleConnection( + dbPath, + { readonly: readOnly }, + { readOnly, busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS }, + ); } interface MigrationStats { @@ -102,9 +157,9 @@ function createMigrationStats(): MigrationStats { } async function decryptAndMigrateField( - db: Database.Database, + orm: DrizzleExecutor, accountId: string, - field: 'token_json' | 'quota_json', + field: 'tokenJson' | 'quotaJson', value: string | null, ): Promise<{ value: string | null; migrated: boolean; usedFallback?: KeySource }> { if (!value) { @@ -113,16 +168,18 @@ async function decryptAndMigrateField( const result = await decryptWithMigration(value); if (result.reencrypted) { - if (field === 'token_json') { - db.prepare('UPDATE accounts SET token_json = ? WHERE id = ?').run( - result.reencrypted, - accountId, - ); + if (field === 'tokenJson') { + orm + .update(accounts) + .set({ tokenJson: result.reencrypted }) + .where(eq(accounts.id, accountId)) + .run(); } else { - db.prepare('UPDATE accounts SET quota_json = ? WHERE id = ?').run( - result.reencrypted, - accountId, - ); + orm + .update(accounts) + .set({ quotaJson: result.reencrypted }) + .where(eq(accounts.id, accountId)) + .run(); } logger.info( `Migrated ${field} for account ${accountId} from ${result.usedFallback ?? 'unknown'} key`, @@ -139,6 +196,8 @@ async function decryptAndMigrateField( type DecryptFieldResult = Awaited>; export class CloudAccountRepo { + private static versionFailureLogged = false; + static async init(): Promise { const dbPath = getCloudAccountsDbPath(); ensureDatabaseInitialized(dbPath); @@ -146,14 +205,21 @@ export class CloudAccountRepo { } static async migrateToEncrypted(): Promise { - const db = getDb(); + const { raw, orm } = getCloudDb(); try { - const rows = db.prepare('SELECT id, token_json, quota_json FROM accounts').all() as any[]; + const rows = orm + .select({ + id: accounts.id, + tokenJson: accounts.tokenJson, + quotaJson: accounts.quotaJson, + }) + .from(accounts) + .all(); for (const row of rows) { let changed = false; - let newToken = row.token_json; - let newQuota = row.quota_json; + let newToken = row.tokenJson; + let newQuota = row.quotaJson; // Check if plain text (starts with {) if (newToken && newToken.startsWith('{')) { @@ -166,105 +232,109 @@ export class CloudAccountRepo { } if (changed) { - db.prepare('UPDATE accounts SET token_json = ?, quota_json = ? WHERE id = ?').run( - newToken, - newQuota, - row.id, - ); + orm + .update(accounts) + .set({ tokenJson: newToken, quotaJson: newQuota }) + .where(eq(accounts.id, row.id)) + .run(); logger.info(`Migrated account ${row.id} to encrypted storage`); } } } catch (e) { logger.error('Failed to migrate data', e); } finally { - db.close(); + raw.close(); } } static async addAccount(account: CloudAccount): Promise { - const db = getDb(); - + const { raw, orm } = getCloudDb(); try { const tokenEncrypted = await encrypt(JSON.stringify(account.token)); const quotaEncrypted = account.quota ? await encrypt(JSON.stringify(account.quota)) : null; + const values = { + id: account.id, + provider: account.provider, + email: account.email, + name: account.name ?? null, + avatarUrl: account.avatar_url ?? null, + tokenJson: tokenEncrypted, + quotaJson: quotaEncrypted, + createdAt: account.created_at, + lastUsed: account.last_used, + status: account.status || 'active', + isActive: account.is_active ? 1 : 0, + }; - const transaction = db.transaction(() => { + orm.transaction((tx) => { // If this account is being set to active, deactivate all others first if (account.is_active) { logger.info( `[DEBUG] addAccount: Deactivating all other accounts because ${account.email} is active`, ); - const info = db.prepare('UPDATE accounts SET is_active = 0').run(); + const info = tx.update(accounts).set({ isActive: 0 }).run(); logger.info(`[DEBUG] addAccount: Deactivation changed ${info.changes} rows`); } - - const stmt = db.prepare(` - INSERT OR REPLACE INTO accounts ( - id, provider, email, name, avatar_url, token_json, quota_json, created_at, last_used, status, is_active - ) VALUES ( - @id, @provider, @email, @name, @avatar_url, @token_json, @quota_json, @created_at, @last_used, @status, @is_active - ) - `); - - stmt.run({ - id: account.id, - provider: account.provider, - email: account.email, - name: account.name || null, - avatar_url: account.avatar_url || null, - token_json: tokenEncrypted, - quota_json: quotaEncrypted, - created_at: account.created_at, - last_used: account.last_used, - status: account.status || 'active', - is_active: account.is_active ? 1 : 0, - }); + tx.insert(accounts) + .values(values) + .onConflictDoUpdate({ + target: accounts.id, + set: values, + }) + .run(); }); - - transaction(); logger.info(`Added/Updated cloud account: ${account.email}`); } finally { - db.close(); + raw.close(); } } static async getAccounts(): Promise { - const db = getDb(); + const { raw, orm } = getCloudDb(); const migrationStats = createMigrationStats(); try { - const stmt = db.prepare('SELECT * FROM accounts ORDER BY last_used DESC'); - const rows = stmt.all() as any[]; + const rows = orm.select().from(accounts).orderBy(desc(accounts.lastUsed)).all(); // DEBUG LOGS - const activeRows = rows.filter((r) => r.is_active); + const activeRows = rows.filter((r) => r.isActive); logger.info( `[DEBUG] getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`, ); activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`)); - const accounts: CloudAccount[] = []; - for (const row of rows) { + const cloudAccounts: CloudAccount[] = []; + for (const normalizedRow of rows) { let tokenResult: DecryptFieldResult; try { - tokenResult = await decryptAndMigrateField(db, row.id, 'token_json', row.token_json); + tokenResult = await decryptAndMigrateField( + orm, + normalizedRow.id, + 'tokenJson', + normalizedRow.tokenJson, + ); } catch (error) { migrationStats.failedFields += 1; - logger.error(`Failed to decrypt token for account ${row.id}`, error); + logger.error(`Failed to decrypt token for account ${normalizedRow.id}`, error); throw error; } let quotaResult: DecryptFieldResult; try { - quotaResult = await decryptAndMigrateField(db, row.id, 'quota_json', row.quota_json); + quotaResult = await decryptAndMigrateField( + orm, + normalizedRow.id, + 'quotaJson', + normalizedRow.quotaJson, + ); } catch (error) { migrationStats.failedFields += 1; - logger.error(`Failed to decrypt quota for account ${row.id}`, error); + logger.error(`Failed to decrypt quota for account ${normalizedRow.id}`, error); throw error; } if (!tokenResult.value) { - throw new Error(`Missing token data for account ${row.id}`); + throw new Error(`Missing token data for account ${normalizedRow.id}`); } if (tokenResult.value) { @@ -293,22 +363,22 @@ export class CloudAccountRepo { } } - accounts.push({ - id: row.id, - provider: row.provider, - email: row.email, - name: row.name, - avatar_url: row.avatar_url, + cloudAccounts.push({ + id: normalizedRow.id, + provider: normalizedRow.provider as CloudAccount['provider'], + email: normalizedRow.email, + name: normalizedRow.name ?? undefined, + avatar_url: normalizedRow.avatarUrl ?? undefined, token: JSON.parse(tokenResult.value), quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined, - created_at: row.created_at, - last_used: row.last_used, - status: row.status, - is_active: Boolean(row.is_active), + created_at: normalizedRow.createdAt, + last_used: normalizedRow.lastUsed, + status: (normalizedRow.status as CloudAccount['status']) ?? undefined, + is_active: Boolean(normalizedRow.isActive), }); } - return accounts; + return cloudAccounts; } finally { if ( migrationStats.migratedFields > 0 || @@ -328,106 +398,321 @@ export class CloudAccountRepo { logger.info('CloudAccountRepo migration summary', summary); } } - db.close(); + raw.close(); } } static async getAccount(id: string): Promise { - const db = getDb(); + const { raw, orm } = getCloudDb(); try { - const stmt = db.prepare('SELECT * FROM accounts WHERE id = ?'); - const row = stmt.get(id) as any; + const rows = orm.select().from(accounts).where(eq(accounts.id, id)).all(); + const normalizedRow = rows[0]; + if (!normalizedRow) { + return undefined; + } - if (!row) return undefined; - - const tokenResult = await decryptAndMigrateField(db, row.id, 'token_json', row.token_json); - const quotaResult = await decryptAndMigrateField(db, row.id, 'quota_json', row.quota_json); + const tokenResult = await decryptAndMigrateField( + orm, + normalizedRow.id, + 'tokenJson', + normalizedRow.tokenJson, + ); + const quotaResult = await decryptAndMigrateField( + orm, + normalizedRow.id, + 'quotaJson', + normalizedRow.quotaJson, + ); if (!tokenResult.value) { - throw new Error(`Missing token data for account ${row.id}`); + throw new Error(`Missing token data for account ${normalizedRow.id}`); } return { - id: row.id, - provider: row.provider, - email: row.email, - name: row.name, - avatar_url: row.avatar_url, + id: normalizedRow.id, + provider: normalizedRow.provider as CloudAccount['provider'], + email: normalizedRow.email, + name: normalizedRow.name ?? undefined, + avatar_url: normalizedRow.avatarUrl ?? undefined, token: JSON.parse(tokenResult.value), quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined, - created_at: row.created_at, - last_used: row.last_used, - status: row.status, - is_active: Boolean(row.is_active), + created_at: normalizedRow.createdAt, + last_used: normalizedRow.lastUsed, + status: (normalizedRow.status as CloudAccount['status']) ?? undefined, + is_active: Boolean(normalizedRow.isActive), }; } finally { - db.close(); + raw.close(); } } static async removeAccount(id: string): Promise { - const db = getDb(); + const { raw, orm } = getCloudDb(); try { - db.prepare('DELETE FROM accounts WHERE id = ?').run(id); + orm.delete(accounts).where(eq(accounts.id, id)).run(); logger.info(`Removed cloud account: ${id}`); } finally { - db.close(); + raw.close(); } } static async updateToken(id: string, token: any): Promise { - const db = getDb(); + const { raw, orm } = getCloudDb(); try { const encrypted = await encrypt(JSON.stringify(token)); - db.prepare('UPDATE accounts SET token_json = ? WHERE id = ?').run(encrypted, id); + orm.update(accounts).set({ tokenJson: encrypted }).where(eq(accounts.id, id)).run(); } finally { - db.close(); + raw.close(); } } static async updateQuota(id: string, quota: any): Promise { - const db = getDb(); + const { raw, orm } = getCloudDb(); try { const encrypted = await encrypt(JSON.stringify(quota)); - db.prepare('UPDATE accounts SET quota_json = ? WHERE id = ?').run(encrypted, id); + orm.update(accounts).set({ quotaJson: encrypted }).where(eq(accounts.id, id)).run(); } finally { - db.close(); + raw.close(); } } static updateLastUsed(id: string): void { - const db = getDb(); + const { raw, orm } = getCloudDb(); try { - db.prepare('UPDATE accounts SET last_used = ? WHERE id = ?').run( - Math.floor(Date.now() / 1000), - id, - ); + orm + .update(accounts) + .set({ lastUsed: Math.floor(Date.now() / 1000) }) + .where(eq(accounts.id, id)) + .run(); } finally { - db.close(); + raw.close(); } } static setActive(id: string): void { - const db = getDb(); - const updateAll = db.prepare('UPDATE accounts SET is_active = 0'); - const updateOne = db.prepare('UPDATE accounts SET is_active = 1 WHERE id = ?'); - - const transaction = db.transaction(() => { - updateAll.run(); - updateOne.run(id); - }); + const { raw, orm } = getCloudDb(); try { - transaction(); + orm.transaction((tx) => { + tx.update(accounts).set({ isActive: 0 }).run(); + tx.update(accounts).set({ isActive: 1 }).where(eq(accounts.id, id)).run(); + }); logger.info(`Set account ${id} as active`); } finally { - db.close(); + raw.close(); } } + private static upsertItemValue(db: DrizzleExecutor, key: string, value: string): void { + db.insert(itemTable) + .values({ key, value }) + .onConflictDoUpdate({ + target: itemTable.key, + set: { value }, + }) + .run(); + } + + private static writeAuthStatusAndCleanup(db: DrizzleExecutor, account: CloudAccount): void { + const authStatus = { + name: account.name || account.email, + email: account.email, + apiKey: account.token.access_token, + }; + + this.upsertItemValue(db, 'antigravityAuthStatus', JSON.stringify(authStatus)); + this.upsertItemValue(db, 'antigravityOnboarding', 'true'); + db.delete(itemTable).where(eq(itemTable.key, 'google.antigravity')).run(); + } + + private static getItemValue(db: DrizzleExecutor, key: string, context: string): string | null { + const rows = db + .select({ value: itemTable.value }) + .from(itemTable) + .where(eq(itemTable.key, key)) + .all(); + const row = parseRow(ItemTableValueRowSchema, rows[0], context); + return row?.value ?? null; + } + + private static injectNewFormat( + orm: BetterSQLite3Database, + account: CloudAccount, + ): void { + const oauthToken = ProtobufUtils.createUnifiedOAuthToken( + account.token.access_token, + account.token.refresh_token, + account.token.expiry_timestamp, + ); + + orm.transaction((tx) => { + this.upsertItemValue(tx, 'antigravityUnifiedStateSync.oauthToken', oauthToken); + this.writeAuthStatusAndCleanup(tx, account); + }); + } + + private static injectOldFormat( + orm: BetterSQLite3Database, + account: CloudAccount, + ): void { + const value = this.getItemValue( + orm, + 'jetskiStateSync.agentManagerInitState', + 'ide.itemTable.jetskiStateSync.agentManagerInitState', + ); + + orm.transaction((tx) => { + if (!value) { + logger.warn( + 'jetskiStateSync.agentManagerInitState not found. ' + + 'Injecting minimal auth state only. User may need to complete onboarding in the IDE first.', + ); + + this.writeAuthStatusAndCleanup(tx, account); + + logger.info( + `Injected minimal auth state for ${account.email} (no protobuf state available)`, + ); + return; + } + + const buffer = Buffer.from(value, 'base64'); + const data = new Uint8Array(buffer); + const cleanData = ProtobufUtils.removeField(data, 6); + const newField = ProtobufUtils.createOAuthTokenInfo( + account.token.access_token, + account.token.refresh_token, + account.token.expiry_timestamp, + ); + + const finalData = new Uint8Array(cleanData.length + newField.length); + finalData.set(cleanData, 0); + finalData.set(newField, cleanData.length); + + const finalB64 = Buffer.from(finalData).toString('base64'); + + tx.update(itemTable) + .set({ value: finalB64 }) + .where(eq(itemTable.key, 'jetskiStateSync.agentManagerInitState')) + .run(); + + this.writeAuthStatusAndCleanup(tx, account); + }); + } + + private static detectFormatCapability(db: DrizzleExecutor): 'new' | 'old' | null { + const unifiedValue = this.getItemValue( + db, + 'antigravityUnifiedStateSync.oauthToken', + 'ide.itemTable.antigravityUnifiedStateSync.oauthToken', + ); + if (unifiedValue) { + return 'new'; + } + + const oldValue = this.getItemValue( + db, + 'jetskiStateSync.agentManagerInitState', + 'ide.itemTable.jetskiStateSync.agentManagerInitState', + ); + if (oldValue) { + return 'old'; + } + + return null; + } + + private static resolveInjectionStrategy(db: DrizzleExecutor): { + name: 'new' | 'old' | 'dual'; + reason: string; + } { + try { + const version = getAntigravityVersion(); + return { + name: isNewVersion(version) ? 'new' : 'old', + reason: `version:${version.shortVersion}`, + }; + } catch (error) { + if (!this.versionFailureLogged) { + logger.warn('Version detection failed, falling back to capability detection', error); + this.versionFailureLogged = true; + } + } + + const capability = this.detectFormatCapability(db); + if (capability) { + return { name: capability, reason: 'capability' }; + } + + return { name: 'dual', reason: 'fallback' }; + } + + private static getStrategy(name: 'new' | 'old'): { + name: 'new' | 'old'; + inject: (db: BetterSQLite3Database, account: CloudAccount) => void; + } { + if (name === 'new') { + return { name, inject: (db, account) => this.injectNewFormat(db, account) }; + } + return { name, inject: (db, account) => this.injectOldFormat(db, account) }; + } + + private static injectWithRetry( + dbPath: string, + account: CloudAccount, + ): { strategy: string; attempts: number } { + let lastError: unknown; + for (let attempt = 1; attempt <= SQLITE_MAX_RETRIES; attempt += 1) { + const { raw, orm } = getIdeDb(dbPath, false); + try { + const { name, reason } = this.resolveInjectionStrategy(orm); + if (name === 'dual') { + let newInjected = false; + let oldInjected = false; + + try { + this.injectNewFormat(orm, account); + newInjected = true; + } catch (newError) { + logger.warn('Failed to inject new format', newError); + } + + try { + this.injectOldFormat(orm, account); + oldInjected = true; + } catch (oldError) { + logger.warn('Failed to inject old format', oldError); + } + + if (!newInjected && !oldInjected) { + throw new Error('Token injection failed for both formats'); + } + + return { strategy: `dual:${reason}`, attempts: attempt }; + } + + const strategy = this.getStrategy(name); + strategy.inject(orm, account); + return { strategy: `${strategy.name}:${reason}`, attempts: attempt }; + } catch (error) { + lastError = error; + if (isSqliteBusyError(error) && attempt < SQLITE_MAX_RETRIES) { + logger.warn(`SQLite busy, retrying injection (attempt ${attempt})`, error); + sleepSync(SQLITE_RETRY_DELAY_MS); + continue; + } + throw error; + } finally { + raw.close(); + } + } + + throw lastError; + } + static injectCloudToken(account: CloudAccount): void { const dbPaths = getAntigravityDbPaths(); const dbPath = dbPaths.find((p) => fs.existsSync(p)) ?? null; @@ -436,133 +721,124 @@ export class CloudAccountRepo { throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`); } - const db = new Database(dbPath); - db.pragma('journal_mode = WAL'); - try { - const row = db - .prepare('SELECT value FROM ItemTable WHERE key = ?') - .get('jetskiStateSync.agentManagerInitState') as { value: string } | undefined; - - if (!row || !row.value) { - logger.warn( - 'jetskiStateSync.agentManagerInitState not found. ' + - 'Injecting minimal auth state only. User may need to complete onboarding in the IDE first.', - ); - - // Inject minimal state: auth status and onboarding flag only - const authStatus = { - name: account.name || account.email, - email: account.email, - apiKey: account.token.access_token, - }; - - db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)').run( - 'antigravityAuthStatus', - JSON.stringify(authStatus), - ); - - db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)').run( - 'antigravityOnboarding', - 'true', - ); - - db.prepare('DELETE FROM ItemTable WHERE key = ?').run('google.antigravity'); - - logger.info( - `Injected minimal auth state for ${account.email} (no protobuf state available)`, - ); - - return; // Early return - skip protobuf manipulation - } - - // 1. Decode Base64 - const buffer = Buffer.from(row.value, 'base64'); - const data = new Uint8Array(buffer); - - // 2. Remove Field 6 - const cleanData = ProtobufUtils.removeField(data, 6); - - // 3. Create New Field 6 - const newField = ProtobufUtils.createOAuthTokenInfo( - account.token.access_token, - account.token.refresh_token, - account.token.expiry_timestamp, - ); - - // 4. Concatenate - const finalData = new Uint8Array(cleanData.length + newField.length); - finalData.set(cleanData, 0); - finalData.set(newField, cleanData.length); - - // 5. Encode Base64 - const finalB64 = Buffer.from(finalData).toString('base64'); - - // 6. Write back - db.prepare('UPDATE ItemTable SET value = ? WHERE key = ?').run( - finalB64, - 'jetskiStateSync.agentManagerInitState', - ); - - // 7. Inject Onboarding Flag - db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)').run( - 'antigravityOnboarding', - 'true', - ); - - // 8. Update Auth Status (Fix for switching issue) - // This ensures the UI reflects the user we just switched to - const authStatus = { - name: account.name || account.email, - email: account.email, - apiKey: account.token.access_token, // Critical for session recognition - // userStatusProtoBinaryBase64: ... // We cannot generate this easily, hoping IDE fetches it - }; - - db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)').run( - 'antigravityAuthStatus', - JSON.stringify(authStatus), - ); - - // 9. Remove google.antigravity to prevent conflicts - // This key often holds old state that might override our injected state - db.prepare('DELETE FROM ItemTable WHERE key = ?').run('google.antigravity'); - - logger.info( - `Successfully injected cloud token and identity for ${account.email} into Antigravity database at ${dbPath}.`, - ); - } finally { - db.close(); - } + const result = this.injectWithRetry(dbPath, account); + logger.info( + `Successfully injected cloud token and identity for ${account.email} into Antigravity database at ${dbPath} (strategy=${result.strategy}, attempts=${result.attempts}).`, + ); } static getSetting(key: string, defaultValue: T): T { - const db = getDb(); + const { raw, orm } = getCloudDb(); try { - const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as - | { value: string } - | undefined; - if (!row) return defaultValue; + const rows = orm + .select({ value: settings.value }) + .from(settings) + .where(eq(settings.key, key)) + .all(); + const row = rows[0]; + if (!row) { + return defaultValue; + } return JSON.parse(row.value) as T; } catch (e) { logger.error(`Failed to get setting ${key}`, e); return defaultValue; } finally { - db.close(); + raw.close(); } } static setSetting(key: string, value: any): void { - const db = getDb(); + const { raw, orm } = getCloudDb(); try { - db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run( - key, - JSON.stringify(value), - ); + const stringValue = JSON.stringify(value); + orm + .insert(settings) + .values({ key, value: stringValue }) + .onConflictDoUpdate({ + target: settings.key, + set: { value: stringValue }, + }) + .run(); } finally { - db.close(); + raw.close(); } } + private static readTokenInfoFromDb(db: DrizzleExecutor): { + accessToken: string; + refreshToken: string; + } { + const unifiedValue = this.getItemValue( + db, + 'antigravityUnifiedStateSync.oauthToken', + 'ide.itemTable.antigravityUnifiedStateSync.oauthToken', + ); + + let tokenInfo: { accessToken: string; refreshToken: string } | null = null; + if (unifiedValue) { + try { + const unifiedBuffer = Buffer.from(unifiedValue, 'base64'); + const unifiedData = new Uint8Array(unifiedBuffer); + tokenInfo = ProtobufUtils.extractOAuthTokenInfoFromUnifiedState(unifiedData); + } catch (error) { + logger.warn('SyncLocal: Failed to parse unified OAuth token', error); + } + } + + if (!tokenInfo) { + const value = this.getItemValue( + db, + 'jetskiStateSync.agentManagerInitState', + 'ide.itemTable.jetskiStateSync.agentManagerInitState', + ); + + if (!value) { + const errorMsg = + 'No cloud account found in IDE. Please login to a Google account in Antigravity IDE first.'; + logger.warn(`SyncLocal: ${errorMsg}`); + throw new Error(errorMsg); + } + + const buffer = Buffer.from(value, 'base64'); + const data = new Uint8Array(buffer); + tokenInfo = ProtobufUtils.extractOAuthTokenInfo(data); + } + + if (!tokenInfo) { + const errorMsg = + 'No OAuth token found in IDE state. Please login to a Google account in Antigravity IDE first.'; + logger.warn(`SyncLocal: ${errorMsg}`); + throw new Error(errorMsg); + } + + return tokenInfo; + } + + private static readTokenInfoWithRetry(dbPath: string): { + accessToken: string; + refreshToken: string; + } { + let lastError: unknown; + for (let attempt = 1; attempt <= SQLITE_MAX_RETRIES; attempt += 1) { + const { raw, orm } = getIdeDb(dbPath, true); + try { + return this.readTokenInfoFromDb(orm); + } catch (error) { + lastError = error; + if (isSqliteBusyError(error) && attempt < SQLITE_MAX_RETRIES) { + logger.warn(`SQLite busy, retrying IDE read (attempt ${attempt})`, error); + sleepSync(SQLITE_RETRY_DELAY_MS); + continue; + } + throw error; + } finally { + raw.close(); + } + } + throw lastError; + } + static async syncFromIDE(): Promise { // Try all possible database paths const dbPaths = getAntigravityDbPaths(); @@ -581,32 +857,8 @@ export class CloudAccountRepo { } logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`); - const ideDb = new Database(dbPath, { readonly: true }); - ideDb.pragma('journal_mode = WAL'); try { - // 1. Read Raw Token Data - const row = ideDb - .prepare('SELECT value FROM ItemTable WHERE key = ?') - .get('jetskiStateSync.agentManagerInitState') as { value: string } | undefined; - - if (!row || !row.value) { - const errorMsg = - 'No cloud account found in IDE. Please login to a Google account in Antigravity IDE first.'; - logger.warn(`SyncLocal: ${errorMsg}`); - throw new Error(errorMsg); - } - - // 2. Decode Protobuf - const buffer = Buffer.from(row.value, 'base64'); - const data = new Uint8Array(buffer); - const tokenInfo = ProtobufUtils.extractOAuthTokenInfo(data); - - if (!tokenInfo) { - const errorMsg = - 'No OAuth token found in IDE state. Please login to a Google account in Antigravity IDE first.'; - logger.warn(`SyncLocal: ${errorMsg}`); - throw new Error(errorMsg); - } + const tokenInfo = this.readTokenInfoWithRetry(dbPath); // 3. Fetch User Info // We need to fetch user info to know who this token belongs to @@ -656,8 +908,6 @@ export class CloudAccountRepo { } catch (error) { logger.error('SyncLocal: Failed to sync account from IDE', error); throw error; - } finally { - ideDb.close(); } } } diff --git a/src/ipc/database/dbConnection.ts b/src/ipc/database/dbConnection.ts new file mode 100644 index 0000000..86a8468 --- /dev/null +++ b/src/ipc/database/dbConnection.ts @@ -0,0 +1,34 @@ +import Database from 'better-sqlite3'; +import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import * as schema from './schema'; + +export interface DbConfigOptions { + readOnly?: boolean; + busyTimeoutMs?: number; + enableWal?: boolean; +} + +export interface DrizzleConnection { + raw: Database.Database; + orm: BetterSQLite3Database; +} + +export function configureDatabase(db: Database.Database, options: DbConfigOptions = {}): void { + const busyTimeoutMs = options.busyTimeoutMs ?? 3000; + const enableWal = options.enableWal ?? !options.readOnly; + if (enableWal) { + db.pragma('journal_mode = WAL'); + } + db.pragma(`busy_timeout = ${busyTimeoutMs}`); +} + +export function openDrizzleConnection( + dbPath: string, + options: Database.Options = {}, + config: DbConfigOptions = {}, +): DrizzleConnection { + const raw = new Database(dbPath, options); + configureDatabase(raw, config); + const orm = drizzle(raw, { schema }); + return { raw, orm }; +} diff --git a/src/ipc/database/handler.ts b/src/ipc/database/handler.ts index 5c627a8..fa0d369 100644 --- a/src/ipc/database/handler.ts +++ b/src/ipc/database/handler.ts @@ -1,17 +1,27 @@ import Database from 'better-sqlite3'; import fs from 'fs'; -import { getAntigravityDbPaths } from '../../utils/paths'; -import { logger } from '../../utils/logger'; -import { AccountBackupData, AccountInfo } from '../../types/account'; - import path from 'path'; +import { eq } from 'drizzle-orm'; +import { AccountBackupData, AccountInfo } from '../../types/account'; +import { ItemTableValueRowSchema, type ItemTableKey } from '../../types/db'; +import { logger } from '../../utils/logger'; +import { getAntigravityDbPaths } from '../../utils/paths'; +import { parseRow } from '../../utils/sqlite'; +import { openDrizzleConnection } from './dbConnection'; +import { itemTable } from './schema'; -const KEYS_TO_BACKUP = ['antigravityAuthStatus', 'jetskiStateSync.agentManagerInitState']; +const KEYS_TO_BACKUP: ItemTableKey[] = [ + 'antigravityAuthStatus', + 'jetskiStateSync.agentManagerInitState', + 'antigravityUnifiedStateSync.oauthToken', +]; -function getDbWithWal(dbPath: string, options: Database.Options = {}): Database.Database { - const db = new Database(dbPath, options); - db.pragma('journal_mode = WAL'); - return db; +function openIdeDb(dbPath: string, readOnly = false): ReturnType { + return openDrizzleConnection( + dbPath, + { readonly: readOnly, fileMustExist: false }, + { readOnly, busyTimeoutMs: 3000 }, + ); } /** @@ -25,8 +35,8 @@ export function initDatabase(): void { return; } - const db = getDatabaseConnection(); - db.close(); + const { raw } = getDatabaseConnection(); + raw.close(); logger.info('Database initialized and verified (WAL mode)'); } catch (error) { logger.error('Failed to initialize database on startup', error); @@ -72,9 +82,9 @@ function ensureDatabaseExists(dbPath: string): void { /** * Gets a database connection. * @param dbPath {string} The path to the database file. - * @returns {Database.Database} The database connection. + * @returns {ReturnType} The database connection. */ -export function getDatabaseConnection(dbPath?: string): Database.Database { +export function getDatabaseConnection(dbPath?: string): ReturnType { const targetPath = dbPath || getAntigravityDbPaths()[0]; if (!targetPath) { @@ -84,7 +94,7 @@ export function getDatabaseConnection(dbPath?: string): Database.Database { ensureDatabaseExists(targetPath); try { - return getDbWithWal(targetPath, { readonly: false, fileMustExist: false }); + return openIdeDb(targetPath); } catch (error: unknown) { const err = error as { code?: string }; if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') { @@ -94,11 +104,19 @@ export function getDatabaseConnection(dbPath?: string): Database.Database { } } -/** - * Gets a database connection and enables WAL mode. - * @param dbPath {string} The path to the database file. - * @returns {Database.Database} The database connection. - */ +function readItemValue( + orm: ReturnType['orm'], + key: string, + context: string, +): string | null { + const rows = orm + .select({ value: itemTable.value }) + .from(itemTable) + .where(eq(itemTable.key, key)) + .all(); + const row = parseRow(ItemTableValueRowSchema, rows[0], context); + return row?.value ?? null; +} /** * Gets the current account info. @@ -106,61 +124,66 @@ export function getDatabaseConnection(dbPath?: string): Database.Database { */ export function getCurrentAccountInfo(): AccountInfo { // NOTE Database existence is now handled by getDatabaseConnection - let db: Database.Database | null = null; + let connection: ReturnType | null = null; try { - db = getDatabaseConnection(); + connection = getDatabaseConnection(); + const { orm } = connection; // Query for auth status - const authRow = db - .prepare("SELECT value FROM ItemTable WHERE key = 'antigravityAuthStatus'") - .get() as { value: string } | undefined; - + const authValue = readItemValue( + orm, + 'antigravityAuthStatus', + 'ide.itemTable.antigravityAuthStatus', + ); let authStatus = null; - if (authRow) { + if (authValue) { try { - authStatus = JSON.parse(authRow.value); + authStatus = JSON.parse(authValue); } catch { // NOTE Ignore JSON parse errors } } // NOTE Query for user info (usually in jetskiStateSync.agentManagerInitState or similar) - const initRow = db - .prepare("SELECT value FROM ItemTable WHERE key = 'jetskiStateSync.agentManagerInitState'") - .get() as { value: string } | undefined; - + const initValue = readItemValue( + orm, + 'jetskiStateSync.agentManagerInitState', + 'ide.itemTable.jetskiStateSync.agentManagerInitState', + ); let initState = null; - if (initRow) { + if (initValue) { try { - initState = JSON.parse(initRow.value); + initState = JSON.parse(initValue); } catch { // Ignore JSON parse errors (this key often contains non-JSON data) } } // Query for google.antigravity - const googleRow = db - .prepare("SELECT value FROM ItemTable WHERE key = 'google.antigravity'") - .get() as { value: string } | undefined; - + const googleValue = readItemValue( + orm, + 'google.antigravity', + 'ide.itemTable.google.antigravity', + ); let googleState = null; - if (googleRow) { + if (googleValue) { try { - googleState = JSON.parse(googleRow.value); + googleState = JSON.parse(googleValue); } catch { // Ignore JSON parse errors } } // Query for antigravityUserSettings.allUserSettings - const settingsRow = db - .prepare("SELECT value FROM ItemTable WHERE key = 'antigravityUserSettings.allUserSettings'") - .get() as { value: string } | undefined; - + const settingsValue = readItemValue( + orm, + 'antigravityUserSettings.allUserSettings', + 'ide.itemTable.antigravityUserSettings.allUserSettings', + ); let settingsState = null; - if (settingsRow) { + if (settingsValue) { try { - settingsState = JSON.parse(settingsRow.value); + settingsState = JSON.parse(settingsValue); } catch { // Ignore JSON parse errors } @@ -195,28 +218,28 @@ export function getCurrentAccountInfo(): AccountInfo { logger.error('Failed to get current account info', error); throw error; } finally { - if (db) db.close(); + if (connection) { + connection.raw.close(); + } } } export function backupAccount(account: AccountBackupData['account']): AccountBackupData { - let db: Database.Database | null = null; + let connection: ReturnType | null = null; try { - db = getDatabaseConnection(); + connection = getDatabaseConnection(); + const { orm } = connection; // NOTE Backup only specific keys const data: Record = {}; for (const key of KEYS_TO_BACKUP) { - const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(key) as - | { value: string } - | undefined; - - if (row) { + const value = readItemValue(orm, key, `ide.itemTable.backup.${key}`); + if (value) { try { - data[key] = JSON.parse(row.value); + data[key] = JSON.parse(value); } catch { - data[key] = row.value; + data[key] = value; } logger.debug(`Backed up key: ${key}`); } else { @@ -237,7 +260,9 @@ export function backupAccount(account: AccountBackupData['account']): AccountBac logger.error('Failed to backup account', error); throw error; } finally { - if (db) db.close(); + if (connection) { + connection.raw.close(); + } } } @@ -288,32 +313,36 @@ function _restoreSingleDb(dbPath: string, backup: AccountBackupData): boolean { } logger.info(`Restoring database: ${dbPath}`); - let db: Database.Database | null = null; + let connection: ReturnType | null = null; try { - db = getDatabaseConnection(dbPath); - - const insert = db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)'); - - const transaction = db.transaction(() => { + connection = getDatabaseConnection(dbPath); + const { orm } = connection; + orm.transaction((tx) => { // NOTE Only restore the keys that were backed up for (const key of KEYS_TO_BACKUP) { if (key in backup.data) { const value = backup.data[key]; const stringValue = typeof value === 'string' ? value : JSON.stringify(value); - insert.run(key, stringValue); + tx.insert(itemTable) + .values({ key, value: stringValue }) + .onConflictDoUpdate({ + target: itemTable.key, + set: { value: stringValue }, + }) + .run(); logger.debug(`Restored key: ${key}`); } } }); - - transaction(); logger.info(`Database restoration complete: ${dbPath}`); return true; } catch (error) { logger.error(`Failed to restore database: ${dbPath}`, error); return false; } finally { - if (db) db.close(); + if (connection) { + connection.raw.close(); + } } } diff --git a/src/ipc/database/schema.ts b/src/ipc/database/schema.ts new file mode 100644 index 0000000..4b24135 --- /dev/null +++ b/src/ipc/database/schema.ts @@ -0,0 +1,25 @@ +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +export const accounts = sqliteTable('accounts', { + id: text('id').primaryKey(), + provider: text('provider').notNull(), + email: text('email').notNull(), + name: text('name'), + avatarUrl: text('avatar_url'), + tokenJson: text('token_json').notNull(), + quotaJson: text('quota_json'), + createdAt: integer('created_at').notNull(), + lastUsed: integer('last_used').notNull(), + status: text('status'), + isActive: integer('is_active').notNull().default(0), +}); + +export const settings = sqliteTable('settings', { + key: text('key').primaryKey(), + value: text('value').notNull(), +}); + +export const itemTable = sqliteTable('ItemTable', { + key: text('key').primaryKey(), + value: text('value'), +}); diff --git a/src/tests/unit/antigravityVersion.test.ts b/src/tests/unit/antigravityVersion.test.ts new file mode 100644 index 0000000..83198f8 --- /dev/null +++ b/src/tests/unit/antigravityVersion.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from 'vitest'; +import { compareVersion, isNewVersion } from '../../utils/antigravityVersion'; + +describe('antigravityVersion', () => { + it('should compare versions correctly', () => { + expect(compareVersion('1.16.5', '1.16.4')).toBe(1); + expect(compareVersion('1.16.5', '1.16.5')).toBe(0); + expect(compareVersion('1.16.4', '1.16.5')).toBe(-1); + expect(compareVersion('1.17.0', '1.16.5')).toBe(1); + expect(compareVersion('2.0.0', '1.16.5')).toBe(1); + }); + + it('should detect new version >= 1.16.5', () => { + expect(isNewVersion({ shortVersion: '1.16.4', bundleVersion: '1.16.4' })).toBe(false); + expect(isNewVersion({ shortVersion: '1.16.5', bundleVersion: '1.16.5' })).toBe(true); + expect(isNewVersion({ shortVersion: '1.17.0', bundleVersion: '1.17.0' })).toBe(true); + }); + + it('should fallback to package.json on windows when version detection fails', async () => { + if (process.platform !== 'win32') { + return; + } + + vi.resetModules(); + const execSync = vi.fn(() => { + throw new Error('ps fail'); + }); + const existsSync = vi.fn(() => true); + const readFileSync = vi.fn(() => JSON.stringify({ version: '1.16.6' })); + + vi.doMock('child_process', () => ({ + execSync, + default: { + execSync, + }, + })); + vi.doMock('fs', () => ({ + existsSync, + readFileSync, + default: { + existsSync, + readFileSync, + }, + })); + vi.doMock('../../utils/paths', () => ({ + getAntigravityExecutablePath: () => 'C:\\Program Files\\Antigravity\\Antigravity.exe', + })); + + const { getAntigravityVersion } = await import('../../utils/antigravityVersion'); + const version = getAntigravityVersion(); + const cached = getAntigravityVersion(); + + expect(version.shortVersion).toBe('1.16.6'); + expect(cached.shortVersion).toBe('1.16.6'); + expect(execSync).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/tests/unit/cloudHandler-sync.test.ts b/src/tests/unit/cloudHandler-sync.test.ts new file mode 100644 index 0000000..c5f5b02 --- /dev/null +++ b/src/tests/unit/cloudHandler-sync.test.ts @@ -0,0 +1,269 @@ +import fs from 'fs'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ProtobufUtils } from '../../utils/protobuf'; +import { CloudAccountRepo } from '../../ipc/database/cloudHandler'; +import type { UserInfo } from '../../services/GoogleAPIService'; + +let mockData: Record; +let busyOnFirstGet = false; +let getCallCount = 0; +let runCalls: Array<{ sql: string; args: unknown[] }>; +interface MockOrm { + select: () => { + from: () => { + where: (condition: { __key?: string }) => { all: () => Array<{ value: string }> }; + }; + }; + insert: () => { + values: (values: { key: string; value: string }) => { + onConflictDoUpdate: () => { run: () => { changes: number } }; + }; + }; + update: () => { + set: (values: { value?: string }) => { + where: (condition: { __key?: string }) => { run: () => { changes: number } }; + }; + }; + delete: () => { + where: (condition: { __key?: string }) => { run: () => { changes: number } }; + }; + transaction: (fn: (tx: MockOrm) => void) => void; +} + +let mockOrm: MockOrm; + +function createMockUserInfo(email: string, name: string): UserInfo { + return { + id: `id-${email}`, + email, + verified_email: true, + name, + given_name: name, + family_name: 'User', + picture: '', + }; +} + +vi.mock('drizzle-orm', () => ({ + eq: (_column: unknown, value: string) => ({ __key: value }), + desc: (value: unknown) => value, +})); + +vi.mock('../../ipc/database/dbConnection', () => ({ + openDrizzleConnection: () => ({ + raw: { close: vi.fn() }, + orm: mockOrm, + }), +})); + +vi.mock('../../utils/paths', () => ({ + getAntigravityDbPaths: () => ['mock-db'], + getCloudAccountsDbPath: () => 'mock-cloud-db', +})); + +vi.mock('../../utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../../services/GoogleAPIService', () => ({ + GoogleAPIService: { + getUserInfo: vi.fn(), + }, +})); + +describe('CloudAccountRepo.syncFromIDE', () => { + beforeEach(() => { + mockData = {}; + busyOnFirstGet = false; + getCallCount = 0; + runCalls = []; + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + mockOrm = { + select: () => ({ + from: () => ({ + where: (condition: { __key?: string }) => ({ + all: () => { + getCallCount += 1; + if (busyOnFirstGet && getCallCount === 1) { + const error = new Error('SQLITE_BUSY'); + (error as { code?: string }).code = 'SQLITE_BUSY'; + throw error; + } + const key = condition?.__key ?? ''; + const value = mockData[key]; + if (value === undefined) { + return []; + } + return [{ value }]; + }, + }), + }), + }), + insert: () => ({ + values: (values: { key: string; value: string }) => ({ + onConflictDoUpdate: () => ({ + run: () => { + runCalls.push({ sql: 'insert', args: [values] }); + return { changes: 1 }; + }, + }), + }), + }), + update: () => ({ + set: (values: { value?: string }) => ({ + where: (condition: { __key?: string }) => ({ + run: () => { + runCalls.push({ sql: 'update', args: [values, condition] }); + return { changes: 1 }; + }, + }), + }), + }), + delete: () => ({ + where: (condition: { __key?: string }) => ({ + run: () => { + runCalls.push({ sql: 'delete', args: [condition] }); + return { changes: 1 }; + }, + }), + }), + transaction: (fn: (tx: typeof mockOrm) => void) => { + fn(mockOrm); + }, + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should prefer unified oauth token when present', async () => { + const accessToken = 'access-new'; + const refreshToken = 'refresh-new'; + const unifiedB64 = ProtobufUtils.createUnifiedOAuthToken(accessToken, refreshToken, 1700000000); + + const oldB64 = Buffer.from( + ProtobufUtils.createOAuthTokenInfo('access-old', 'refresh-old', 1700000000), + ).toString('base64'); + + mockData['antigravityUnifiedStateSync.oauthToken'] = unifiedB64; + mockData['jetskiStateSync.agentManagerInitState'] = oldB64; + + const { GoogleAPIService } = await import('../../services/GoogleAPIService'); + vi.mocked(GoogleAPIService.getUserInfo).mockResolvedValue( + createMockUserInfo('new@example.com', 'New User'), + ); + + vi.spyOn(CloudAccountRepo, 'getAccounts').mockResolvedValue([]); + vi.spyOn(CloudAccountRepo, 'addAccount').mockResolvedValue(); + + const account = await CloudAccountRepo.syncFromIDE(); + + expect(GoogleAPIService.getUserInfo).toHaveBeenCalledWith(accessToken); + expect(account?.email).toBe('new@example.com'); + }); + + it('should fall back to old oauth token when unified is missing', async () => { + const accessToken = 'access-old'; + const refreshToken = 'refresh-old'; + const oldB64 = Buffer.from( + ProtobufUtils.createOAuthTokenInfo(accessToken, refreshToken, 1700000000), + ).toString('base64'); + + mockData['jetskiStateSync.agentManagerInitState'] = oldB64; + + const { GoogleAPIService } = await import('../../services/GoogleAPIService'); + vi.mocked(GoogleAPIService.getUserInfo).mockResolvedValue( + createMockUserInfo('old@example.com', 'Old User'), + ); + + vi.spyOn(CloudAccountRepo, 'getAccounts').mockResolvedValue([]); + vi.spyOn(CloudAccountRepo, 'addAccount').mockResolvedValue(); + + const account = await CloudAccountRepo.syncFromIDE(); + + expect(GoogleAPIService.getUserInfo).toHaveBeenCalledWith(accessToken); + expect(account?.email).toBe('old@example.com'); + }); + + it('should retry when sqlite is busy', async () => { + busyOnFirstGet = true; + const accessToken = 'access-retry'; + const refreshToken = 'refresh-retry'; + const oldB64 = Buffer.from( + ProtobufUtils.createOAuthTokenInfo(accessToken, refreshToken, 1700000000), + ).toString('base64'); + + mockData['jetskiStateSync.agentManagerInitState'] = oldB64; + + const { GoogleAPIService } = await import('../../services/GoogleAPIService'); + vi.mocked(GoogleAPIService.getUserInfo).mockResolvedValue( + createMockUserInfo('retry@example.com', 'Retry User'), + ); + + vi.spyOn(CloudAccountRepo, 'getAccounts').mockResolvedValue([]); + vi.spyOn(CloudAccountRepo, 'addAccount').mockResolvedValue(); + + const account = await CloudAccountRepo.syncFromIDE(); + + expect(GoogleAPIService.getUserInfo).toHaveBeenCalledWith(accessToken); + expect(account?.email).toBe('retry@example.com'); + }); + + it('should prefer new format when capability detection finds unified key', async () => { + vi.resetModules(); + vi.doMock('../../utils/antigravityVersion', () => ({ + getAntigravityVersion: () => { + throw new Error('version detection failed'); + }, + isNewVersion: () => false, + })); + + const { CloudAccountRepo: RepoWithMock } = await import('../../ipc/database/cloudHandler'); + const accessToken = 'access-new'; + const refreshToken = 'refresh-new'; + + mockData['antigravityUnifiedStateSync.oauthToken'] = 'exists'; + mockData['jetskiStateSync.agentManagerInitState'] = 'exists-old'; + + RepoWithMock.injectCloudToken({ + id: 'id', + provider: 'google', + email: 'test@example.com', + name: 'Test', + avatar_url: '', + token: { + access_token: accessToken, + refresh_token: refreshToken, + expires_in: 3600, + expiry_timestamp: 1700000000, + token_type: 'Bearer', + email: 'test@example.com', + }, + created_at: 1700000000, + last_used: 1700000000, + status: 'active', + is_active: true, + }); + + const updatedOldKey = runCalls.some( + (call) => + call.sql === 'update' && + (call.args[1] as { __key?: string } | undefined)?.__key === + 'jetskiStateSync.agentManagerInitState', + ); + const wroteUnifiedKey = runCalls.some( + (call) => + call.sql === 'insert' && + (call.args[0] as { key?: string })?.key === 'antigravityUnifiedStateSync.oauthToken', + ); + + expect(wroteUnifiedKey).toBe(true); + expect(updatedOldKey).toBe(false); + }); +}); diff --git a/src/tests/unit/database-backup-keys.test.ts b/src/tests/unit/database-backup-keys.test.ts new file mode 100644 index 0000000..9419f09 --- /dev/null +++ b/src/tests/unit/database-backup-keys.test.ts @@ -0,0 +1,84 @@ +import fs from 'fs'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as handler from '../../ipc/database/handler'; + +let writes: Record = {}; +interface MockOrm { + insert: () => { + values: (values: { key: string; value: string }) => { + onConflictDoUpdate: () => { run: () => { changes: number } }; + }; + }; + transaction: (fn: (tx: MockOrm) => void) => void; +} + +let mockOrm: MockOrm; + +vi.mock('../../ipc/database/dbConnection', () => ({ + openDrizzleConnection: () => ({ + raw: { close: vi.fn() }, + orm: mockOrm, + }), +})); + +vi.mock('../../utils/paths', () => ({ + getAntigravityDbPaths: () => ['mock-db'], +})); + +vi.mock('../../utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +describe('database backup keys', () => { + beforeEach(() => { + writes = {}; + mockOrm = { + insert: () => ({ + values: (values: { key: string; value: string }) => ({ + onConflictDoUpdate: () => ({ + run: () => { + writes[values.key] = values.value; + return { changes: 1 }; + }, + }), + }), + }), + transaction: (fn: (tx: typeof mockOrm) => void) => { + fn(mockOrm); + }, + }; + vi.spyOn(fs, 'existsSync').mockImplementation((value) => { + const pathValue = String(value); + return !pathValue.endsWith('.backup'); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should restore unified oauth token key', () => { + handler.restoreAccount({ + version: '1.0', + account: { + id: '1', + name: 'Test', + email: 'test@example.com', + created_at: new Date().toISOString(), + last_used: new Date().toISOString(), + }, + data: { + antigravityAuthStatus: '{"email":"test@example.com"}', + 'jetskiStateSync.agentManagerInitState': 'old', + 'antigravityUnifiedStateSync.oauthToken': 'unified', + }, + }); + + expect(writes['antigravityUnifiedStateSync.oauthToken']).toBe('unified'); + }); +}); diff --git a/src/tests/unit/database.test.ts b/src/tests/unit/database.test.ts index 97135a8..5abbc8b 100644 --- a/src/tests/unit/database.test.ts +++ b/src/tests/unit/database.test.ts @@ -54,9 +54,9 @@ describe.skip('Database Handler', () => { }); it('should connect to database', () => { - const db = getDatabaseConnection(); - expect(db).toBeDefined(); - db.close(); + const { raw } = getDatabaseConnection(); + expect(raw).toBeDefined(); + raw.close(); }); it('should get current account info', () => { diff --git a/src/tests/unit/protobuf-unified.test.ts b/src/tests/unit/protobuf-unified.test.ts new file mode 100644 index 0000000..d574a8b --- /dev/null +++ b/src/tests/unit/protobuf-unified.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { ProtobufUtils } from '../../utils/protobuf'; + +describe('ProtobufUtils Unified OAuth', () => { + it('should round-trip OAuthInfo payload', () => { + const accessToken = 'access-token-123'; + const refreshToken = 'refresh-token-456'; + const expiry = 1700000000; + + const oauthInfo = ProtobufUtils.createOAuthInfo(accessToken, refreshToken, expiry); + const parsed = ProtobufUtils.extractOAuthTokenInfoFromOAuthInfo(oauthInfo); + + expect(parsed).toEqual({ + accessToken, + refreshToken, + }); + }); + + it('should round-trip unified oauth token', () => { + const accessToken = 'access-token-abc'; + const refreshToken = 'refresh-token-def'; + const expiry = 1700001234; + + const unifiedB64 = ProtobufUtils.createUnifiedOAuthToken( + accessToken, + refreshToken, + expiry, + ); + const unifiedBytes = new Uint8Array(Buffer.from(unifiedB64, 'base64')); + const parsed = ProtobufUtils.extractOAuthTokenInfoFromUnifiedState(unifiedBytes); + + expect(parsed).toEqual({ + accessToken, + refreshToken, + }); + }); +}); diff --git a/src/types/account.ts b/src/types/account.ts index 1d6dce0..9ac400e 100644 --- a/src/types/account.ts +++ b/src/types/account.ts @@ -17,6 +17,7 @@ export interface AccountBackupData { // Key-value pairs from Antigravity database antigravityAuthStatus?: string; 'jetskiStateSync.agentManagerInitState'?: string; + 'antigravityUnifiedStateSync.oauthToken'?: string; [key: string]: unknown; }; } diff --git a/src/types/db.ts b/src/types/db.ts new file mode 100644 index 0000000..d3f77bd --- /dev/null +++ b/src/types/db.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; + +export const CloudAccountRowSchema = z.object({ + id: z.string(), + provider: z.string(), + email: z.string(), + name: z.string().nullable(), + avatar_url: z.string().nullable(), + token_json: z.string(), + quota_json: z.string().nullable(), + created_at: z.number(), + last_used: z.number(), + status: z.string().nullable(), + is_active: z.number().int(), +}); + +export type CloudAccountRow = z.infer; + +export const AccountTokenRowSchema = z.object({ + id: z.string(), + token_json: z.string(), + quota_json: z.string().nullable(), +}); + +export type AccountTokenRow = z.infer; + +export const SettingsRowSchema = z.object({ + key: z.string(), + value: z.string(), +}); + +export type SettingsRow = z.infer; + +export const SettingsValueRowSchema = z.object({ + value: z.string(), +}); + +export type SettingsValueRow = z.infer; + +export const ItemTableKeys = [ + 'antigravityAuthStatus', + 'antigravityOnboarding', + 'antigravityUnifiedStateSync.oauthToken', + 'jetskiStateSync.agentManagerInitState', + 'google.antigravity', + 'antigravityUserSettings.allUserSettings', +] as const; + +export const ItemTableKeySchema = z.enum(ItemTableKeys); + +export type ItemTableKey = z.infer; + +export const ItemTableRowSchema = z.object({ + key: z.string(), + value: z.string().nullable(), +}); + +export type ItemTableRow = z.infer; + +export const ItemTableValueRowSchema = z.object({ + value: z.string().nullable(), +}); + +export type ItemTableValueRow = z.infer; + +export const TableInfoRowSchema = z.object({ + name: z.string(), +}); + +export type TableInfoRow = z.infer; diff --git a/src/utils/antigravityVersion.ts b/src/utils/antigravityVersion.ts new file mode 100644 index 0000000..a0050fd --- /dev/null +++ b/src/utils/antigravityVersion.ts @@ -0,0 +1,181 @@ +import fs from 'fs'; +import path from 'path'; +import { execSync } from 'child_process'; +import { getAntigravityExecutablePath } from './paths'; + +export interface AntigravityVersion { + shortVersion: string; + bundleVersion: string; +} + +let cachedVersion: AntigravityVersion | null = null; +let cachedError: Error | null = null; + +function cacheAndReturn(version: AntigravityVersion): AntigravityVersion { + cachedVersion = version; + return version; +} + +function readPackageJsonVersion(execPath: string): AntigravityVersion | null { + const parentDir = path.dirname(execPath); + const packageJson = path.join(parentDir, 'resources', 'app', 'package.json'); + if (!fs.existsSync(packageJson)) { + return null; + } + try { + const content = fs.readFileSync(packageJson, 'utf-8'); + const json = JSON.parse(content) as { version?: string }; + const parsed = parseVersionString(json.version || null); + return { + shortVersion: parsed, + bundleVersion: parsed, + }; + } catch { + return null; + } +} + +function readPlistValue(content: string, key: string): string | null { + const pattern = new RegExp(`${key}<\\/key>\\s*([^<]+)<\\/string>`, 'i'); + const match = content.match(pattern); + if (match) { + return match[1].trim(); + } + return null; +} + +function parseVersionString(version: string | null): string { + if (!version) { + throw new Error('Version information not found'); + } + const trimmed = version.trim(); + if (!trimmed) { + throw new Error('Version information is empty'); + } + return trimmed; +} + +export function getAntigravityVersion(): AntigravityVersion { + if (cachedVersion) { + return cachedVersion; + } + if (cachedError) { + throw cachedError; + } + + try { + const execPath = getAntigravityExecutablePath(); + if (!execPath) { + throw new Error('Unable to locate Antigravity executable'); + } + + if (process.platform === 'win32') { + try { + const escapedPath = execPath.replace(/'/g, "''"); + const command = `(Get-Item '${escapedPath}').VersionInfo.FileVersion`; + const version = execSync(`powershell -NoProfile -Command "${command}"`, { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + + const parsed = parseVersionString(version); + return cacheAndReturn({ + shortVersion: parsed, + bundleVersion: parsed, + }); + } catch (error) { + const fallback = readPackageJsonVersion(execPath); + if (fallback) { + return cacheAndReturn(fallback); + } + throw error; + } + } + + if (process.platform === 'darwin') { + const appIndex = execPath.toLowerCase().indexOf('.app'); + const appPath = appIndex >= 0 ? execPath.slice(0, appIndex + 4) : execPath; + const plistPath = path.join(appPath, 'Contents', 'Info.plist'); + if (!fs.existsSync(plistPath)) { + throw new Error(`Info.plist not found: ${plistPath}`); + } + + let content = fs.readFileSync(plistPath, 'utf-8'); + if (content.startsWith('bplist')) { + try { + content = execSync(`plutil -convert xml1 -o - "${plistPath}"`, { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + throw new Error('Failed to parse Info.plist'); + } + } + + const shortVersion = parseVersionString( + readPlistValue(content, 'CFBundleShortVersionString'), + ); + const bundleVersion = parseVersionString( + readPlistValue(content, 'CFBundleVersion') || shortVersion, + ); + + return cacheAndReturn({ + shortVersion, + bundleVersion, + }); + } + + if (process.platform === 'linux') { + try { + const output = execSync(`"${execPath}" --version`, { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + const parsed = parseVersionString(output); + return cacheAndReturn({ + shortVersion: parsed, + bundleVersion: parsed, + }); + } catch { + const fallback = readPackageJsonVersion(execPath); + if (fallback) { + return cacheAndReturn(fallback); + } + } + } + + throw new Error('Unable to determine Antigravity version'); + } catch (error) { + const normalized = + error instanceof Error ? error : new Error('Unable to determine Antigravity version'); + cachedError = normalized; + throw normalized; + } +} + +export function compareVersion(v1: string, v2: string): number { + const parts1 = v1 + .split('.') + .map((part) => Number.parseInt(part, 10)) + .filter((value) => !Number.isNaN(value)); + const parts2 = v2 + .split('.') + .map((part) => Number.parseInt(part, 10)) + .filter((value) => !Number.isNaN(value)); + const length = Math.max(parts1.length, parts2.length); + for (let i = 0; i < length; i += 1) { + const left = parts1[i] ?? 0; + const right = parts2[i] ?? 0; + if (left > right) { + return 1; + } + if (left < right) { + return -1; + } + } + return 0; +} + +export function isNewVersion(version: AntigravityVersion): boolean { + return compareVersion(version.shortVersion, '1.16.5') >= 0; +} diff --git a/src/utils/protobuf.ts b/src/utils/protobuf.ts index b4e0ddf..1abc373 100644 --- a/src/utils/protobuf.ts +++ b/src/utils/protobuf.ts @@ -93,6 +93,24 @@ export class ProtobufUtils { return result; } + static encodeLenDelimField(fieldNum: number, data: Uint8Array): Uint8Array { + const tag = (fieldNum << 3) | 2; + const tagBytes = this.encodeVarint(tag); + const lenBytes = this.encodeVarint(data.length); + + const result = new Uint8Array(tagBytes.length + lenBytes.length + data.length); + result.set(tagBytes, 0); + result.set(lenBytes, tagBytes.length); + result.set(data, tagBytes.length + lenBytes.length); + return result; + } + + static encodeStringField(fieldNum: number, value: string): Uint8Array { + const utf8Encode = new TextEncoder(); + const bytes = utf8Encode.encode(value); + return this.encodeLenDelimField(fieldNum, bytes); + } + // Create timestamp field (Field 4 -> Field 1 varint) static createTimestampField(fieldNum: number, seconds: number): Uint8Array { // Timestamp message format: Field 1 (seconds) as varint @@ -198,4 +216,92 @@ export class ProtobufUtils { return result; } + + static createOAuthInfo(accessToken: string, refreshToken: string, expiry: number): Uint8Array { + const field1 = this.encodeStringField(1, accessToken); + const field2 = this.encodeStringField(2, 'Bearer'); + const field3 = this.encodeStringField(3, refreshToken); + + const timestampTag = (1 << 3) | 0; + const tagBytes = this.encodeVarint(timestampTag); + const secondsBytes = this.encodeVarint(expiry); + const timestampMsg = new Uint8Array(tagBytes.length + secondsBytes.length); + timestampMsg.set(tagBytes, 0); + timestampMsg.set(secondsBytes, tagBytes.length); + + const field4 = this.encodeLenDelimField(4, timestampMsg); + + const combined = new Uint8Array( + field1.length + field2.length + field3.length + field4.length, + ); + combined.set(field1, 0); + combined.set(field2, field1.length); + combined.set(field3, field1.length + field2.length); + combined.set(field4, field1.length + field2.length + field3.length); + return combined; + } + + static createUnifiedOAuthToken( + accessToken: string, + refreshToken: string, + expiry: number, + ): string { + const oauthInfo = this.createOAuthInfo(accessToken, refreshToken, expiry); + const oauthInfoB64 = Buffer.from(oauthInfo).toString('base64'); + + const inner2 = this.encodeStringField(1, oauthInfoB64); + const inner1 = this.encodeStringField(1, 'oauthTokenInfoSentinelKey'); + const innerField2 = this.encodeLenDelimField(2, inner2); + + const inner = new Uint8Array(inner1.length + innerField2.length); + inner.set(inner1, 0); + inner.set(innerField2, inner1.length); + + const outer = this.encodeLenDelimField(1, inner); + return Buffer.from(outer).toString('base64'); + } + + static extractOAuthTokenInfoFromOAuthInfo( + data: Uint8Array, + ): { accessToken: string; refreshToken: string } | null { + const accessTokenBytes = this.getField(data, 1); + const refreshTokenBytes = this.getField(data, 3); + + if (accessTokenBytes && refreshTokenBytes) { + return { + accessToken: this.readString(accessTokenBytes), + refreshToken: this.readString(refreshTokenBytes), + }; + } + return null; + } + + static extractOAuthTokenInfoFromUnifiedState( + data: Uint8Array, + ): { accessToken: string; refreshToken: string } | null { + const inner = this.getField(data, 1); + if (!inner) { + return null; + } + + const inner2 = this.getField(inner, 2); + if (!inner2) { + return null; + } + + const oauthInfoB64Bytes = this.getField(inner2, 1); + if (!oauthInfoB64Bytes) { + return null; + } + + const oauthInfoB64 = this.readString(oauthInfoB64Bytes); + let oauthInfoBytes: Uint8Array; + try { + oauthInfoBytes = new Uint8Array(Buffer.from(oauthInfoB64, 'base64')); + } catch { + return null; + } + + return this.extractOAuthTokenInfoFromOAuthInfo(oauthInfoBytes); + } } diff --git a/src/utils/sqlite.ts b/src/utils/sqlite.ts new file mode 100644 index 0000000..2ca377a --- /dev/null +++ b/src/utils/sqlite.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; +import { logger } from './logger'; + +interface StatementGet { + get: (...args: unknown[]) => unknown; +} + +interface StatementAll { + all: (...args: unknown[]) => unknown[]; +} + +function logSchemaIssues(context: string, issues: z.core.$ZodIssue[]): void { + logger.warn('SQLite row failed schema validation', { + context, + issues, + }); +} + +export function parseRow( + schema: T, + row: unknown, + context: string, +): z.infer | null { + if (!row) { + return null; + } + const parsed = schema.safeParse(row); + if (!parsed.success) { + logSchemaIssues(context, parsed.error.issues); + return null; + } + return parsed.data; +} + +export function parseRows( + schema: T, + rows: unknown[], + context: string, +): z.infer[] { + const parsed: z.infer[] = []; + for (const row of rows) { + const parsedRow = parseRow(schema, row, context); + if (parsedRow) { + parsed.push(parsedRow); + } + } + return parsed; +} + +export function parseRowFromStatement( + stmt: StatementGet, + schema: T, + context: string, + params: unknown[] = [], +): z.infer | null { + const row: unknown = stmt.get(...params); + return parseRow(schema, row, context); +} + +export function parseRowsFromStatement( + stmt: StatementAll, + schema: T, + context: string, + params: unknown[] = [], +): z.infer[] { + const rows: unknown[] = stmt.all(...params); + return parseRows(schema, rows, context); +}