fix: restore account switching on Antigravity 1.16.5 and migrate db sync to drizzle (#69)
This commit is contained in:
16 files changed
+1658
-328
No files matched your search
Generated
+119
-1
@@ -48,6 +48,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"drizzle-orm": "^0.39.1",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
"find-process": "^2.0.0",
|
"find-process": "^2.0.0",
|
||||||
@@ -10897,7 +10898,7 @@
|
|||||||
"version": "7.6.13",
|
"version": "7.6.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@@ -15119,6 +15120,123 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause"
|
"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": {
|
"node_modules/ds-store": {
|
||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"resolved": "https://registry.npmmirror.com/ds-store/-/ds-store-0.1.6.tgz",
|
"resolved": "https://registry.npmmirror.com/ds-store/-/ds-store-0.1.6.tgz",
|
||||||
|
|||||||
@@ -127,6 +127,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"drizzle-orm": "^0.39.1",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
"find-process": "^2.0.0",
|
"find-process": "^2.0.0",
|
||||||
|
|||||||
+508
-258
@@ -2,12 +2,50 @@ import Database from 'better-sqlite3';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
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 { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { CloudAccount } from '../../types/cloudAccount';
|
import { CloudAccount } from '../../types/cloudAccount';
|
||||||
|
import { ItemTableValueRowSchema, TableInfoRowSchema } from '../../types/db';
|
||||||
import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security';
|
import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security';
|
||||||
import { ProtobufUtils } from '../../utils/protobuf';
|
import { ProtobufUtils } from '../../utils/protobuf';
|
||||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
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<typeof drizzleSchema>,
|
||||||
|
'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.
|
* Ensures that the cloud database file and schema exist.
|
||||||
@@ -22,7 +60,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
|||||||
let db: Database.Database | null = null;
|
let db: Database.Database | null = null;
|
||||||
try {
|
try {
|
||||||
db = new Database(dbPath);
|
db = new Database(dbPath);
|
||||||
db.pragma('journal_mode = WAL');
|
configureDatabase(db, { busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS });
|
||||||
|
|
||||||
// Create accounts table
|
// Create accounts table
|
||||||
// Storing complex objects (token, quota) as JSON strings for simplicity
|
// 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
|
// 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');
|
const hasIsActive = tableInfo.some((col) => col.name === 'is_active');
|
||||||
if (!hasIsActive) {
|
if (!hasIsActive) {
|
||||||
db.exec('ALTER TABLE accounts ADD COLUMN is_active INTEGER DEFAULT 0');
|
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.
|
* Gets a connection to the cloud accounts database.
|
||||||
*/
|
*/
|
||||||
function getDb(): Database.Database {
|
function getCloudDb(): {
|
||||||
|
raw: Database.Database;
|
||||||
|
orm: BetterSQLite3Database<typeof drizzleSchema>;
|
||||||
|
} {
|
||||||
const dbPath = getCloudAccountsDbPath();
|
const dbPath = getCloudAccountsDbPath();
|
||||||
ensureDatabaseInitialized(dbPath);
|
ensureDatabaseInitialized(dbPath);
|
||||||
const db = new Database(dbPath);
|
return openDrizzleConnection(
|
||||||
db.pragma('journal_mode = WAL');
|
dbPath,
|
||||||
return db;
|
{ readonly: false, fileMustExist: false },
|
||||||
|
{ busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIdeDb(
|
||||||
|
dbPath: string,
|
||||||
|
readOnly: boolean,
|
||||||
|
): { raw: Database.Database; orm: BetterSQLite3Database<typeof drizzleSchema> } {
|
||||||
|
return openDrizzleConnection(
|
||||||
|
dbPath,
|
||||||
|
{ readonly: readOnly },
|
||||||
|
{ readOnly, busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MigrationStats {
|
interface MigrationStats {
|
||||||
@@ -102,9 +157,9 @@ function createMigrationStats(): MigrationStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function decryptAndMigrateField(
|
async function decryptAndMigrateField(
|
||||||
db: Database.Database,
|
orm: DrizzleExecutor,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
field: 'token_json' | 'quota_json',
|
field: 'tokenJson' | 'quotaJson',
|
||||||
value: string | null,
|
value: string | null,
|
||||||
): Promise<{ value: string | null; migrated: boolean; usedFallback?: KeySource }> {
|
): Promise<{ value: string | null; migrated: boolean; usedFallback?: KeySource }> {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -113,16 +168,18 @@ async function decryptAndMigrateField(
|
|||||||
|
|
||||||
const result = await decryptWithMigration(value);
|
const result = await decryptWithMigration(value);
|
||||||
if (result.reencrypted) {
|
if (result.reencrypted) {
|
||||||
if (field === 'token_json') {
|
if (field === 'tokenJson') {
|
||||||
db.prepare('UPDATE accounts SET token_json = ? WHERE id = ?').run(
|
orm
|
||||||
result.reencrypted,
|
.update(accounts)
|
||||||
accountId,
|
.set({ tokenJson: result.reencrypted })
|
||||||
);
|
.where(eq(accounts.id, accountId))
|
||||||
|
.run();
|
||||||
} else {
|
} else {
|
||||||
db.prepare('UPDATE accounts SET quota_json = ? WHERE id = ?').run(
|
orm
|
||||||
result.reencrypted,
|
.update(accounts)
|
||||||
accountId,
|
.set({ quotaJson: result.reencrypted })
|
||||||
);
|
.where(eq(accounts.id, accountId))
|
||||||
|
.run();
|
||||||
}
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
`Migrated ${field} for account ${accountId} from ${result.usedFallback ?? 'unknown'} key`,
|
`Migrated ${field} for account ${accountId} from ${result.usedFallback ?? 'unknown'} key`,
|
||||||
@@ -139,6 +196,8 @@ async function decryptAndMigrateField(
|
|||||||
type DecryptFieldResult = Awaited<ReturnType<typeof decryptAndMigrateField>>;
|
type DecryptFieldResult = Awaited<ReturnType<typeof decryptAndMigrateField>>;
|
||||||
|
|
||||||
export class CloudAccountRepo {
|
export class CloudAccountRepo {
|
||||||
|
private static versionFailureLogged = false;
|
||||||
|
|
||||||
static async init(): Promise<void> {
|
static async init(): Promise<void> {
|
||||||
const dbPath = getCloudAccountsDbPath();
|
const dbPath = getCloudAccountsDbPath();
|
||||||
ensureDatabaseInitialized(dbPath);
|
ensureDatabaseInitialized(dbPath);
|
||||||
@@ -146,14 +205,21 @@ export class CloudAccountRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async migrateToEncrypted(): Promise<void> {
|
static async migrateToEncrypted(): Promise<void> {
|
||||||
const db = getDb();
|
const { raw, orm } = getCloudDb();
|
||||||
try {
|
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) {
|
for (const row of rows) {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
let newToken = row.token_json;
|
let newToken = row.tokenJson;
|
||||||
let newQuota = row.quota_json;
|
let newQuota = row.quotaJson;
|
||||||
|
|
||||||
// Check if plain text (starts with {)
|
// Check if plain text (starts with {)
|
||||||
if (newToken && newToken.startsWith('{')) {
|
if (newToken && newToken.startsWith('{')) {
|
||||||
@@ -166,105 +232,109 @@ export class CloudAccountRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
db.prepare('UPDATE accounts SET token_json = ?, quota_json = ? WHERE id = ?').run(
|
orm
|
||||||
newToken,
|
.update(accounts)
|
||||||
newQuota,
|
.set({ tokenJson: newToken, quotaJson: newQuota })
|
||||||
row.id,
|
.where(eq(accounts.id, row.id))
|
||||||
);
|
.run();
|
||||||
logger.info(`Migrated account ${row.id} to encrypted storage`);
|
logger.info(`Migrated account ${row.id} to encrypted storage`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to migrate data', e);
|
logger.error('Failed to migrate data', e);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
raw.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async addAccount(account: CloudAccount): Promise<void> {
|
static async addAccount(account: CloudAccount): Promise<void> {
|
||||||
const db = getDb();
|
const { raw, orm } = getCloudDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokenEncrypted = await encrypt(JSON.stringify(account.token));
|
const tokenEncrypted = await encrypt(JSON.stringify(account.token));
|
||||||
const quotaEncrypted = account.quota ? await encrypt(JSON.stringify(account.quota)) : null;
|
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 this account is being set to active, deactivate all others first
|
||||||
if (account.is_active) {
|
if (account.is_active) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[DEBUG] addAccount: Deactivating all other accounts because ${account.email} is active`,
|
`[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`);
|
logger.info(`[DEBUG] addAccount: Deactivation changed ${info.changes} rows`);
|
||||||
}
|
}
|
||||||
|
tx.insert(accounts)
|
||||||
const stmt = db.prepare(`
|
.values(values)
|
||||||
INSERT OR REPLACE INTO accounts (
|
.onConflictDoUpdate({
|
||||||
id, provider, email, name, avatar_url, token_json, quota_json, created_at, last_used, status, is_active
|
target: accounts.id,
|
||||||
) VALUES (
|
set: values,
|
||||||
@id, @provider, @email, @name, @avatar_url, @token_json, @quota_json, @created_at, @last_used, @status, @is_active
|
})
|
||||||
)
|
.run();
|
||||||
`);
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
transaction();
|
|
||||||
logger.info(`Added/Updated cloud account: ${account.email}`);
|
logger.info(`Added/Updated cloud account: ${account.email}`);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
raw.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getAccounts(): Promise<CloudAccount[]> {
|
static async getAccounts(): Promise<CloudAccount[]> {
|
||||||
const db = getDb();
|
const { raw, orm } = getCloudDb();
|
||||||
const migrationStats = createMigrationStats();
|
const migrationStats = createMigrationStats();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stmt = db.prepare('SELECT * FROM accounts ORDER BY last_used DESC');
|
const rows = orm.select().from(accounts).orderBy(desc(accounts.lastUsed)).all();
|
||||||
const rows = stmt.all() as any[];
|
|
||||||
|
|
||||||
// DEBUG LOGS
|
// DEBUG LOGS
|
||||||
const activeRows = rows.filter((r) => r.is_active);
|
const activeRows = rows.filter((r) => r.isActive);
|
||||||
logger.info(
|
logger.info(
|
||||||
`[DEBUG] getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`,
|
`[DEBUG] getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`,
|
||||||
);
|
);
|
||||||
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
|
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
|
||||||
|
|
||||||
const accounts: CloudAccount[] = [];
|
const cloudAccounts: CloudAccount[] = [];
|
||||||
for (const row of rows) {
|
for (const normalizedRow of rows) {
|
||||||
let tokenResult: DecryptFieldResult;
|
let tokenResult: DecryptFieldResult;
|
||||||
try {
|
try {
|
||||||
tokenResult = await decryptAndMigrateField(db, row.id, 'token_json', row.token_json);
|
tokenResult = await decryptAndMigrateField(
|
||||||
|
orm,
|
||||||
|
normalizedRow.id,
|
||||||
|
'tokenJson',
|
||||||
|
normalizedRow.tokenJson,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
migrationStats.failedFields += 1;
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
let quotaResult: DecryptFieldResult;
|
let quotaResult: DecryptFieldResult;
|
||||||
try {
|
try {
|
||||||
quotaResult = await decryptAndMigrateField(db, row.id, 'quota_json', row.quota_json);
|
quotaResult = await decryptAndMigrateField(
|
||||||
|
orm,
|
||||||
|
normalizedRow.id,
|
||||||
|
'quotaJson',
|
||||||
|
normalizedRow.quotaJson,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
migrationStats.failedFields += 1;
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tokenResult.value) {
|
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) {
|
if (tokenResult.value) {
|
||||||
@@ -293,22 +363,22 @@ export class CloudAccountRepo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
accounts.push({
|
cloudAccounts.push({
|
||||||
id: row.id,
|
id: normalizedRow.id,
|
||||||
provider: row.provider,
|
provider: normalizedRow.provider as CloudAccount['provider'],
|
||||||
email: row.email,
|
email: normalizedRow.email,
|
||||||
name: row.name,
|
name: normalizedRow.name ?? undefined,
|
||||||
avatar_url: row.avatar_url,
|
avatar_url: normalizedRow.avatarUrl ?? undefined,
|
||||||
token: JSON.parse(tokenResult.value),
|
token: JSON.parse(tokenResult.value),
|
||||||
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||||
created_at: row.created_at,
|
created_at: normalizedRow.createdAt,
|
||||||
last_used: row.last_used,
|
last_used: normalizedRow.lastUsed,
|
||||||
status: row.status,
|
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||||
is_active: Boolean(row.is_active),
|
is_active: Boolean(normalizedRow.isActive),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return accounts;
|
return cloudAccounts;
|
||||||
} finally {
|
} finally {
|
||||||
if (
|
if (
|
||||||
migrationStats.migratedFields > 0 ||
|
migrationStats.migratedFields > 0 ||
|
||||||
@@ -328,106 +398,321 @@ export class CloudAccountRepo {
|
|||||||
logger.info('CloudAccountRepo migration summary', summary);
|
logger.info('CloudAccountRepo migration summary', summary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
db.close();
|
raw.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getAccount(id: string): Promise<CloudAccount | undefined> {
|
static async getAccount(id: string): Promise<CloudAccount | undefined> {
|
||||||
const db = getDb();
|
const { raw, orm } = getCloudDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stmt = db.prepare('SELECT * FROM accounts WHERE id = ?');
|
const rows = orm.select().from(accounts).where(eq(accounts.id, id)).all();
|
||||||
const row = stmt.get(id) as any;
|
const normalizedRow = rows[0];
|
||||||
|
if (!normalizedRow) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (!row) return undefined;
|
const tokenResult = await decryptAndMigrateField(
|
||||||
|
orm,
|
||||||
const tokenResult = await decryptAndMigrateField(db, row.id, 'token_json', row.token_json);
|
normalizedRow.id,
|
||||||
const quotaResult = await decryptAndMigrateField(db, row.id, 'quota_json', row.quota_json);
|
'tokenJson',
|
||||||
|
normalizedRow.tokenJson,
|
||||||
|
);
|
||||||
|
const quotaResult = await decryptAndMigrateField(
|
||||||
|
orm,
|
||||||
|
normalizedRow.id,
|
||||||
|
'quotaJson',
|
||||||
|
normalizedRow.quotaJson,
|
||||||
|
);
|
||||||
|
|
||||||
if (!tokenResult.value) {
|
if (!tokenResult.value) {
|
||||||
throw new Error(`Missing token data for account ${row.id}`);
|
throw new Error(`Missing token data for account ${normalizedRow.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: normalizedRow.id,
|
||||||
provider: row.provider,
|
provider: normalizedRow.provider as CloudAccount['provider'],
|
||||||
email: row.email,
|
email: normalizedRow.email,
|
||||||
name: row.name,
|
name: normalizedRow.name ?? undefined,
|
||||||
avatar_url: row.avatar_url,
|
avatar_url: normalizedRow.avatarUrl ?? undefined,
|
||||||
token: JSON.parse(tokenResult.value),
|
token: JSON.parse(tokenResult.value),
|
||||||
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||||
created_at: row.created_at,
|
created_at: normalizedRow.createdAt,
|
||||||
last_used: row.last_used,
|
last_used: normalizedRow.lastUsed,
|
||||||
status: row.status,
|
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||||
is_active: Boolean(row.is_active),
|
is_active: Boolean(normalizedRow.isActive),
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
raw.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async removeAccount(id: string): Promise<void> {
|
static async removeAccount(id: string): Promise<void> {
|
||||||
const db = getDb();
|
const { raw, orm } = getCloudDb();
|
||||||
try {
|
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}`);
|
logger.info(`Removed cloud account: ${id}`);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
raw.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async updateToken(id: string, token: any): Promise<void> {
|
static async updateToken(id: string, token: any): Promise<void> {
|
||||||
const db = getDb();
|
const { raw, orm } = getCloudDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encrypted = await encrypt(JSON.stringify(token));
|
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 {
|
} finally {
|
||||||
db.close();
|
raw.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async updateQuota(id: string, quota: any): Promise<void> {
|
static async updateQuota(id: string, quota: any): Promise<void> {
|
||||||
const db = getDb();
|
const { raw, orm } = getCloudDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encrypted = await encrypt(JSON.stringify(quota));
|
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 {
|
} finally {
|
||||||
db.close();
|
raw.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static updateLastUsed(id: string): void {
|
static updateLastUsed(id: string): void {
|
||||||
const db = getDb();
|
const { raw, orm } = getCloudDb();
|
||||||
try {
|
try {
|
||||||
db.prepare('UPDATE accounts SET last_used = ? WHERE id = ?').run(
|
orm
|
||||||
Math.floor(Date.now() / 1000),
|
.update(accounts)
|
||||||
id,
|
.set({ lastUsed: Math.floor(Date.now() / 1000) })
|
||||||
);
|
.where(eq(accounts.id, id))
|
||||||
|
.run();
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
raw.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static setActive(id: string): void {
|
static setActive(id: string): void {
|
||||||
const db = getDb();
|
const { raw, orm } = getCloudDb();
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
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`);
|
logger.info(`Set account ${id} as active`);
|
||||||
} finally {
|
} 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<typeof drizzleSchema>,
|
||||||
|
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<typeof drizzleSchema>,
|
||||||
|
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<typeof drizzleSchema>, 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 {
|
static injectCloudToken(account: CloudAccount): void {
|
||||||
const dbPaths = getAntigravityDbPaths();
|
const dbPaths = getAntigravityDbPaths();
|
||||||
const dbPath = dbPaths.find((p) => fs.existsSync(p)) ?? null;
|
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(', ')}`);
|
throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = new Database(dbPath);
|
const result = this.injectWithRetry(dbPath, account);
|
||||||
db.pragma('journal_mode = WAL');
|
logger.info(
|
||||||
try {
|
`Successfully injected cloud token and identity for ${account.email} into Antigravity database at ${dbPath} (strategy=${result.strategy}, attempts=${result.attempts}).`,
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getSetting<T>(key: string, defaultValue: T): T {
|
static getSetting<T>(key: string, defaultValue: T): T {
|
||||||
const db = getDb();
|
const { raw, orm } = getCloudDb();
|
||||||
try {
|
try {
|
||||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as
|
const rows = orm
|
||||||
| { value: string }
|
.select({ value: settings.value })
|
||||||
| undefined;
|
.from(settings)
|
||||||
if (!row) return defaultValue;
|
.where(eq(settings.key, key))
|
||||||
|
.all();
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
return JSON.parse(row.value) as T;
|
return JSON.parse(row.value) as T;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Failed to get setting ${key}`, e);
|
logger.error(`Failed to get setting ${key}`, e);
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
raw.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static setSetting(key: string, value: any): void {
|
static setSetting(key: string, value: any): void {
|
||||||
const db = getDb();
|
const { raw, orm } = getCloudDb();
|
||||||
try {
|
try {
|
||||||
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(
|
const stringValue = JSON.stringify(value);
|
||||||
key,
|
orm
|
||||||
JSON.stringify(value),
|
.insert(settings)
|
||||||
);
|
.values({ key, value: stringValue })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: settings.key,
|
||||||
|
set: { value: stringValue },
|
||||||
|
})
|
||||||
|
.run();
|
||||||
} finally {
|
} 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<CloudAccount | null> {
|
static async syncFromIDE(): Promise<CloudAccount | null> {
|
||||||
// Try all possible database paths
|
// Try all possible database paths
|
||||||
const dbPaths = getAntigravityDbPaths();
|
const dbPaths = getAntigravityDbPaths();
|
||||||
@@ -581,32 +857,8 @@ export class CloudAccountRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`);
|
logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`);
|
||||||
const ideDb = new Database(dbPath, { readonly: true });
|
|
||||||
ideDb.pragma('journal_mode = WAL');
|
|
||||||
try {
|
try {
|
||||||
// 1. Read Raw Token Data
|
const tokenInfo = this.readTokenInfoWithRetry(dbPath);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fetch User Info
|
// 3. Fetch User Info
|
||||||
// We need to fetch user info to know who this token belongs to
|
// We need to fetch user info to know who this token belongs to
|
||||||
@@ -656,8 +908,6 @@ export class CloudAccountRepo {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('SyncLocal: Failed to sync account from IDE', error);
|
logger.error('SyncLocal: Failed to sync account from IDE', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
|
||||||
ideDb.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<typeof schema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
+95
-66
@@ -1,17 +1,27 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import fs from 'fs';
|
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 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 {
|
function openIdeDb(dbPath: string, readOnly = false): ReturnType<typeof openDrizzleConnection> {
|
||||||
const db = new Database(dbPath, options);
|
return openDrizzleConnection(
|
||||||
db.pragma('journal_mode = WAL');
|
dbPath,
|
||||||
return db;
|
{ readonly: readOnly, fileMustExist: false },
|
||||||
|
{ readOnly, busyTimeoutMs: 3000 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,8 +35,8 @@ export function initDatabase(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getDatabaseConnection();
|
const { raw } = getDatabaseConnection();
|
||||||
db.close();
|
raw.close();
|
||||||
logger.info('Database initialized and verified (WAL mode)');
|
logger.info('Database initialized and verified (WAL mode)');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to initialize database on startup', error);
|
logger.error('Failed to initialize database on startup', error);
|
||||||
@@ -72,9 +82,9 @@ function ensureDatabaseExists(dbPath: string): void {
|
|||||||
/**
|
/**
|
||||||
* Gets a database connection.
|
* Gets a database connection.
|
||||||
* @param dbPath {string} The path to the database file.
|
* @param dbPath {string} The path to the database file.
|
||||||
* @returns {Database.Database} The database connection.
|
* @returns {ReturnType<typeof openDrizzleConnection>} The database connection.
|
||||||
*/
|
*/
|
||||||
export function getDatabaseConnection(dbPath?: string): Database.Database {
|
export function getDatabaseConnection(dbPath?: string): ReturnType<typeof openDrizzleConnection> {
|
||||||
const targetPath = dbPath || getAntigravityDbPaths()[0];
|
const targetPath = dbPath || getAntigravityDbPaths()[0];
|
||||||
|
|
||||||
if (!targetPath) {
|
if (!targetPath) {
|
||||||
@@ -84,7 +94,7 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
|
|||||||
ensureDatabaseExists(targetPath);
|
ensureDatabaseExists(targetPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return getDbWithWal(targetPath, { readonly: false, fileMustExist: false });
|
return openIdeDb(targetPath);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { code?: string };
|
const err = error as { code?: string };
|
||||||
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
|
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
|
||||||
@@ -94,11 +104,19 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function readItemValue(
|
||||||
* Gets a database connection and enables WAL mode.
|
orm: ReturnType<typeof openDrizzleConnection>['orm'],
|
||||||
* @param dbPath {string} The path to the database file.
|
key: string,
|
||||||
* @returns {Database.Database} The database connection.
|
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.
|
* Gets the current account info.
|
||||||
@@ -106,61 +124,66 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
|
|||||||
*/
|
*/
|
||||||
export function getCurrentAccountInfo(): AccountInfo {
|
export function getCurrentAccountInfo(): AccountInfo {
|
||||||
// NOTE Database existence is now handled by getDatabaseConnection
|
// NOTE Database existence is now handled by getDatabaseConnection
|
||||||
let db: Database.Database | null = null;
|
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||||
try {
|
try {
|
||||||
db = getDatabaseConnection();
|
connection = getDatabaseConnection();
|
||||||
|
const { orm } = connection;
|
||||||
|
|
||||||
// Query for auth status
|
// Query for auth status
|
||||||
const authRow = db
|
const authValue = readItemValue(
|
||||||
.prepare("SELECT value FROM ItemTable WHERE key = 'antigravityAuthStatus'")
|
orm,
|
||||||
.get() as { value: string } | undefined;
|
'antigravityAuthStatus',
|
||||||
|
'ide.itemTable.antigravityAuthStatus',
|
||||||
|
);
|
||||||
let authStatus = null;
|
let authStatus = null;
|
||||||
if (authRow) {
|
if (authValue) {
|
||||||
try {
|
try {
|
||||||
authStatus = JSON.parse(authRow.value);
|
authStatus = JSON.parse(authValue);
|
||||||
} catch {
|
} catch {
|
||||||
// NOTE Ignore JSON parse errors
|
// NOTE Ignore JSON parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE Query for user info (usually in jetskiStateSync.agentManagerInitState or similar)
|
// NOTE Query for user info (usually in jetskiStateSync.agentManagerInitState or similar)
|
||||||
const initRow = db
|
const initValue = readItemValue(
|
||||||
.prepare("SELECT value FROM ItemTable WHERE key = 'jetskiStateSync.agentManagerInitState'")
|
orm,
|
||||||
.get() as { value: string } | undefined;
|
'jetskiStateSync.agentManagerInitState',
|
||||||
|
'ide.itemTable.jetskiStateSync.agentManagerInitState',
|
||||||
|
);
|
||||||
let initState = null;
|
let initState = null;
|
||||||
if (initRow) {
|
if (initValue) {
|
||||||
try {
|
try {
|
||||||
initState = JSON.parse(initRow.value);
|
initState = JSON.parse(initValue);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore JSON parse errors (this key often contains non-JSON data)
|
// Ignore JSON parse errors (this key often contains non-JSON data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query for google.antigravity
|
// Query for google.antigravity
|
||||||
const googleRow = db
|
const googleValue = readItemValue(
|
||||||
.prepare("SELECT value FROM ItemTable WHERE key = 'google.antigravity'")
|
orm,
|
||||||
.get() as { value: string } | undefined;
|
'google.antigravity',
|
||||||
|
'ide.itemTable.google.antigravity',
|
||||||
|
);
|
||||||
let googleState = null;
|
let googleState = null;
|
||||||
if (googleRow) {
|
if (googleValue) {
|
||||||
try {
|
try {
|
||||||
googleState = JSON.parse(googleRow.value);
|
googleState = JSON.parse(googleValue);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore JSON parse errors
|
// Ignore JSON parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query for antigravityUserSettings.allUserSettings
|
// Query for antigravityUserSettings.allUserSettings
|
||||||
const settingsRow = db
|
const settingsValue = readItemValue(
|
||||||
.prepare("SELECT value FROM ItemTable WHERE key = 'antigravityUserSettings.allUserSettings'")
|
orm,
|
||||||
.get() as { value: string } | undefined;
|
'antigravityUserSettings.allUserSettings',
|
||||||
|
'ide.itemTable.antigravityUserSettings.allUserSettings',
|
||||||
|
);
|
||||||
let settingsState = null;
|
let settingsState = null;
|
||||||
if (settingsRow) {
|
if (settingsValue) {
|
||||||
try {
|
try {
|
||||||
settingsState = JSON.parse(settingsRow.value);
|
settingsState = JSON.parse(settingsValue);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore JSON parse errors
|
// Ignore JSON parse errors
|
||||||
}
|
}
|
||||||
@@ -195,28 +218,28 @@ export function getCurrentAccountInfo(): AccountInfo {
|
|||||||
logger.error('Failed to get current account info', error);
|
logger.error('Failed to get current account info', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (db) db.close();
|
if (connection) {
|
||||||
|
connection.raw.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function backupAccount(account: AccountBackupData['account']): AccountBackupData {
|
export function backupAccount(account: AccountBackupData['account']): AccountBackupData {
|
||||||
let db: Database.Database | null = null;
|
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||||
try {
|
try {
|
||||||
db = getDatabaseConnection();
|
connection = getDatabaseConnection();
|
||||||
|
const { orm } = connection;
|
||||||
|
|
||||||
// NOTE Backup only specific keys
|
// NOTE Backup only specific keys
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
|
|
||||||
for (const key of KEYS_TO_BACKUP) {
|
for (const key of KEYS_TO_BACKUP) {
|
||||||
const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(key) as
|
const value = readItemValue(orm, key, `ide.itemTable.backup.${key}`);
|
||||||
| { value: string }
|
if (value) {
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (row) {
|
|
||||||
try {
|
try {
|
||||||
data[key] = JSON.parse(row.value);
|
data[key] = JSON.parse(value);
|
||||||
} catch {
|
} catch {
|
||||||
data[key] = row.value;
|
data[key] = value;
|
||||||
}
|
}
|
||||||
logger.debug(`Backed up key: ${key}`);
|
logger.debug(`Backed up key: ${key}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -237,7 +260,9 @@ export function backupAccount(account: AccountBackupData['account']): AccountBac
|
|||||||
logger.error('Failed to backup account', error);
|
logger.error('Failed to backup account', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} 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}`);
|
logger.info(`Restoring database: ${dbPath}`);
|
||||||
let db: Database.Database | null = null;
|
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db = getDatabaseConnection(dbPath);
|
connection = getDatabaseConnection(dbPath);
|
||||||
|
const { orm } = connection;
|
||||||
const insert = db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)');
|
orm.transaction((tx) => {
|
||||||
|
|
||||||
const transaction = db.transaction(() => {
|
|
||||||
// NOTE Only restore the keys that were backed up
|
// NOTE Only restore the keys that were backed up
|
||||||
for (const key of KEYS_TO_BACKUP) {
|
for (const key of KEYS_TO_BACKUP) {
|
||||||
if (key in backup.data) {
|
if (key in backup.data) {
|
||||||
const value = backup.data[key];
|
const value = backup.data[key];
|
||||||
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
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}`);
|
logger.debug(`Restored key: ${key}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
transaction();
|
|
||||||
logger.info(`Database restoration complete: ${dbPath}`);
|
logger.info(`Database restoration complete: ${dbPath}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to restore database: ${dbPath}`, error);
|
logger.error(`Failed to restore database: ${dbPath}`, error);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
if (db) db.close();
|
if (connection) {
|
||||||
|
connection.raw.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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'),
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>;
|
||||||
|
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('[email protected]', '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('[email protected]');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('[email protected]', '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('[email protected]');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('[email protected]', '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('[email protected]');
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '[email protected]',
|
||||||
|
name: 'Test',
|
||||||
|
avatar_url: '',
|
||||||
|
token: {
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
expires_in: 3600,
|
||||||
|
expiry_timestamp: 1700000000,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
email: '[email protected]',
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string> = {};
|
||||||
|
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: '[email protected]',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
last_used: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
antigravityAuthStatus: '{"email":"[email protected]"}',
|
||||||
|
'jetskiStateSync.agentManagerInitState': 'old',
|
||||||
|
'antigravityUnifiedStateSync.oauthToken': 'unified',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(writes['antigravityUnifiedStateSync.oauthToken']).toBe('unified');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,9 +54,9 @@ describe.skip('Database Handler', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should connect to database', () => {
|
it('should connect to database', () => {
|
||||||
const db = getDatabaseConnection();
|
const { raw } = getDatabaseConnection();
|
||||||
expect(db).toBeDefined();
|
expect(raw).toBeDefined();
|
||||||
db.close();
|
raw.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get current account info', () => {
|
it('should get current account info', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ export interface AccountBackupData {
|
|||||||
// Key-value pairs from Antigravity database
|
// Key-value pairs from Antigravity database
|
||||||
antigravityAuthStatus?: string;
|
antigravityAuthStatus?: string;
|
||||||
'jetskiStateSync.agentManagerInitState'?: string;
|
'jetskiStateSync.agentManagerInitState'?: string;
|
||||||
|
'antigravityUnifiedStateSync.oauthToken'?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof CloudAccountRowSchema>;
|
||||||
|
|
||||||
|
export const AccountTokenRowSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
token_json: z.string(),
|
||||||
|
quota_json: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AccountTokenRow = z.infer<typeof AccountTokenRowSchema>;
|
||||||
|
|
||||||
|
export const SettingsRowSchema = z.object({
|
||||||
|
key: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SettingsRow = z.infer<typeof SettingsRowSchema>;
|
||||||
|
|
||||||
|
export const SettingsValueRowSchema = z.object({
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SettingsValueRow = z.infer<typeof SettingsValueRowSchema>;
|
||||||
|
|
||||||
|
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<typeof ItemTableKeySchema>;
|
||||||
|
|
||||||
|
export const ItemTableRowSchema = z.object({
|
||||||
|
key: z.string(),
|
||||||
|
value: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ItemTableRow = z.infer<typeof ItemTableRowSchema>;
|
||||||
|
|
||||||
|
export const ItemTableValueRowSchema = z.object({
|
||||||
|
value: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ItemTableValueRow = z.infer<typeof ItemTableValueRowSchema>;
|
||||||
|
|
||||||
|
export const TableInfoRowSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TableInfoRow = z.infer<typeof TableInfoRowSchema>;
|
||||||
@@ -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}<\\/key>\\s*<string>([^<]+)<\\/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;
|
||||||
|
}
|
||||||
@@ -93,6 +93,24 @@ export class ProtobufUtils {
|
|||||||
return result;
|
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)
|
// Create timestamp field (Field 4 -> Field 1 varint)
|
||||||
static createTimestampField(fieldNum: number, seconds: number): Uint8Array {
|
static createTimestampField(fieldNum: number, seconds: number): Uint8Array {
|
||||||
// Timestamp message format: Field 1 (seconds) as varint
|
// Timestamp message format: Field 1 (seconds) as varint
|
||||||
@@ -198,4 +216,92 @@ export class ProtobufUtils {
|
|||||||
|
|
||||||
return result;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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<T extends z.ZodTypeAny>(
|
||||||
|
schema: T,
|
||||||
|
row: unknown,
|
||||||
|
context: string,
|
||||||
|
): z.infer<T> | 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<T extends z.ZodTypeAny>(
|
||||||
|
schema: T,
|
||||||
|
rows: unknown[],
|
||||||
|
context: string,
|
||||||
|
): z.infer<T>[] {
|
||||||
|
const parsed: z.infer<T>[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const parsedRow = parseRow(schema, row, context);
|
||||||
|
if (parsedRow) {
|
||||||
|
parsed.push(parsedRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRowFromStatement<T extends z.ZodTypeAny>(
|
||||||
|
stmt: StatementGet,
|
||||||
|
schema: T,
|
||||||
|
context: string,
|
||||||
|
params: unknown[] = [],
|
||||||
|
): z.infer<T> | null {
|
||||||
|
const row: unknown = stmt.get(...params);
|
||||||
|
return parseRow(schema, row, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRowsFromStatement<T extends z.ZodTypeAny>(
|
||||||
|
stmt: StatementAll,
|
||||||
|
schema: T,
|
||||||
|
context: string,
|
||||||
|
params: unknown[] = [],
|
||||||
|
): z.infer<T>[] {
|
||||||
|
const rows: unknown[] = stmt.all(...params);
|
||||||
|
return parseRows(schema, rows, context);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user